mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge remote-tracking branch 'origin/main' into puddle-barometer
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Support adaptive thinking levels for Claude Opus and Sonnet 5 and later.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Let users open Agent Manager terminals in the VS Code terminal or an embedded side panel. The terminal button's dropdown picks the destination; the side panel shares the right-hand inspector with the diff view and keeps running in the background when hidden.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep conversations and workspace files unchanged when a checkpoint cannot be fully restored.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Include basic-auth credentials in the Local and Network Console URLs printed by `kilo console`, so users on headless hosts (no `DISPLAY`/`WAYLAND_DISPLAY`, SSH sessions, CI runners) can open the URL in a browser on another machine and reach the Console.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Keep Nix builds on the Bun version required by the repository.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Update the visible agent mode when cycling modes in Kilo sidebars and pending session tabs.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix settings changes sometimes failing to save and apply in VS Code.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Preserve parenthesized tilde expressions as literal text in rendered chat messages.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Fix session transcripts losing their final messages when the CLI exits — pending uploads are now flushed on shutdown and as soon as a session closes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show prompt input toggle tooltips instantly on hover instead of after a delay.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": minor
|
||||
---
|
||||
|
||||
Publish a signed GitHub-hosted JetBrains plugin build with the CLI bundled for offline installation.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Allow sending prompts while a session is busy and show queued prompts with a remove action.
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Changes from opencode v1.17.5 to v1.17.9 upstream:
|
||||
|
||||
- Core Bugfixes: Improved MCP server compatibility by declaring Kilo's supported client capabilities.
|
||||
- Core Bugfixes: Plugin client requests now reuse the active server instead of assuming the default local port.
|
||||
- Core Bugfixes: ACP shell tool calls now show the command and working directory from the start.
|
||||
- Core Bugfixes: Plugin-provided shell environment variables now apply to PTY sessions.
|
||||
- Core Bugfixes: OpenAI-compatible providers now accept MCP tool schemas that previously failed validation. (@jquense)
|
||||
- Core Bugfixes: Cloudflare AI Gateway now receives the configured API key correctly. (@keefetang)
|
||||
- Core Bugfixes: MCP tools without declared schema properties now work with providers that expect object properties.
|
||||
- Core Bugfixes: Long-running MCP tools now keep their timeout alive when they report progress. (@Nomadcxx)
|
||||
- Core Bugfixes: The MCP OAuth callback server now shuts down once authorization finishes or is cancelled.
|
||||
- Core Bugfixes: MCP tool failures now surface the server's error text instead of a generic failure.
|
||||
- Core Bugfixes: MCP OAuth error pages now escape provider error text correctly.
|
||||
- Core Bugfixes: Honor configured agent step limits by forcing a final text response instead of failing mid-run.
|
||||
- Core Bugfixes: Queue steering prompts before dismissing pending questions so the previous turn cannot resume first.
|
||||
- Core Bugfixes: Prevent local server credentials from leaking into spawned PTY processes.
|
||||
- Core Bugfixes: Fix Devstral model detection when provider IDs use different casing. (@Robin1987China)
|
||||
- Core Bugfixes: Pass configured custom headers to Copilot model requests.
|
||||
- Core Improvements: MCP servers can now receive the current workspace as a client root.
|
||||
- Core Improvements: Session timelines load much faster and avoid flicker or scroll jumps.
|
||||
- Core Improvements: Add `high` and `max` thinking variants for GLM-5.2 across supported providers. (@imranshaiedi-byte)
|
||||
- Core Improvements: Stop wrapping follow-up user messages in a steering reminder so prompt caching stays effective.
|
||||
- TUI Bugfixes: MCP debug now uses the SDK's latest protocol version.
|
||||
- TUI Bugfixes: Only show the background subagent shortcut when the server supports it.
|
||||
- UI Bugfixes: Render completed Mermaid blocks from diagram source instead of fenced Markdown.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Fix bash permission rules being bypassed on PowerShell for commands containing a bare `--` such as `git checkout -- <file>`. Commands the shell parser cannot parse now get checked against their raw command text instead of executing without a permission check.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Emit each agent event once from `kilo run --format json`.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Prevent the VS Code backend from eagerly starting native file watchers for every Agent Manager worktree.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep message and response copy buttons working after switching focus away from VS Code.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep Windows snapshot diffs parseable and preserve valid files when a stored patch is malformed.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Bound the wait for a provider's first response byte by the request timeout. A provider that accepts a request and returns headers but never sends body data now fails and retries instead of leaving the turn hanging after a tool call completes. The same `timeout` value now covers both the connection phase and the wait for the first byte as a single deadline; streaming responses that have already produced data are unaffected.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Stabilize cross-platform CLI subprocess tests under constrained CI runners
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Open Kilo chats, settings, and files as tabs in the selected editor pane without creating, locking, or resizing editor panes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Remove unused translation keys from the Agent Manager, sidebar webview, shared kilo-i18n, and autocomplete dictionaries across all locales, and add a conservative lint test for unreferenced, unprotected dictionary keys.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show the `Ctrl+T` variant cycling shortcut in the TUI prompt hint row whenever the active model exposes reasoning variants, as the first hint before the agent and command palette hints
|
||||
+108
-32
@@ -6,20 +6,22 @@
|
||||
* Batching bounds each `kilo run` context (a replay window can yield dozens
|
||||
* of docs-worthy PRs with large diffs). Each batch gets its own CLI session
|
||||
* and writes its own summary file; results are merged into
|
||||
* docs-sync-out/edit-summary.json. A batch that fails is skipped with a
|
||||
* warning — its PRs show up in the rolling PR body as skipped, so nothing
|
||||
* fails silently.
|
||||
* docs-sync-out/edit-summary.json. A batch that fails or is deferred by the
|
||||
* wall-clock budget is recorded as action "pending" so the watermark holds
|
||||
* back and the next run re-collects those PRs.
|
||||
*
|
||||
* Env: EDIT_MODEL (provider/model), KILO_API_KEY (set by workflow; read natively by the kilo provider).
|
||||
* Env: EDIT_MODEL (provider/model), KILO_API_KEY + KILO_ORG_ID (set by workflow; read natively by the kilo provider).
|
||||
* Budgets: EDIT_BUDGET_MINUTES (default 50), EDIT_BATCH_TIMEOUT_MINUTES (default 15).
|
||||
* Test hook: DOCS_SYNC_BACKOFF_MS replaces every retry wait when set.
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { backoffMsForAttempt, deadline, remainingMs, runKilo, sleepSync } from "./lib.mjs"
|
||||
|
||||
const BATCH_SIZE = 5
|
||||
const ATTEMPTS = 2
|
||||
const ATTEMPTS = 3
|
||||
const OUT_DIR = "docs-sync-out"
|
||||
export const SUMMARY_FILE = ".docs-sync-summary.json"
|
||||
|
||||
@@ -28,6 +30,10 @@ const basePrompt = fs.readFileSync(path.join(HERE, "edit-prompt.md"), "utf8")
|
||||
const model = process.env.EDIT_MODEL
|
||||
if (!model) throw new Error("EDIT_MODEL is required")
|
||||
|
||||
const EDIT_BUDGET_MINUTES = Number(process.env.EDIT_BUDGET_MINUTES) || 50
|
||||
const EDIT_BATCH_TIMEOUT_MINUTES = Number(process.env.EDIT_BATCH_TIMEOUT_MINUTES) || 15
|
||||
const BATCH_TIMEOUT_MS = EDIT_BATCH_TIMEOUT_MINUTES * 60 * 1000
|
||||
|
||||
const worthy = JSON.parse(fs.readFileSync(`${OUT_DIR}/worthy.json`, "utf8"))
|
||||
const triage = JSON.parse(fs.readFileSync(`${OUT_DIR}/triage.json`, "utf8"))
|
||||
const priority = new Map(triage.map((e) => [e.url, e]))
|
||||
@@ -36,7 +42,18 @@ const ordered = [...worthy].sort((a, b) => {
|
||||
return (rank[priority.get(a.url)?.priority] ?? 1) - (rank[priority.get(b.url)?.priority] ?? 1)
|
||||
})
|
||||
|
||||
function editBatch(batch, index) {
|
||||
/** @type {Map<string, string>} url → pending cause for failed/deferred batches */
|
||||
const pendingCauses = new Map()
|
||||
|
||||
function formatCause(result) {
|
||||
const bits = []
|
||||
if (result.timedOut) bits.push("timed out")
|
||||
if (result.exitCode !== null && result.exitCode !== undefined) bits.push(`exit ${result.exitCode}`)
|
||||
if (result.stderrTail) bits.push(result.stderrTail.replaceAll("\n", " ").slice(0, 200))
|
||||
return bits.join("; ") || "no diagnostic"
|
||||
}
|
||||
|
||||
function editBatch(batch, index, budgetDeadline) {
|
||||
const batchFile = `${OUT_DIR}/edit-batch-${index}.json`
|
||||
const triageFile = `${OUT_DIR}/edit-batch-triage-${index}.json`
|
||||
const summaryFile = `${OUT_DIR}/edit-summary-${index}.json`
|
||||
@@ -54,31 +71,59 @@ function editBatch(batch, index) {
|
||||
|
||||
Batch specifics for this run: the PRs to handle are in the attached ${batchFile} (full details) and ${triageFile} (triage verdicts). Handle ONLY the PRs in these batch files. When finished, write your per-PR results in the summary JSON format described above to the file \`${summaryFile}\` (path relative to the repository root).`
|
||||
|
||||
let lastCause = "edit pass failed"
|
||||
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
|
||||
try {
|
||||
// Message positional first: --file is multi-value and would otherwise
|
||||
// consume a trailing message as a file path ("File not found").
|
||||
execFileSync(
|
||||
"kilo",
|
||||
["run", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile],
|
||||
// stdout streams live to the Actions log; stderr is piped so failure
|
||||
// warnings can include the tail of the actual CLI error.
|
||||
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 25 * 60 * 1000, stdio: ["ignore", "inherit", "pipe"] },
|
||||
const left = remainingMs(budgetDeadline)
|
||||
if (left < BATCH_TIMEOUT_MS) {
|
||||
lastCause = `edit budget exhausted before batch ${index} attempt ${attempt} (${Math.ceil(left / 1000)}s left, need ${EDIT_BATCH_TIMEOUT_MINUTES}m)`
|
||||
console.warn(
|
||||
`batch ${index}: stopping retries — remaining budget cannot fit another ${EDIT_BATCH_TIMEOUT_MINUTES}m attempt`,
|
||||
)
|
||||
if (fs.existsSync(summaryFile)) return true
|
||||
// Tolerate the agent dropping the docs-sync-out/ prefix.
|
||||
const alt = path.basename(summaryFile)
|
||||
if (fs.existsSync(alt)) {
|
||||
fs.renameSync(alt, summaryFile)
|
||||
return true
|
||||
break
|
||||
}
|
||||
|
||||
const result = runKilo({
|
||||
args: ["run", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile],
|
||||
timeoutMs: Math.min(BATCH_TIMEOUT_MS, left),
|
||||
streamStdout: true,
|
||||
label: `edit batch ${index} attempt ${attempt}`,
|
||||
})
|
||||
|
||||
if (fs.existsSync(summaryFile)) return true
|
||||
// Tolerate the agent dropping the docs-sync-out/ prefix.
|
||||
const alt = path.basename(summaryFile)
|
||||
if (fs.existsSync(alt)) {
|
||||
fs.renameSync(alt, summaryFile)
|
||||
return true
|
||||
}
|
||||
|
||||
// Exit 0 is not success: missing summary is a failure logged WITH the
|
||||
// captured stderrTail and exit code on every attempt.
|
||||
const cause = formatCause(result)
|
||||
lastCause = `edit batch ${index}: ${cause}`
|
||||
console.warn(
|
||||
`batch ${index} attempt ${attempt}: summary file ${summaryFile} not produced` +
|
||||
` (exit ${result.exitCode}${result.timedOut ? ", timed out" : ""})` +
|
||||
(result.stderrTail ? `\nstderr tail:\n${result.stderrTail}` : "\nstderr tail: (empty)"),
|
||||
)
|
||||
|
||||
if (attempt < ATTEMPTS) {
|
||||
const wait = backoffMsForAttempt(attempt)
|
||||
// Skip the wait when the remaining budget cannot fit another attempt.
|
||||
const afterWait = remainingMs(budgetDeadline) - wait
|
||||
if (wait > 0 && afterWait >= BATCH_TIMEOUT_MS) {
|
||||
console.warn(`batch ${index}: backing off ${wait / 1000}s before attempt ${attempt + 1}`)
|
||||
sleepSync(wait)
|
||||
} else if (wait > 0) {
|
||||
console.warn(
|
||||
`batch ${index}: skipping backoff — remaining budget cannot fit attempt ${attempt + 1} after wait`,
|
||||
)
|
||||
}
|
||||
console.warn(`batch ${index} attempt ${attempt}: summary file ${summaryFile} not produced`)
|
||||
} catch (err) {
|
||||
const stderr = String(err.stderr ?? "").trim().split("\n").slice(-5).join("\n")
|
||||
console.warn(`batch ${index} attempt ${attempt}: kilo run failed: ${stderr || err.message}`)
|
||||
}
|
||||
}
|
||||
console.warn(`::warning::edit batch ${index} failed after ${ATTEMPTS} attempts; ${batch.length} PRs skipped`)
|
||||
|
||||
console.warn(`::warning::edit batch ${index} failed after up to ${ATTEMPTS} attempts; ${batch.length} PRs pending`)
|
||||
for (const d of batch) pendingCauses.set(d.url, lastCause)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -88,12 +133,34 @@ for (let i = 0; i < ordered.length; i += BATCH_SIZE) {
|
||||
}
|
||||
console.log(`editing docs for ${ordered.length} PRs in ${batches.length} batches of up to ${BATCH_SIZE}`)
|
||||
|
||||
const budgetDeadline = deadline(EDIT_BUDGET_MINUTES)
|
||||
let deferredFrom = -1
|
||||
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
editBatch(batches[i], i)
|
||||
const left = remainingMs(budgetDeadline)
|
||||
if (left < BATCH_TIMEOUT_MS) {
|
||||
deferredFrom = i
|
||||
const deferredPrs = batches.slice(i).reduce((n, b) => n + b.length, 0)
|
||||
console.warn(
|
||||
`stopping edit pass before batch ${i}: remaining budget (${Math.ceil(left / 1000)}s) cannot fit a ${EDIT_BATCH_TIMEOUT_MINUTES}m batch; deferring ${deferredPrs} PRs`,
|
||||
)
|
||||
const cause = `edit budget exhausted before batch ${i} (${Math.ceil(left / 1000)}s left)`
|
||||
for (let j = i; j < batches.length; j++) {
|
||||
for (const d of batches[j]) pendingCauses.set(d.url, cause)
|
||||
}
|
||||
break
|
||||
}
|
||||
editBatch(batches[i], i, budgetDeadline)
|
||||
}
|
||||
|
||||
if (deferredFrom >= 0) {
|
||||
console.warn(
|
||||
`edit pass deferred ${batches.slice(deferredFrom).reduce((n, b) => n + b.length, 0)} PRs due to wall-clock budget`,
|
||||
)
|
||||
}
|
||||
|
||||
// Merge batch summaries. Coverage: every worthy PR gets an entry so the PR
|
||||
// body accounts for it; failed batches show up as skipped.
|
||||
// body accounts for it; failed/deferred batches show up as pending (not skipped).
|
||||
const merged = []
|
||||
const seen = new Set()
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
@@ -108,15 +175,24 @@ for (let i = 0; i < batches.length; i++) {
|
||||
const url = String(e?.url ?? "")
|
||||
if (!url.startsWith("http") || seen.has(url)) continue
|
||||
seen.add(url)
|
||||
merged.push({ pr: Number(e.pr) || 0, url, action: String(e.action ?? "skipped"), reason: String(e.reason ?? "") })
|
||||
merged.push({
|
||||
pr: Number(e.pr) || 0,
|
||||
url,
|
||||
action: String(e.action ?? "skipped"),
|
||||
reason: String(e.reason ?? ""),
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const d of ordered) {
|
||||
if (seen.has(d.url)) continue
|
||||
merged.push({ pr: d.number, url: d.url, action: "skipped", reason: "edit pass failed or timed out for this PR" })
|
||||
const cause = pendingCauses.get(d.url) || "edit pass failed or timed out for this PR"
|
||||
merged.push({ pr: d.number, url: d.url, action: "pending", reason: cause })
|
||||
}
|
||||
|
||||
// upsert-pr.mjs consumes the merged summary from the repo root; the file is
|
||||
// removed there before committing so it never lands in the docs PR.
|
||||
fs.writeFileSync(SUMMARY_FILE, JSON.stringify(merged, null, 2))
|
||||
console.log(`edit pass complete: ${merged.filter((e) => e.action !== "skipped").length} changed, ${merged.filter((e) => e.action === "skipped").length} skipped`)
|
||||
const changed = merged.filter((e) => e.action !== "skipped" && e.action !== "pending").length
|
||||
const skipped = merged.filter((e) => e.action === "skipped").length
|
||||
const pending = merged.filter((e) => e.action === "pending").length
|
||||
console.log(`edit pass complete: ${changed} changed, ${skipped} skipped, ${pending} pending`)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* gh CLI.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process"
|
||||
import fs from "node:fs"
|
||||
|
||||
const API = "https://api.github.com"
|
||||
@@ -111,3 +112,97 @@ export function appendSummary(markdown) {
|
||||
const summary = process.env.GITHUB_STEP_SUMMARY
|
||||
if (summary) fs.appendFileSync(summary, markdown + "\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute deadline timestamp (ms since epoch) for a wall-clock budget.
|
||||
* Used by triage/edit to stop before the job timeout rather than silently
|
||||
* truncating.
|
||||
*/
|
||||
export function deadline(minutes) {
|
||||
return Date.now() + Number(minutes) * 60 * 1000
|
||||
}
|
||||
|
||||
/** Remaining milliseconds until a deadline; never negative. */
|
||||
export function remainingMs(deadlineMs) {
|
||||
return Math.max(0, Number(deadlineMs) - Date.now())
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoff schedule between kilo-run attempts. Production waits 60s then 300s
|
||||
* (observed outage lasted ~11 min; batch 8 recovered on attempt 2). When
|
||||
* DOCS_SYNC_BACKOFF_MS is set it replaces EVERY wait (`0` disables waiting);
|
||||
* the workflow never sets it — only selftests do.
|
||||
*/
|
||||
export function backoffMsForAttempt(attempt) {
|
||||
// attempt is 1-based; wait happens after attempt N before attempt N+1.
|
||||
const override = process.env.DOCS_SYNC_BACKOFF_MS
|
||||
if (override !== undefined && override !== "") {
|
||||
const n = Number(override)
|
||||
return Number.isFinite(n) && n >= 0 ? n : 0
|
||||
}
|
||||
// After attempt 1 → 60s; after attempt 2 → 300s; nothing after the last.
|
||||
if (attempt === 1) return 60_000
|
||||
if (attempt === 2) return 300_000
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking sleep used between kilo-run retries. Prefer this over async sleep
|
||||
* so edit/triage stay synchronous around spawnSync.
|
||||
*/
|
||||
export function sleepSync(ms) {
|
||||
const n = Number(ms)
|
||||
if (!Number.isFinite(n) || n <= 0) return
|
||||
const end = Date.now() + n
|
||||
// Atomics.wait is the portable Node sync sleep (no busy loop).
|
||||
const sab = new SharedArrayBuffer(4)
|
||||
const view = new Int32Array(sab)
|
||||
while (Date.now() < end) {
|
||||
const left = end - Date.now()
|
||||
if (left <= 0) break
|
||||
Atomics.wait(view, 0, 0, Math.min(left, 2_147_483_647))
|
||||
}
|
||||
}
|
||||
|
||||
const STDERR_TAIL_LINES = 20
|
||||
const STDERR_TAIL_CHARS = 4_000
|
||||
|
||||
function tailText(text, { lines = STDERR_TAIL_LINES, chars = STDERR_TAIL_CHARS } = {}) {
|
||||
const s = String(text ?? "").trim()
|
||||
if (!s) return ""
|
||||
const lastLines = s.split("\n").slice(-lines).join("\n")
|
||||
return lastLines.length > chars ? lastLines.slice(-chars) : lastLines
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `kilo` via spawnSync so stderr is always recoverable — including when
|
||||
* the child exits 0 after writing a diagnostic (execFileSync cannot return
|
||||
* piped stderr on exit 0; that path lost every diagnostic on run 30122603016).
|
||||
*
|
||||
* streamStdout:true → inherit fd 1 (edit live log); false → capture stdout
|
||||
* (triage parses it). stderr is always buffered.
|
||||
*/
|
||||
export function runKilo({ args, timeoutMs, streamStdout = false, label = "kilo" }) {
|
||||
const result = spawnSync("kilo", args, {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
timeout: timeoutMs,
|
||||
stdio: ["ignore", streamStdout ? "inherit" : "pipe", "pipe"],
|
||||
})
|
||||
|
||||
const timedOut = Boolean(result.error && result.error.code === "ETIMEDOUT")
|
||||
const exitCode =
|
||||
typeof result.status === "number" ? result.status : timedOut ? null : result.status === null ? null : result.status
|
||||
const stderrTail = tailText(result.stderr)
|
||||
const stdout = streamStdout ? "" : String(result.stdout ?? "")
|
||||
// ok is "process finished without OS-level failure". Callers still treat a
|
||||
// missing summary / unparseable output as failure even when ok is true —
|
||||
// exit 0 is not success for the docs-sync bot.
|
||||
const ok = !result.error && result.status === 0
|
||||
|
||||
if (result.error && !timedOut) {
|
||||
console.warn(`${label}: spawn error: ${result.error.message}`)
|
||||
}
|
||||
|
||||
return { ok, stdout, stderrTail, exitCode, timedOut }
|
||||
}
|
||||
|
||||
@@ -6,56 +6,100 @@
|
||||
* origin/main (preserves any human commits on the branch)
|
||||
* - otherwise -> fresh branch from origin/main (bot force-pushes later)
|
||||
*
|
||||
* Outputs: branch, mode (update|fresh), pr_number (empty when fresh).
|
||||
* Outputs: branch, mode (update|fresh|conflict), pr_number (empty when fresh).
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { api, appendOutput, repo, searchIssues } from "./lib.mjs"
|
||||
|
||||
export const DEFAULT_BRANCH = "docs/auto-sync"
|
||||
|
||||
const git = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim()
|
||||
const defaultGit = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim()
|
||||
|
||||
const prs = await searchIssues(`repo:${repo()} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 1 })
|
||||
|
||||
let mode = "fresh"
|
||||
let prNumber = ""
|
||||
let branch = DEFAULT_BRANCH
|
||||
|
||||
if (prs.length > 0) {
|
||||
const pr = await api(`/repos/${repo()}/pulls/${prs[0].number}`)
|
||||
branch = pr.head?.ref ?? DEFAULT_BRANCH
|
||||
prNumber = String(pr.number)
|
||||
git(["fetch", "origin", "main", branch])
|
||||
git(["checkout", branch])
|
||||
/**
|
||||
* Merge origin/main into the current branch. On a genuine conflict, abort the
|
||||
* merge, switch to a dated fallback branch from origin/main, and return
|
||||
* mode=conflict so human commits on the rolling branch stay untouched. Any
|
||||
* other merge failure (missing identity, corrupt ref, fetch issues) is
|
||||
* rethrown so the job fails loudly.
|
||||
*/
|
||||
export function mergeOrFallback({ branch, git = defaultGit }) {
|
||||
try {
|
||||
git(["merge", "origin/main", "--no-edit"])
|
||||
mode = "update"
|
||||
} catch {
|
||||
return { branch, mode: "update" }
|
||||
} catch (err) {
|
||||
// Conflict ⇔ unmerged index entries (or MERGE_HEAD still present).
|
||||
// Identity failures and similar abort before a merge is started, so
|
||||
// merge --abort would itself fail — those must rethrow.
|
||||
let unmerged = ""
|
||||
try {
|
||||
unmerged = git(["ls-files", "--unmerged"])
|
||||
} catch {
|
||||
// ls-files itself failing is not a conflict signal
|
||||
}
|
||||
let mergeInProgress = false
|
||||
try {
|
||||
git(["rev-parse", "-q", "--verify", "MERGE_HEAD"])
|
||||
mergeInProgress = true
|
||||
} catch {
|
||||
mergeInProgress = false
|
||||
}
|
||||
const isConflict = unmerged.length > 0 || mergeInProgress
|
||||
if (!isConflict) throw err
|
||||
|
||||
console.warn(`merge of origin/main into ${branch} conflicted.`)
|
||||
console.warn("Leaving the conflicted branch untouched so human commits are preserved; continuing on a fresh dated branch.")
|
||||
console.warn(
|
||||
"Leaving the conflicted branch untouched so human commits are preserved; continuing on a fresh dated branch.",
|
||||
)
|
||||
git(["merge", "--abort"])
|
||||
branch = `${DEFAULT_BRANCH}-${new Date().toISOString().slice(0, 10)}`
|
||||
const fallback = `${DEFAULT_BRANCH}-${new Date().toISOString().slice(0, 10)}`
|
||||
try {
|
||||
git(["fetch", "origin", `+refs/heads/${fallback}:refs/remotes/origin/${fallback}`])
|
||||
} catch {
|
||||
console.log(`dated branch ${fallback} does not exist on origin yet; will create it on push`)
|
||||
}
|
||||
git(["checkout", "-B", fallback, "origin/main"])
|
||||
return { branch: fallback, mode: "conflict" }
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const git = defaultGit
|
||||
const prs = await searchIssues(`repo:${repo()} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 1 })
|
||||
|
||||
let mode = "fresh"
|
||||
let prNumber = ""
|
||||
let branch = DEFAULT_BRANCH
|
||||
|
||||
if (prs.length > 0) {
|
||||
const pr = await api(`/repos/${repo()}/pulls/${prs[0].number}`)
|
||||
branch = pr.head?.ref ?? DEFAULT_BRANCH
|
||||
prNumber = String(pr.number)
|
||||
git(["fetch", "origin", "main", branch])
|
||||
git(["checkout", branch])
|
||||
;({ branch, mode } = mergeOrFallback({ branch, git }))
|
||||
} else {
|
||||
// Keep the remote-tracking ref current so the later --force-with-lease
|
||||
// push (stale branch left over from a merged/closed PR) is safe.
|
||||
try {
|
||||
git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`])
|
||||
} catch {
|
||||
console.log(`dated branch ${branch} does not exist on origin yet; will create it on push`)
|
||||
console.log(`branch ${branch} does not exist on origin yet; will create it on push`)
|
||||
}
|
||||
git(["checkout", "-B", branch, "origin/main"])
|
||||
mode = "conflict"
|
||||
}
|
||||
} else {
|
||||
// Keep the remote-tracking ref current so the later --force-with-lease
|
||||
// push (stale branch left over from a merged/closed PR) is safe.
|
||||
try {
|
||||
git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`])
|
||||
} catch {
|
||||
console.log(`branch ${branch} does not exist on origin yet; will create it on push`)
|
||||
}
|
||||
git(["checkout", "-B", branch, "origin/main"])
|
||||
|
||||
appendOutput("branch", branch)
|
||||
appendOutput("mode", mode)
|
||||
appendOutput("pr_number", prNumber)
|
||||
console.log(`branch ${branch} ready (mode=${mode}, pr=${prNumber || "none"})`)
|
||||
}
|
||||
|
||||
appendOutput("branch", branch)
|
||||
appendOutput("mode", mode)
|
||||
appendOutput("pr_number", prNumber)
|
||||
console.log(`branch ${branch} ready (mode=${mode}, pr=${prNumber || "none"})`)
|
||||
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
if (isMain) {
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,964 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* Offline self-check for the docs-sync failure paths (S4).
|
||||
* Plain node:assert, no network, no LLM, no new dependency.
|
||||
* Run: node .github/docs-sync/selftest.mjs
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict"
|
||||
import { execFileSync, spawnSync } from "node:child_process"
|
||||
import fs from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
import { mergeOrFallback, DEFAULT_BRANCH } from "./prepare-branch.mjs"
|
||||
import { applyCap } from "./watermark.mjs"
|
||||
import {
|
||||
computeUncovered,
|
||||
computeProcessedThrough,
|
||||
routeRows,
|
||||
dropLegacySkipped,
|
||||
noDiffReport,
|
||||
renderBody,
|
||||
extractSectionRows,
|
||||
} from "./upsert-pr.mjs"
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||
const EDIT_SCRIPT = path.join(HERE, "edit.mjs")
|
||||
const TRIAGE_SCRIPT = path.join(HERE, "triage.mjs")
|
||||
const COLLECT_SCRIPT = path.join(HERE, "collect.mjs")
|
||||
|
||||
const temps = []
|
||||
|
||||
function mktemp(prefix) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix))
|
||||
temps.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
for (const dir of temps.splice(0)) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeExecutable(filePath, body) {
|
||||
fs.writeFileSync(filePath, body, { mode: 0o755 })
|
||||
}
|
||||
|
||||
function makeStubKiloDir({ mode, callLog, stderrText = "event stream disconnected" }) {
|
||||
const dir = mktemp("docs-sync-kilo-")
|
||||
const kiloPath = path.join(dir, "kilo")
|
||||
// mode: "stderr-exit0" | "record" | "partial-triage" | "mixed-triage"
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const mode = ${JSON.stringify(mode)};
|
||||
const callLog = ${JSON.stringify(callLog ?? "")};
|
||||
const stderrText = ${JSON.stringify(stderrText)};
|
||||
if (callLog) {
|
||||
fs.appendFileSync(callLog, JSON.stringify({ argv: process.argv.slice(2), cwd: process.cwd() }) + "\\n");
|
||||
}
|
||||
if (mode === "stderr-exit0") {
|
||||
process.stderr.write(stderrText + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
if (mode === "record") {
|
||||
process.stderr.write("recorded\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
// Parse -f chunk/batch file from args for triage stubs
|
||||
const args = process.argv.slice(2);
|
||||
const fIdx = args.indexOf("-f");
|
||||
const fileArg = fIdx >= 0 ? args[fIdx + 1] : null;
|
||||
let chunk = [];
|
||||
if (fileArg && fs.existsSync(fileArg)) {
|
||||
try { chunk = JSON.parse(fs.readFileSync(fileArg, "utf8")); } catch { chunk = []; }
|
||||
}
|
||||
if (mode === "partial-triage") {
|
||||
// Classify only a proper subset (first URL) of the chunk.
|
||||
const owned = chunk.slice(0, Math.max(0, chunk.length - 1));
|
||||
const entries = owned.map((d) => ({
|
||||
pr: d.number,
|
||||
url: d.url,
|
||||
docs_worthy: false,
|
||||
reason: "genuine not worthy",
|
||||
target_sections: [],
|
||||
priority: "medium",
|
||||
}));
|
||||
if (entries.length === 0 && chunk.length > 0) {
|
||||
// single-PR chunk: still leave one missing by emitting empty-ish foreign-only
|
||||
process.stdout.write("[]\\n");
|
||||
} else {
|
||||
process.stdout.write(JSON.stringify(entries) + "\\n");
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
if (mode === "mixed-triage") {
|
||||
// Half docs_worthy true, half fail (no output for second half — but we return
|
||||
// only some entries so backfill marks the rest pending). Actually: return
|
||||
// docs_worthy:true for first half of chunk URLs so worthy > 0.
|
||||
const half = Math.ceil(chunk.length / 2);
|
||||
const entries = chunk.slice(0, half).map((d) => ({
|
||||
pr: d.number,
|
||||
url: d.url,
|
||||
docs_worthy: true,
|
||||
reason: "needs docs",
|
||||
target_sections: ["overview"],
|
||||
priority: "high",
|
||||
}));
|
||||
process.stdout.write(JSON.stringify(entries) + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
process.stderr.write("unknown stub mode\\n");
|
||||
process.exit(1);
|
||||
`
|
||||
writeExecutable(kiloPath, script)
|
||||
return dir
|
||||
}
|
||||
|
||||
function gitIn(cwd, args, env = {}) {
|
||||
return execFileSync("git", args, {
|
||||
cwd,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf8",
|
||||
}).toString().trim()
|
||||
}
|
||||
|
||||
function makeGitRunner(cwd, env = {}) {
|
||||
return (args) => gitIn(cwd, args, env)
|
||||
}
|
||||
|
||||
function initRepoWithIdentity(dir) {
|
||||
gitIn(dir, ["init", "-b", "main"])
|
||||
gitIn(dir, ["config", "user.name", "docs-sync-selftest"])
|
||||
gitIn(dir, ["config", "user.email", "docs-sync-selftest@example.com"])
|
||||
gitIn(dir, ["config", "commit.gpgsign", "false"])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 1 — Defect A: mergeOrFallback
|
||||
// ---------------------------------------------------------------------------
|
||||
function case1_mergeOrFallback() {
|
||||
console.log("case 1: Defect A (mergeOrFallback)")
|
||||
|
||||
// 1a — identity configured + clean merge → mode=update
|
||||
{
|
||||
const dir = mktemp("docs-sync-merge-clean-")
|
||||
initRepoWithIdentity(dir)
|
||||
fs.writeFileSync(path.join(dir, "a.txt"), "base\n")
|
||||
gitIn(dir, ["add", "a.txt"])
|
||||
gitIn(dir, ["commit", "-m", "base"])
|
||||
gitIn(dir, ["checkout", "-b", DEFAULT_BRANCH])
|
||||
fs.writeFileSync(path.join(dir, "b.txt"), "on branch\n")
|
||||
gitIn(dir, ["add", "b.txt"])
|
||||
gitIn(dir, ["commit", "-m", "branch commit"])
|
||||
// Advance main without conflict
|
||||
gitIn(dir, ["checkout", "main"])
|
||||
fs.writeFileSync(path.join(dir, "c.txt"), "on main\n")
|
||||
gitIn(dir, ["add", "c.txt"])
|
||||
gitIn(dir, ["commit", "-m", "main advance"])
|
||||
gitIn(dir, ["update-ref", "refs/remotes/origin/main", "main"])
|
||||
gitIn(dir, ["checkout", DEFAULT_BRANCH])
|
||||
|
||||
const result = mergeOrFallback({ branch: DEFAULT_BRANCH, git: makeGitRunner(dir) })
|
||||
assert.equal(result.mode, "update")
|
||||
assert.equal(result.branch, DEFAULT_BRANCH)
|
||||
// merge brought c.txt in
|
||||
assert.ok(fs.existsSync(path.join(dir, "c.txt")))
|
||||
}
|
||||
|
||||
// 1b — genuine conflict → mode=conflict, abort succeeds, original branch untouched
|
||||
{
|
||||
const dir = mktemp("docs-sync-merge-conflict-")
|
||||
initRepoWithIdentity(dir)
|
||||
fs.writeFileSync(path.join(dir, "conflict.txt"), "base\n")
|
||||
gitIn(dir, ["add", "conflict.txt"])
|
||||
gitIn(dir, ["commit", "-m", "base"])
|
||||
const baseSha = gitIn(dir, ["rev-parse", "HEAD"])
|
||||
|
||||
gitIn(dir, ["checkout", "-b", DEFAULT_BRANCH])
|
||||
fs.writeFileSync(path.join(dir, "conflict.txt"), "branch side\n")
|
||||
gitIn(dir, ["add", "conflict.txt"])
|
||||
gitIn(dir, ["commit", "-m", "branch edit"])
|
||||
const branchShaBefore = gitIn(dir, ["rev-parse", "HEAD"])
|
||||
|
||||
gitIn(dir, ["checkout", "main"])
|
||||
fs.writeFileSync(path.join(dir, "conflict.txt"), "main side\n")
|
||||
gitIn(dir, ["add", "conflict.txt"])
|
||||
gitIn(dir, ["commit", "-m", "main edit"])
|
||||
gitIn(dir, ["update-ref", "refs/remotes/origin/main", "main"])
|
||||
gitIn(dir, ["checkout", DEFAULT_BRANCH])
|
||||
|
||||
const result = mergeOrFallback({ branch: DEFAULT_BRANCH, git: makeGitRunner(dir) })
|
||||
assert.equal(result.mode, "conflict")
|
||||
assert.ok(result.branch.startsWith(`${DEFAULT_BRANCH}-`))
|
||||
// Original rolling branch tip unchanged
|
||||
const branchShaAfter = gitIn(dir, ["rev-parse", DEFAULT_BRANCH])
|
||||
assert.equal(branchShaAfter, branchShaBefore)
|
||||
// No merge in progress
|
||||
let mergeHead = true
|
||||
try {
|
||||
gitIn(dir, ["rev-parse", "-q", "--verify", "MERGE_HEAD"])
|
||||
} catch {
|
||||
mergeHead = false
|
||||
}
|
||||
assert.equal(mergeHead, false)
|
||||
void baseSha
|
||||
}
|
||||
|
||||
// 1c — identity-less / non-conflict merge failure → throws (does not fake conflict)
|
||||
{
|
||||
const dir = mktemp("docs-sync-merge-noid-")
|
||||
initRepoWithIdentity(dir)
|
||||
fs.writeFileSync(path.join(dir, "a.txt"), "base\n")
|
||||
gitIn(dir, ["add", "a.txt"])
|
||||
gitIn(dir, ["commit", "-m", "base"])
|
||||
gitIn(dir, ["checkout", "-b", DEFAULT_BRANCH])
|
||||
fs.writeFileSync(path.join(dir, "b.txt"), "branch\n")
|
||||
gitIn(dir, ["add", "b.txt"])
|
||||
gitIn(dir, ["commit", "-m", "branch"])
|
||||
gitIn(dir, ["checkout", "main"])
|
||||
fs.writeFileSync(path.join(dir, "c.txt"), "main\n")
|
||||
gitIn(dir, ["add", "c.txt"])
|
||||
gitIn(dir, ["commit", "-m", "main"])
|
||||
gitIn(dir, ["update-ref", "refs/remotes/origin/main", "main"])
|
||||
gitIn(dir, ["checkout", DEFAULT_BRANCH])
|
||||
|
||||
// Strip identity so merge cannot create a commit
|
||||
gitIn(dir, ["config", "--unset", "user.name"])
|
||||
gitIn(dir, ["config", "--unset", "user.email"])
|
||||
|
||||
const env = {
|
||||
GIT_CONFIG_GLOBAL: "/dev/null",
|
||||
GIT_CONFIG_SYSTEM: "/dev/null",
|
||||
GIT_CONFIG_NOSYSTEM: "1",
|
||||
}
|
||||
const git = (args) =>
|
||||
execFileSync("git", ["-c", "user.useConfigOnly=true", ...args], {
|
||||
cwd: dir,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf8",
|
||||
}).toString().trim()
|
||||
|
||||
assert.throws(() => mergeOrFallback({ branch: DEFAULT_BRANCH, git }), (err) => {
|
||||
// Must throw the original merge error, not a merge --abort failure
|
||||
const msg = String(err?.stderr ?? err?.message ?? err)
|
||||
assert.ok(!/no merge to abort/i.test(msg), `should not reach merge --abort: ${msg}`)
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers to run edit.mjs / triage.mjs as child processes
|
||||
// ---------------------------------------------------------------------------
|
||||
function setupEditCwd(worthy, triage) {
|
||||
const cwd = mktemp("docs-sync-edit-")
|
||||
fs.mkdirSync(path.join(cwd, "docs-sync-out"), { recursive: true })
|
||||
fs.writeFileSync(path.join(cwd, "docs-sync-out", "worthy.json"), JSON.stringify(worthy, null, 2))
|
||||
fs.writeFileSync(path.join(cwd, "docs-sync-out", "triage.json"), JSON.stringify(triage, null, 2))
|
||||
return cwd
|
||||
}
|
||||
|
||||
function setupTriageCwd(digest) {
|
||||
const cwd = mktemp("docs-sync-triage-")
|
||||
fs.mkdirSync(path.join(cwd, "docs-sync-out"), { recursive: true })
|
||||
fs.writeFileSync(path.join(cwd, "docs-sync-out", "digest.json"), JSON.stringify(digest, null, 2))
|
||||
return cwd
|
||||
}
|
||||
|
||||
function runNodeScript(scriptPath, { cwd, env = {}, kiloDir }) {
|
||||
const pathEnv = [kiloDir, process.env.PATH].filter(Boolean).join(path.delimiter)
|
||||
const result = spawnSync(process.execPath, [scriptPath], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
PATH: pathEnv,
|
||||
DOCS_SYNC_BACKOFF_MS: env.DOCS_SYNC_BACKOFF_MS ?? "0",
|
||||
},
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
})
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: String(result.stdout ?? ""),
|
||||
stderr: String(result.stderr ?? ""),
|
||||
output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
|
||||
error: result.error,
|
||||
}
|
||||
}
|
||||
|
||||
function samplePr(n, { merged_at, repo = "Kilo-Org/cloud" } = {}) {
|
||||
return {
|
||||
repo,
|
||||
number: n,
|
||||
title: `feat: sample ${n}`,
|
||||
url: `https://github.com/${repo}/pull/${n}`,
|
||||
author: "dev",
|
||||
merged_at: merged_at ?? "2026-07-20T12:00:00.000Z",
|
||||
labels: [],
|
||||
body: "body",
|
||||
files: [],
|
||||
files_total: 1,
|
||||
patch_excerpt: "",
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 2 — Defect B: edit.mjs with stub kilo (exit 0 + stderr)
|
||||
// ---------------------------------------------------------------------------
|
||||
function case2_defectB() {
|
||||
console.log("case 2: Defect B (edit.mjs stderr-on-exit-0)")
|
||||
|
||||
const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n))
|
||||
const worthy = prs
|
||||
const triage = prs.map((p) => ({
|
||||
pr: p.number,
|
||||
url: p.url,
|
||||
docs_worthy: true,
|
||||
reason: "needs docs",
|
||||
target_sections: ["overview"],
|
||||
priority: "high",
|
||||
}))
|
||||
const cwd = setupEditCwd(worthy, triage)
|
||||
const stderrText = "event stream disconnected DIAG-CASE2"
|
||||
const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText })
|
||||
|
||||
const started = Date.now()
|
||||
const result = runNodeScript(EDIT_SCRIPT, {
|
||||
cwd,
|
||||
kiloDir,
|
||||
env: {
|
||||
EDIT_MODEL: "test/model",
|
||||
DOCS_SYNC_BACKOFF_MS: "0",
|
||||
// Enough budget for 3 attempts × tiny timeout
|
||||
EDIT_BUDGET_MINUTES: "5",
|
||||
EDIT_BATCH_TIMEOUT_MINUTES: "1",
|
||||
},
|
||||
})
|
||||
const elapsed = Date.now() - started
|
||||
|
||||
assert.equal(result.status, 0, `edit.mjs exit: ${result.output}`)
|
||||
// Backoff collapsed — 3 attempts without 60s+300s waits
|
||||
assert.ok(elapsed < 15_000, `backoff should collapse with DOCS_SYNC_BACKOFF_MS=0; elapsed=${elapsed}ms`)
|
||||
|
||||
assert.match(result.output, /stderr tail:/)
|
||||
assert.match(result.output, /DIAG-CASE2|event stream disconnected/)
|
||||
assert.match(result.output, /attempt 1/)
|
||||
assert.match(result.output, /attempt 2/)
|
||||
// 3 attempts
|
||||
assert.match(result.output, /attempt 3|failed after up to 3 attempts/)
|
||||
|
||||
const summary = JSON.parse(fs.readFileSync(path.join(cwd, ".docs-sync-summary.json"), "utf8"))
|
||||
assert.equal(summary.length, 5)
|
||||
for (const e of summary) {
|
||||
assert.equal(e.action, "pending", `expected pending, got ${JSON.stringify(e)}`)
|
||||
}
|
||||
|
||||
const uncovered = computeUncovered({ worthy, summary, triage })
|
||||
assert.equal(uncovered.length, 5)
|
||||
for (const u of uncovered) {
|
||||
assert.ok(u.reason, "uncovered reason present")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 3 — watermark invariant
|
||||
// ---------------------------------------------------------------------------
|
||||
function case3_watermark() {
|
||||
console.log("case 3: watermark invariant")
|
||||
|
||||
const now = "2026-07-27T12:00:00.000Z"
|
||||
const nowMs = Date.parse(now)
|
||||
|
||||
const prA = samplePr(10, { merged_at: "2026-07-20T10:00:00.000Z" })
|
||||
const prB = samplePr(11, { merged_at: "2026-07-22T15:30:00.000Z" })
|
||||
const prC = samplePr(12, { merged_at: "2026-07-25T08:00:00.000Z" })
|
||||
const digest = [prA, prB, prC]
|
||||
|
||||
// all covered → processed-through === now
|
||||
{
|
||||
const worthy = [prA, prB]
|
||||
const summary = [
|
||||
{ pr: 10, url: prA.url, action: "updated packages/kilo-docs/pages/x.md", reason: "" },
|
||||
{ pr: 11, url: prB.url, action: "skipped", reason: "already documented" },
|
||||
]
|
||||
const triage = [
|
||||
{ pr: 10, url: prA.url, docs_worthy: true, pending: false, reason: "ok" },
|
||||
{ pr: 11, url: prB.url, docs_worthy: true, pending: false, reason: "ok" },
|
||||
]
|
||||
const uncovered = computeUncovered({ worthy, summary, triage })
|
||||
assert.equal(uncovered.length, 0)
|
||||
const through = computeProcessedThrough({ uncovered, digest, now })
|
||||
assert.equal(through, now)
|
||||
}
|
||||
|
||||
// one uncovered → merged_at − 1 ms, strictly < now
|
||||
{
|
||||
const worthy = [prA, prB]
|
||||
const summary = [
|
||||
{ pr: 10, url: prA.url, action: "updated x", reason: "" },
|
||||
{ pr: 11, url: prB.url, action: "pending", reason: "edit batch 0: exit 0" },
|
||||
]
|
||||
const uncovered = computeUncovered({ worthy, summary, triage: [] })
|
||||
assert.equal(uncovered.length, 1)
|
||||
assert.equal(uncovered[0].url, prB.url)
|
||||
const through = computeProcessedThrough({ uncovered, digest, now })
|
||||
const expected = new Date(Date.parse(prB.merged_at) - 1).toISOString()
|
||||
assert.equal(through, expected)
|
||||
assert.ok(Date.parse(through) < nowMs)
|
||||
}
|
||||
|
||||
// several uncovered → earliest merge time wins
|
||||
{
|
||||
const worthy = [prA, prB, prC]
|
||||
const summary = [
|
||||
{ pr: 10, url: prA.url, action: "pending", reason: "fail" },
|
||||
{ pr: 12, url: prC.url, action: "pending", reason: "fail" },
|
||||
]
|
||||
// prB missing from summary entirely
|
||||
const uncovered = computeUncovered({ worthy, summary, triage: [] })
|
||||
assert.ok(uncovered.length >= 2)
|
||||
const through = computeProcessedThrough({ uncovered, digest, now })
|
||||
// earliest among A, B, C that are uncovered — A is earliest
|
||||
const times = uncovered
|
||||
.map((u) => digest.find((d) => d.url === u.url)?.merged_at)
|
||||
.filter(Boolean)
|
||||
.map((t) => Date.parse(t))
|
||||
const earliest = Math.min(...times)
|
||||
assert.equal(through, new Date(earliest - 1).toISOString())
|
||||
}
|
||||
|
||||
// summary missing/truncated while worthy non-empty → every worthy URL held back
|
||||
{
|
||||
const worthy = [prA, prB]
|
||||
const uncovered = computeUncovered({ worthy, summary: [], triage: [] })
|
||||
assert.equal(uncovered.length, 2)
|
||||
const through = computeProcessedThrough({ uncovered, digest, now })
|
||||
assert.equal(through, new Date(Date.parse(prA.merged_at) - 1).toISOString())
|
||||
}
|
||||
|
||||
// noDiffReport three arms
|
||||
{
|
||||
const uncovered = [{ url: prA.url, reason: "edit batch failed" }]
|
||||
const arm1 = noDiffReport({ uncovered, sinceOverride: true })
|
||||
assert.ok(arm1.summary.includes(prA.url))
|
||||
assert.ok(arm1.warning, "override + uncovered → warning present")
|
||||
|
||||
const arm2 = noDiffReport({ uncovered: [], sinceOverride: true })
|
||||
assert.equal(arm2.warning, null, "override + empty uncovered → warning absent")
|
||||
|
||||
const arm3 = noDiffReport({ uncovered, sinceOverride: false })
|
||||
assert.equal(arm3.warning, null, "scheduled + uncovered → warning absent")
|
||||
}
|
||||
|
||||
// triage pending:true backfill rows land in uncovered (consumption)
|
||||
{
|
||||
const triage = [
|
||||
{
|
||||
pr: 99,
|
||||
url: "https://github.com/Kilo-Org/cloud/pull/99",
|
||||
docs_worthy: false,
|
||||
pending: true,
|
||||
reason: "not classified by triage",
|
||||
},
|
||||
]
|
||||
const uncovered = computeUncovered({ worthy: [], summary: [], triage })
|
||||
assert.equal(uncovered.length, 1)
|
||||
assert.equal(uncovered[0].url, triage[0].url)
|
||||
const through = computeProcessedThrough({
|
||||
uncovered,
|
||||
digest: [{ url: triage[0].url, merged_at: "2026-07-21T00:00:00.000Z" }],
|
||||
now,
|
||||
})
|
||||
assert.equal(through, new Date(Date.parse("2026-07-21T00:00:00.000Z") - 1).toISOString())
|
||||
}
|
||||
|
||||
// fallback field (post-plan repair)
|
||||
{
|
||||
const uncovered = [{ url: "https://github.com/Kilo-Org/cloud/pull/50", reason: "missing" }]
|
||||
const fallback = "2026-07-17T00:00:00.000Z"
|
||||
// unresolved merged_at + parseable fallback → hold at fallback, warn
|
||||
const prevWarn = console.warn
|
||||
const warnings = []
|
||||
console.warn = (...a) => warnings.push(a.join(" "))
|
||||
try {
|
||||
const through = computeProcessedThrough({ uncovered, digest: [], now, fallback })
|
||||
assert.equal(through, new Date(fallback).toISOString())
|
||||
assert.ok(Date.parse(through) < nowMs)
|
||||
assert.ok(warnings.some((w) => w.includes("::warning::")))
|
||||
} finally {
|
||||
console.warn = prevWarn
|
||||
}
|
||||
|
||||
// unresolved + unparseable/missing fallback → throws
|
||||
assert.throws(() => computeProcessedThrough({ uncovered, digest: [], now }), /fallback|SINCE|refusing/i)
|
||||
assert.throws(
|
||||
() => computeProcessedThrough({ uncovered, digest: [], now, fallback: "not-a-date" }),
|
||||
/fallback|SINCE|refusing/i,
|
||||
)
|
||||
|
||||
// resolved merged_at ignores fallback
|
||||
const throughResolved = computeProcessedThrough({
|
||||
uncovered: [{ url: prA.url, reason: "x" }],
|
||||
digest: [prA],
|
||||
now,
|
||||
fallback: "2020-01-01T00:00:00.000Z",
|
||||
})
|
||||
assert.equal(throughResolved, new Date(Date.parse(prA.merged_at) - 1).toISOString())
|
||||
|
||||
// empty uncovered ignores fallback
|
||||
const throughEmpty = computeProcessedThrough({
|
||||
uncovered: [],
|
||||
digest: [],
|
||||
now,
|
||||
fallback: "2020-01-01T00:00:00.000Z",
|
||||
})
|
||||
assert.equal(throughEmpty, now)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 4 — routing and round trip
|
||||
// ---------------------------------------------------------------------------
|
||||
function case4_routing() {
|
||||
console.log("case 4: routing and round trip")
|
||||
|
||||
const summary = [
|
||||
{ pr: 1, url: "https://github.com/Kilo-Org/cloud/pull/1", action: "updated pages/a.md", reason: "" },
|
||||
{ pr: 2, url: "https://github.com/Kilo-Org/cloud/pull/2", action: "skipped", reason: "already documented" },
|
||||
{ pr: 3, url: "https://github.com/Kilo-Org/cloud/pull/3", action: "pending", reason: "edit batch 1: exit 0" },
|
||||
]
|
||||
const triage = [
|
||||
{
|
||||
pr: 4,
|
||||
url: "https://github.com/Kilo-Org/cloud/pull/4",
|
||||
docs_worthy: false,
|
||||
pending: false,
|
||||
reason: "chore only",
|
||||
},
|
||||
{
|
||||
pr: 5,
|
||||
url: "https://github.com/Kilo-Org/cloud/pull/5",
|
||||
docs_worthy: false,
|
||||
pending: true,
|
||||
reason: "triage failed to classify this PR",
|
||||
},
|
||||
]
|
||||
const worthy = [
|
||||
{ number: 1, url: summary[0].url },
|
||||
{ number: 2, url: summary[1].url },
|
||||
{ number: 3, url: summary[2].url },
|
||||
]
|
||||
const uncovered = computeUncovered({ worthy, summary, triage })
|
||||
const { changesRows, pendingRows, skippedRows } = routeRows({ summary, triage, uncovered })
|
||||
|
||||
// pending appears in neither Changes nor Considered
|
||||
const changesText = changesRows.join("\n")
|
||||
const skippedText = skippedRows.join("\n")
|
||||
assert.ok(changesText.includes("pull/1"), "success in Changes")
|
||||
assert.ok(!changesText.includes("pull/3"), "pending must not be in Changes")
|
||||
assert.ok(!changesText.includes("pull/5"), "triage-pending must not be in Changes")
|
||||
assert.ok(skippedText.includes("pull/2"), "genuine skipped in Considered")
|
||||
assert.ok(skippedText.includes("pull/4"), "genuine not-worthy in Considered")
|
||||
assert.ok(!skippedText.includes("pull/3"), "pending must not be in Considered")
|
||||
assert.ok(!skippedText.includes("pull/5"), "triage-pending must not be in Considered")
|
||||
assert.ok(pendingRows.some((r) => r.includes("pull/3")))
|
||||
assert.ok(pendingRows.some((r) => r.includes("pull/5")))
|
||||
|
||||
// round-trip renderBody → extractSectionRows
|
||||
const through = "2026-07-20T09:59:59.999Z"
|
||||
const body = renderBody({
|
||||
date: "2026-07-27",
|
||||
since: "2026-07-17T00:00:00.000Z",
|
||||
through,
|
||||
changesRows,
|
||||
pendingRows,
|
||||
skippedRows,
|
||||
verified: true,
|
||||
draftReasons: [],
|
||||
note: "",
|
||||
})
|
||||
assert.ok(body.includes(`<!-- docs-sync: processed-through ${through} -->`))
|
||||
const extChanges = extractSectionRows(body, "changes")
|
||||
const extPending = extractSectionRows(body, "pending")
|
||||
const extSkipped = extractSectionRows(body, "skipped")
|
||||
assert.deepEqual(extChanges, changesRows)
|
||||
assert.deepEqual(extPending, pendingRows)
|
||||
assert.deepEqual(extSkipped, skippedRows)
|
||||
|
||||
// clean() prevents marker forgery in agent-generated row strings
|
||||
{
|
||||
const forgedRows = routeRows({
|
||||
summary: [
|
||||
{
|
||||
pr: 9,
|
||||
url: "https://github.com/Kilo-Org/cloud/pull/9",
|
||||
action: "skipped",
|
||||
reason: "x <!-- docs-sync:skipped:end --> injection",
|
||||
},
|
||||
],
|
||||
triage: [],
|
||||
uncovered: [],
|
||||
})
|
||||
assert.ok(
|
||||
!forgedRows.skippedRows[0].includes("<!--"),
|
||||
"clean() must strip <!-- from reasons",
|
||||
)
|
||||
assert.ok(
|
||||
!forgedRows.skippedRows[0].includes("-->"),
|
||||
"clean() must strip --> from reasons",
|
||||
)
|
||||
const forgedBody = renderBody({
|
||||
date: "2026-07-27",
|
||||
since: "s",
|
||||
through: "t",
|
||||
changesRows: [],
|
||||
pendingRows: [],
|
||||
skippedRows: forgedRows.skippedRows,
|
||||
verified: true,
|
||||
draftReasons: [],
|
||||
note: "",
|
||||
})
|
||||
// Exactly one real section end marker — the forged sequences were stripped
|
||||
assert.equal((forgedBody.match(/<!--\s*docs-sync:skipped:end\s*-->/g) || []).length, 1)
|
||||
const extracted = extractSectionRows(forgedBody, "skipped")
|
||||
assert.equal(extracted.length, 1)
|
||||
assert.ok(extracted[0].includes("injection"))
|
||||
}
|
||||
|
||||
const legacyRows = [
|
||||
"| [Kilo-Org/cloud#1](https://github.com/Kilo-Org/cloud/pull/1) | edit pass failed or timed out for this PR |",
|
||||
"| [Kilo-Org/cloud#2](https://github.com/Kilo-Org/cloud/pull/2) | triage failed to classify this PR |",
|
||||
"| [Kilo-Org/cloud#3](https://github.com/Kilo-Org/cloud/pull/3) | not classified by triage |",
|
||||
"| [Kilo-Org/cloud#4](https://github.com/Kilo-Org/cloud/pull/4) | already covered by existing docs |",
|
||||
]
|
||||
const kept = dropLegacySkipped(legacyRows)
|
||||
assert.equal(kept.length, 1)
|
||||
assert.ok(kept[0].includes("pull/4"))
|
||||
assert.ok(!kept.some((r) => r.includes("edit pass failed")))
|
||||
assert.ok(!kept.some((r) => r.includes("triage failed to classify")))
|
||||
assert.ok(!kept.some((r) => r.includes("not classified by triage")))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 5 — re-collection window
|
||||
// ---------------------------------------------------------------------------
|
||||
function case5_recollection() {
|
||||
console.log("case 5: re-collection closes the loop")
|
||||
|
||||
const collectSrc = fs.readFileSync(COLLECT_SCRIPT, "utf8")
|
||||
// Query template must use merged:>=
|
||||
assert.ok(
|
||||
/merged:>=\$\{since\.toISOString\(\)\}/.test(collectSrc) || /merged:>=/.test(collectSrc),
|
||||
"collect.mjs must search merged:>=since",
|
||||
)
|
||||
assert.match(collectSrc, /merged:>=/)
|
||||
|
||||
const mergedAt = "2026-07-22T15:30:00.000Z"
|
||||
const uncovered = [{ url: "https://github.com/Kilo-Org/cloud/pull/11", reason: "pending" }]
|
||||
const digest = [{ url: uncovered[0].url, merged_at: mergedAt }]
|
||||
const now = "2026-07-27T12:00:00.000Z"
|
||||
const since = computeProcessedThrough({ uncovered, digest, now })
|
||||
// held-back since is strictly before the uncovered PR's merged_at
|
||||
assert.ok(Date.parse(since) < Date.parse(mergedAt), `since ${since} must be < merged_at ${mergedAt}`)
|
||||
// And the query window merged:>=since therefore includes that PR
|
||||
assert.ok(Date.parse(mergedAt) >= Date.parse(since))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 6 — budgets
|
||||
// ---------------------------------------------------------------------------
|
||||
function case6_budgets() {
|
||||
console.log("case 6: budgets")
|
||||
|
||||
// --- edit budget ---
|
||||
{
|
||||
// 12 PRs = 3 batches of 5; budget too small for even one batch unit
|
||||
const prs = Array.from({ length: 12 }, (_, i) => samplePr(100 + i))
|
||||
const worthy = prs
|
||||
const triage = prs.map((p) => ({
|
||||
pr: p.number,
|
||||
url: p.url,
|
||||
docs_worthy: true,
|
||||
reason: "needs docs",
|
||||
target_sections: [],
|
||||
priority: "medium",
|
||||
}))
|
||||
const cwd = setupEditCwd(worthy, triage)
|
||||
const callLog = path.join(cwd, "kilo-calls.log")
|
||||
const kiloDir = makeStubKiloDir({ mode: "record", callLog })
|
||||
|
||||
// EDIT_BUDGET_MINUTES must be positive (0 falls through to default 50).
|
||||
// BATCH_TIMEOUT default would be 15m; set both tiny so left < BATCH_TIMEOUT immediately.
|
||||
const result = runNodeScript(EDIT_SCRIPT, {
|
||||
cwd,
|
||||
kiloDir,
|
||||
env: {
|
||||
EDIT_MODEL: "test/model",
|
||||
DOCS_SYNC_BACKOFF_MS: "0",
|
||||
EDIT_BUDGET_MINUTES: "0.0001",
|
||||
EDIT_BATCH_TIMEOUT_MINUTES: "15",
|
||||
},
|
||||
})
|
||||
assert.equal(result.status, 0, result.output)
|
||||
assert.match(result.output, /deferring \d+ PRs/)
|
||||
assert.match(result.output, /deferred \d+ PRs due to wall-clock budget/)
|
||||
|
||||
const calls = fs.existsSync(callLog) ? fs.readFileSync(callLog, "utf8").trim() : ""
|
||||
const callCount = calls ? calls.split("\n").filter(Boolean).length : 0
|
||||
assert.equal(callCount, 0, `kilo must not be invoked for deferred edit batches; got ${callCount}`)
|
||||
|
||||
const summary = JSON.parse(fs.readFileSync(path.join(cwd, ".docs-sync-summary.json"), "utf8"))
|
||||
assert.ok(summary.every((e) => e.action === "pending"))
|
||||
const uncovered = computeUncovered({ worthy, summary, triage })
|
||||
assert.equal(uncovered.length, 12)
|
||||
assert.ok(summary.every((e) => e.action !== "skipped"))
|
||||
}
|
||||
|
||||
// --- triage budget ---
|
||||
{
|
||||
// CHUNK_SIZE=25; 30 PRs = 2 chunks; budget too small for a 10m chunk
|
||||
const digest = Array.from({ length: 30 }, (_, i) => samplePr(200 + i))
|
||||
const cwd = setupTriageCwd(digest)
|
||||
const callLog = path.join(cwd, "kilo-calls.log")
|
||||
const kiloDir = makeStubKiloDir({ mode: "record", callLog })
|
||||
const summaryFile = path.join(cwd, "step-summary.md")
|
||||
fs.writeFileSync(summaryFile, "")
|
||||
|
||||
const result = runNodeScript(TRIAGE_SCRIPT, {
|
||||
cwd,
|
||||
kiloDir,
|
||||
env: {
|
||||
TRIAGE_MODEL: "test/model",
|
||||
DOCS_SYNC_BACKOFF_MS: "0",
|
||||
TRIAGE_BUDGET_MINUTES: "0.0001",
|
||||
GITHUB_STEP_SUMMARY: summaryFile,
|
||||
},
|
||||
})
|
||||
assert.equal(result.status, 0, result.output)
|
||||
assert.match(result.output, /deferring \d+ PRs/)
|
||||
|
||||
const calls = fs.existsSync(callLog) ? fs.readFileSync(callLog, "utf8").trim() : ""
|
||||
const callCount = calls ? calls.split("\n").filter(Boolean).length : 0
|
||||
assert.equal(callCount, 0, `kilo must not be invoked for deferred triage chunks; got ${callCount}`)
|
||||
|
||||
const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8"))
|
||||
assert.equal(triage.length, 30)
|
||||
assert.ok(triage.every((e) => e.pending === true))
|
||||
assert.ok(triage.every((e) => e.docs_worthy === false))
|
||||
const uncovered = computeUncovered({ worthy: [], summary: [], triage })
|
||||
assert.equal(uncovered.length, 30)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 7 — applyCap both arms
|
||||
// ---------------------------------------------------------------------------
|
||||
function case7_cap() {
|
||||
console.log("case 7: applyCap")
|
||||
|
||||
const now = new Date("2026-07-27T12:00:00.000Z")
|
||||
const old = new Date("2026-06-01T00:00:00.000Z")
|
||||
|
||||
const prevLog = console.log
|
||||
const prevWarn = console.warn
|
||||
const logs = []
|
||||
const warnings = []
|
||||
console.log = (...a) => logs.push(a.join(" "))
|
||||
console.warn = (...a) => warnings.push(a.join(" "))
|
||||
try {
|
||||
// explicit:false + older than 14 days → clamped AND reported
|
||||
const a = applyCap(old, now, { explicit: false })
|
||||
assert.equal(a.clamped, true)
|
||||
assert.ok(a.since.getTime() > old.getTime())
|
||||
const cap = new Date(now.getTime() - 14 * 24 * 3600 * 1000)
|
||||
assert.equal(a.since.toISOString(), cap.toISOString())
|
||||
assert.ok(warnings.some((w) => w.includes("::warning::") && w.includes("clamped")))
|
||||
|
||||
// explicit:true + older than 14 days → unchanged, skip reported
|
||||
logs.length = 0
|
||||
warnings.length = 0
|
||||
const b = applyCap(old, now, { explicit: true })
|
||||
assert.equal(b.clamped, false)
|
||||
assert.equal(b.since.toISOString(), old.toISOString())
|
||||
assert.ok(logs.some((l) => /cap skipped|INPUT_SINCE/i.test(l)))
|
||||
} finally {
|
||||
console.log = prevLog
|
||||
console.warn = prevWarn
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 8 — triage.mjs outputs
|
||||
// ---------------------------------------------------------------------------
|
||||
function case8_triage() {
|
||||
console.log("case 8: triage pass outputs")
|
||||
|
||||
// 8a Run A: SINCE_OVERRIDE=true + everything pending → warning present
|
||||
{
|
||||
const digest = [samplePr(301), samplePr(302), samplePr(303)]
|
||||
const cwd = setupTriageCwd(digest)
|
||||
const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText: "stream end before idle" })
|
||||
const summaryFile = path.join(cwd, "step-summary.md")
|
||||
fs.writeFileSync(summaryFile, "")
|
||||
|
||||
const result = runNodeScript(TRIAGE_SCRIPT, {
|
||||
cwd,
|
||||
kiloDir,
|
||||
env: {
|
||||
TRIAGE_MODEL: "test/model",
|
||||
DOCS_SYNC_BACKOFF_MS: "0",
|
||||
TRIAGE_BUDGET_MINUTES: "30",
|
||||
SINCE_OVERRIDE: "true",
|
||||
GITHUB_STEP_SUMMARY: summaryFile,
|
||||
},
|
||||
})
|
||||
assert.equal(result.status, 0, result.output)
|
||||
const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8"))
|
||||
assert.equal(triage.length, 3)
|
||||
assert.ok(triage.every((e) => e.pending === true))
|
||||
const summary = fs.readFileSync(summaryFile, "utf8")
|
||||
assert.match(summary, /triage pending/)
|
||||
for (const d of digest) {
|
||||
assert.ok(summary.includes(d.url), `summary lists ${d.url}`)
|
||||
}
|
||||
assert.match(result.output, /::warning::.*since-override/)
|
||||
}
|
||||
|
||||
// 8a Run B: SINCE_OVERRIDE unset → warning absent
|
||||
{
|
||||
const digest = [samplePr(311), samplePr(312)]
|
||||
const cwd = setupTriageCwd(digest)
|
||||
const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText: "stream end" })
|
||||
const summaryFile = path.join(cwd, "step-summary.md")
|
||||
fs.writeFileSync(summaryFile, "")
|
||||
|
||||
const result = runNodeScript(TRIAGE_SCRIPT, {
|
||||
cwd,
|
||||
kiloDir,
|
||||
env: {
|
||||
TRIAGE_MODEL: "test/model",
|
||||
DOCS_SYNC_BACKOFF_MS: "0",
|
||||
TRIAGE_BUDGET_MINUTES: "30",
|
||||
GITHUB_STEP_SUMMARY: summaryFile,
|
||||
},
|
||||
})
|
||||
assert.equal(result.status, 0, result.output)
|
||||
const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8"))
|
||||
assert.ok(triage.every((e) => e.pending === true))
|
||||
assert.ok(fs.readFileSync(summaryFile, "utf8").includes("triage pending"))
|
||||
assert.ok(!/::warning::.*since-override/.test(result.output), "override warning must be absent when unset")
|
||||
}
|
||||
|
||||
// 8a Run C: SINCE_OVERRIDE=true with MIXED stub (worthy > 0) → warning ABSENT
|
||||
{
|
||||
const digest = [samplePr(321), samplePr(322), samplePr(323), samplePr(324)]
|
||||
const cwd = setupTriageCwd(digest)
|
||||
const kiloDir = makeStubKiloDir({ mode: "mixed-triage" })
|
||||
const summaryFile = path.join(cwd, "step-summary.md")
|
||||
fs.writeFileSync(summaryFile, "")
|
||||
|
||||
const result = runNodeScript(TRIAGE_SCRIPT, {
|
||||
cwd,
|
||||
kiloDir,
|
||||
env: {
|
||||
TRIAGE_MODEL: "test/model",
|
||||
DOCS_SYNC_BACKOFF_MS: "0",
|
||||
TRIAGE_BUDGET_MINUTES: "30",
|
||||
SINCE_OVERRIDE: "true",
|
||||
GITHUB_STEP_SUMMARY: summaryFile,
|
||||
},
|
||||
})
|
||||
assert.equal(result.status, 0, result.output)
|
||||
const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8"))
|
||||
const worthy = triage.filter((e) => e.docs_worthy === true).length
|
||||
const pending = triage.filter((e) => e.pending === true).length
|
||||
assert.ok(worthy > 0, "mixed stub must produce worthy > 0")
|
||||
assert.ok(pending > 0, "mixed stub must leave some pending")
|
||||
assert.ok(
|
||||
!/::warning::.*since-override/.test(result.output),
|
||||
"override warning must be ABSENT when worthy > 0 (Upsert will run)",
|
||||
)
|
||||
}
|
||||
|
||||
// 8b — partial classification → missing URLs pending:true + computeUncovered
|
||||
{
|
||||
// One chunk of 4 PRs; stub classifies first 3 only
|
||||
const digest = [samplePr(401), samplePr(402), samplePr(403), samplePr(404)]
|
||||
const cwd = setupTriageCwd(digest)
|
||||
const kiloDir = makeStubKiloDir({ mode: "partial-triage" })
|
||||
const summaryFile = path.join(cwd, "step-summary.md")
|
||||
fs.writeFileSync(summaryFile, "")
|
||||
|
||||
const result = runNodeScript(TRIAGE_SCRIPT, {
|
||||
cwd,
|
||||
kiloDir,
|
||||
env: {
|
||||
TRIAGE_MODEL: "test/model",
|
||||
DOCS_SYNC_BACKOFF_MS: "0",
|
||||
TRIAGE_BUDGET_MINUTES: "30",
|
||||
GITHUB_STEP_SUMMARY: summaryFile,
|
||||
},
|
||||
})
|
||||
assert.equal(result.status, 0, result.output)
|
||||
const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8"))
|
||||
assert.equal(triage.length, 4)
|
||||
const missing = triage.filter((e) => e.reason === "not classified by triage")
|
||||
assert.ok(missing.length >= 1, "backfill must mark unclassified URLs")
|
||||
assert.ok(missing.every((e) => e.pending === true))
|
||||
const uncovered = computeUncovered({ worthy: [], summary: [], triage })
|
||||
for (const m of missing) {
|
||||
assert.ok(
|
||||
uncovered.some((u) => u.url === m.url),
|
||||
`${m.url} must appear in computeUncovered`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main
|
||||
// ---------------------------------------------------------------------------
|
||||
function main() {
|
||||
const cases = [
|
||||
case1_mergeOrFallback,
|
||||
case2_defectB,
|
||||
case3_watermark,
|
||||
case4_routing,
|
||||
case5_recollection,
|
||||
case6_budgets,
|
||||
case7_cap,
|
||||
case8_triage,
|
||||
]
|
||||
let failed = 0
|
||||
for (const fn of cases) {
|
||||
try {
|
||||
fn()
|
||||
console.log(` ok: ${fn.name}`)
|
||||
} catch (err) {
|
||||
failed++
|
||||
console.error(` FAIL: ${fn.name}`)
|
||||
console.error(err)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
if (failed > 0) {
|
||||
console.error(`\nselftest: ${failed} case(s) failed`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log("\nselftest: all cases passed")
|
||||
}
|
||||
|
||||
main()
|
||||
+122
-44
@@ -6,52 +6,82 @@
|
||||
* A daily window holds ~30-50 PRs; a replay can hold several hundred. A
|
||||
* single triage call over that volume truncates its JSON output, so the
|
||||
* digest is split into chunks of CHUNK_SIZE and each chunk is triaged with
|
||||
* its own `kilo run` call. A chunk that fails twice is degraded to
|
||||
* "unclassified" entries (docs_worthy=false) instead of failing the run —
|
||||
* the PR body then shows those PRs as skipped, visible to reviewers.
|
||||
* its own `kilo run` call. A chunk that fails, is only partially classified,
|
||||
* or is deferred by the wall-clock budget is marked pending:true (still
|
||||
* docs_worthy:false so filter-worthy excludes it) so the watermark holds
|
||||
* back and the next run re-collects those PRs.
|
||||
*
|
||||
* Env: TRIAGE_MODEL (provider/model), KILO_API_KEY (gateway auth, set by the workflow;
|
||||
* the kilo provider reads it natively). Reads the prompt from triage-prompt.md next to this script.
|
||||
* Env: TRIAGE_MODEL (provider/model), KILO_API_KEY + KILO_ORG_ID (gateway auth, set by
|
||||
* the workflow; the kilo provider reads them natively). Reads the prompt from triage-prompt.md next to this script.
|
||||
* Budget: TRIAGE_BUDGET_MINUTES (default 35). Test hook: DOCS_SYNC_BACKOFF_MS.
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { parseTriageEntries } from "./extract-json.mjs"
|
||||
import { appendSummary, backoffMsForAttempt, deadline, remainingMs, runKilo, sleepSync } from "./lib.mjs"
|
||||
|
||||
const CHUNK_SIZE = 25
|
||||
const ATTEMPTS = 2
|
||||
const ATTEMPTS = 3
|
||||
const OUT_DIR = "docs-sync-out"
|
||||
const CHUNK_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||
const prompt = fs.readFileSync(path.join(HERE, "triage-prompt.md"), "utf8")
|
||||
const model = process.env.TRIAGE_MODEL
|
||||
if (!model) throw new Error("TRIAGE_MODEL is required")
|
||||
|
||||
const TRIAGE_BUDGET_MINUTES = Number(process.env.TRIAGE_BUDGET_MINUTES) || 35
|
||||
|
||||
const digest = JSON.parse(fs.readFileSync(`${OUT_DIR}/digest.json`, "utf8"))
|
||||
|
||||
function triageChunk(chunk, index) {
|
||||
function formatCause(result) {
|
||||
const bits = []
|
||||
if (result.timedOut) bits.push("timed out")
|
||||
if (result.exitCode !== null && result.exitCode !== undefined) bits.push(`exit ${result.exitCode}`)
|
||||
if (result.stderrTail) bits.push(result.stderrTail.replaceAll("\n", " ").slice(0, 200))
|
||||
return bits.join("; ") || "no diagnostic"
|
||||
}
|
||||
|
||||
function pendingEntry(d, reason) {
|
||||
return {
|
||||
pr: d.number,
|
||||
url: d.url,
|
||||
docs_worthy: false,
|
||||
pending: true,
|
||||
reason,
|
||||
target_sections: [],
|
||||
priority: "medium",
|
||||
}
|
||||
}
|
||||
|
||||
function triageChunk(chunk, index, budgetDeadline) {
|
||||
const chunkFile = `${OUT_DIR}/triage-chunk-${index}.json`
|
||||
fs.writeFileSync(chunkFile, JSON.stringify(chunk, null, 2))
|
||||
|
||||
let lastCause = "triage failed to classify this PR"
|
||||
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
|
||||
let raw
|
||||
try {
|
||||
// Message positional first: --file is multi-value and would otherwise
|
||||
// consume a trailing message as a file path ("File not found").
|
||||
raw = execFileSync(
|
||||
"kilo",
|
||||
["run", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile],
|
||||
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 10 * 60 * 1000, stdio: ["ignore", "pipe", "pipe"] },
|
||||
const left = remainingMs(budgetDeadline)
|
||||
if (left < CHUNK_TIMEOUT_MS) {
|
||||
lastCause = `triage budget exhausted before chunk ${index} attempt ${attempt}`
|
||||
console.warn(
|
||||
`chunk ${index}: stopping retries — remaining budget cannot fit another ${CHUNK_TIMEOUT_MS / 60000}m attempt`,
|
||||
)
|
||||
} catch (err) {
|
||||
const stderr = String(err.stderr ?? "").trim().split("\n").slice(-5).join("\n")
|
||||
console.warn(`chunk ${index} attempt ${attempt}: kilo run failed: ${stderr || err.message}`)
|
||||
continue
|
||||
break
|
||||
}
|
||||
fs.writeFileSync(`${OUT_DIR}/triage-raw-${index}.txt`, raw)
|
||||
const entries = parseTriageEntries(raw)
|
||||
|
||||
const result = runKilo({
|
||||
args: ["run", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile],
|
||||
timeoutMs: Math.min(CHUNK_TIMEOUT_MS, left),
|
||||
streamStdout: false,
|
||||
label: `triage chunk ${index} attempt ${attempt}`,
|
||||
})
|
||||
|
||||
const raw = result.stdout
|
||||
if (raw) fs.writeFileSync(`${OUT_DIR}/triage-raw-${index}.txt`, raw)
|
||||
|
||||
const entries = raw ? parseTriageEntries(raw) : null
|
||||
if (entries) {
|
||||
// An entry for a PR outside this chunk must not win the shared dedupe
|
||||
// against the chunk that actually owns it — drop foreign entries.
|
||||
@@ -62,18 +92,35 @@ function triageChunk(chunk, index) {
|
||||
}
|
||||
if (owned.length > 0) return owned
|
||||
}
|
||||
console.warn(`chunk ${index} attempt ${attempt}: no valid JSON in output`)
|
||||
|
||||
// Exit 0 is not success: unparseable output is a failure logged WITH
|
||||
// the captured stderrTail and exit code on every attempt.
|
||||
const cause = formatCause(result)
|
||||
lastCause = `triage chunk ${index}: ${cause}`
|
||||
console.warn(
|
||||
`chunk ${index} attempt ${attempt}: no valid JSON in output` +
|
||||
` (exit ${result.exitCode}${result.timedOut ? ", timed out" : ""})` +
|
||||
(result.stderrTail ? `\nstderr tail:\n${result.stderrTail}` : "\nstderr tail: (empty)"),
|
||||
)
|
||||
|
||||
if (attempt < ATTEMPTS) {
|
||||
const wait = backoffMsForAttempt(attempt)
|
||||
const afterWait = remainingMs(budgetDeadline) - wait
|
||||
if (wait > 0 && afterWait >= CHUNK_TIMEOUT_MS) {
|
||||
console.warn(`chunk ${index}: backing off ${wait / 1000}s before attempt ${attempt + 1}`)
|
||||
sleepSync(wait)
|
||||
} else if (wait > 0) {
|
||||
console.warn(
|
||||
`chunk ${index}: skipping backoff — remaining budget cannot fit attempt ${attempt + 1} after wait`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(`::warning::chunk ${index} failed triage after ${ATTEMPTS} attempts; marking ${chunk.length} PRs unclassified`)
|
||||
return chunk.map((d) => ({
|
||||
pr: d.number,
|
||||
url: d.url,
|
||||
docs_worthy: false,
|
||||
reason: "triage failed to classify this PR",
|
||||
target_sections: [],
|
||||
priority: "medium",
|
||||
}))
|
||||
console.warn(
|
||||
`::warning::chunk ${index} failed triage after up to ${ATTEMPTS} attempts; marking ${chunk.length} PRs pending`,
|
||||
)
|
||||
return chunk.map((d) => pendingEntry(d, lastCause.includes("triage failed") ? lastCause : `triage failed to classify this PR (${lastCause})`))
|
||||
}
|
||||
|
||||
const chunks = []
|
||||
@@ -82,30 +129,61 @@ for (let i = 0; i < digest.length; i += CHUNK_SIZE) {
|
||||
}
|
||||
console.log(`triaging ${digest.length} PRs in ${chunks.length} chunks of up to ${CHUNK_SIZE}`)
|
||||
|
||||
const budgetDeadline = deadline(TRIAGE_BUDGET_MINUTES)
|
||||
const merged = []
|
||||
const seen = new Set()
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
for (const e of triageChunk(chunks[i], i)) {
|
||||
const left = remainingMs(budgetDeadline)
|
||||
if (left < CHUNK_TIMEOUT_MS) {
|
||||
const deferredPrs = chunks.slice(i).reduce((n, c) => n + c.length, 0)
|
||||
console.warn(
|
||||
`stopping triage before chunk ${i}: remaining budget (${Math.ceil(left / 1000)}s) cannot fit a ${CHUNK_TIMEOUT_MS / 60000}m chunk; deferring ${deferredPrs} PRs`,
|
||||
)
|
||||
const cause = `triage budget exhausted before chunk ${i} (${Math.ceil(left / 1000)}s left)`
|
||||
for (let j = i; j < chunks.length; j++) {
|
||||
for (const d of chunks[j]) {
|
||||
if (seen.has(d.url)) continue
|
||||
seen.add(d.url)
|
||||
merged.push(pendingEntry(d, cause))
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
for (const e of triageChunk(chunks[i], i, budgetDeadline)) {
|
||||
if (seen.has(e.url)) continue
|
||||
seen.add(e.url)
|
||||
merged.push(e)
|
||||
}
|
||||
}
|
||||
|
||||
// Coverage: every digest PR gets a triage entry so the PR body's skipped
|
||||
// table is complete. Unclassified defaults to not-docs-worthy (conservative).
|
||||
// Coverage: every digest PR gets a triage entry. Partial-chunk backfill and
|
||||
// any other missing URL are pending:true — not a genuine "not worthy" verdict.
|
||||
for (const d of digest) {
|
||||
if (seen.has(d.url)) continue
|
||||
merged.push({
|
||||
pr: d.number,
|
||||
url: d.url,
|
||||
docs_worthy: false,
|
||||
reason: "not classified by triage",
|
||||
target_sections: [],
|
||||
priority: "medium",
|
||||
})
|
||||
merged.push(pendingEntry(d, "not classified by triage"))
|
||||
}
|
||||
|
||||
fs.writeFileSync(`${OUT_DIR}/triage.json`, JSON.stringify(merged, null, 2))
|
||||
const worthy = merged.filter((e) => e.docs_worthy).length
|
||||
console.log(`triage complete: ${merged.length} entries, ${worthy} docs-worthy`)
|
||||
const pending = merged.filter((e) => e.pending === true)
|
||||
console.log(`triage complete: ${merged.length} entries, ${worthy} docs-worthy, ${pending.length} pending`)
|
||||
|
||||
// Upsert is gated off when worthy == 0, so triage emits its own Step Summary
|
||||
// listing every PR it marked pending:true and why.
|
||||
if (pending.length > 0) {
|
||||
const lines = pending.map((e) => `- [${e.url}] ${e.reason}`)
|
||||
appendSummary(
|
||||
`### docs-sync: triage pending (will retry)\n\n${pending.length} PR(s) were not classified and will be re-collected on the next run:\n\n${lines.join("\n")}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Replay warning (S2j): warn IFF since-override AND something pending AND
|
||||
// docs-worthy count is 0 (Upsert is gated off, so noDiffReport never runs).
|
||||
const sinceOverride = process.env.SINCE_OVERRIDE === "true"
|
||||
if (sinceOverride && pending.length > 0 && worthy === 0) {
|
||||
console.warn(
|
||||
"::warning::docs-sync since-override replay left uncovered PRs and wrote no PR body (worthy=0); re-run the override — the watermark was not held back in the body",
|
||||
)
|
||||
}
|
||||
|
||||
+245
-15
@@ -8,6 +8,14 @@
|
||||
* diff exceeds the file cap or verification failed. The PR body carries
|
||||
* marker-delimited sections so later runs can append rows, plus a
|
||||
* machine-readable processed-through watermark.
|
||||
*
|
||||
* Watermark invariant: processed-through never moves past a PR that has no
|
||||
* terminal outcome. Terminal := action !== "pending" (a deliberate agent
|
||||
* "skipped" IS terminal). Uncovered PRs hold the marker at earliest
|
||||
* merged_at − 1 ms so collect's merged:>=since re-collects them next run.
|
||||
* Three review rounds found four independent defects in a queue-based
|
||||
* alternative (unreachable gate, empty-PR creation, cap-overflow loss,
|
||||
* draft-state corruption); a held-back watermark has none of those modes.
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process"
|
||||
@@ -17,6 +25,7 @@ import { pathToFileURL } from "node:url"
|
||||
const BRANCH = process.env.BRANCH || "docs/auto-sync"
|
||||
const FILE_CAP = 15
|
||||
const ROW_CAP = 150
|
||||
const PENDING_DISPLAY_CAP = 60
|
||||
const SUMMARY_FILE = ".docs-sync-summary.json"
|
||||
const DOCS_PATH = "packages/kilo-docs"
|
||||
|
||||
@@ -42,6 +51,11 @@ function skippedRow(e) {
|
||||
return `| [${shortRef(e.url)}](${clean(e.url)}) | ${reason} |`
|
||||
}
|
||||
|
||||
function pendingRow(e) {
|
||||
const reason = clean(e.reason ?? e.cause ?? "").replaceAll("|", "\\|").replaceAll("\n", " ")
|
||||
return `| [${shortRef(e.url)}](${clean(e.url)}) | ${reason} |`
|
||||
}
|
||||
|
||||
export function extractSectionRows(body, name) {
|
||||
const m = String(body ?? "").match(
|
||||
new RegExp(`<!--\\s*docs-sync:${name}:start\\s*-->([\\s\\S]*?)<!--\\s*docs-sync:${name}:end\\s*-->`),
|
||||
@@ -50,7 +64,14 @@ export function extractSectionRows(body, name) {
|
||||
return m[1]
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.startsWith("|") && !l.startsWith("| ---") && !/^\|\s*Docs change/.test(l) && !/^\|\s*PR\s*\|/.test(l))
|
||||
.filter(
|
||||
(l) =>
|
||||
l.startsWith("|") &&
|
||||
!l.startsWith("| ---") &&
|
||||
!/^\|\s*Docs change/.test(l) &&
|
||||
!/^\|\s*PR\s*\|/.test(l) &&
|
||||
!/^\|\s*Why\s*\|/.test(l),
|
||||
)
|
||||
}
|
||||
|
||||
function section(name, header, rows) {
|
||||
@@ -58,7 +79,12 @@ function section(name, header, rows) {
|
||||
return `<!-- docs-sync:${name}:start -->\n${body}\n<!-- docs-sync:${name}:end -->`
|
||||
}
|
||||
|
||||
export function renderBody({ date, since, through, changesRows, skippedRows, verified, draftReasons, note }) {
|
||||
export function renderBody({ date, since, through, changesRows, pendingRows, skippedRows, verified, draftReasons, note }) {
|
||||
const pendingDisplay =
|
||||
pendingRows.length > PENDING_DISPLAY_CAP
|
||||
? [...pendingRows.slice(0, PENDING_DISPLAY_CAP), `| +${pendingRows.length - PENDING_DISPLAY_CAP} more | |`]
|
||||
: pendingRows
|
||||
|
||||
return `## Automated docs sync — ${date}
|
||||
|
||||
This PR keeps kilo.ai/docs in sync with features merged to [Kilo-Org/cloud](https://github.com/Kilo-Org/cloud) and [Kilo-Org/kilocode](https://github.com/Kilo-Org/kilocode). Every change below links to the merged PR it documents.
|
||||
@@ -70,6 +96,10 @@ ${note ? `- ${note}\n` : ""}${draftReasons.length > 0 ? `- Draft because: ${draf
|
||||
|
||||
${section("changes", "| Docs change | Source |", changesRows)}
|
||||
|
||||
### Pending — will retry
|
||||
|
||||
${section("pending", "| PR | Why |", pendingDisplay)}
|
||||
|
||||
### Considered, no docs change needed
|
||||
|
||||
${section("skipped", "| PR | Reason |", skippedRows)}
|
||||
@@ -100,32 +130,222 @@ function readJson(path, fallback) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uncovered = (worthy URLs with no summary row) ∪ (summary action "pending")
|
||||
* ∪ (triage entries with pending: true). A worthy PR is covered iff it has a
|
||||
* summary row whose action !== "pending" and carries no triage pending flag.
|
||||
*/
|
||||
export function computeUncovered({ worthy, summary, triage }) {
|
||||
const worthyList = Array.isArray(worthy) ? worthy : []
|
||||
const summaryList = Array.isArray(summary) ? summary : []
|
||||
const triageList = Array.isArray(triage) ? triage : []
|
||||
|
||||
const summaryByUrl = new Map()
|
||||
for (const e of summaryList) {
|
||||
if (e?.url) summaryByUrl.set(e.url, e)
|
||||
}
|
||||
|
||||
const triagePendingByUrl = new Map()
|
||||
for (const e of triageList) {
|
||||
if (e?.url && e.pending === true) triagePendingByUrl.set(e.url, e)
|
||||
}
|
||||
|
||||
/** @type {Map<string, { url: string, pr?: number, reason: string }>} */
|
||||
const out = new Map()
|
||||
|
||||
for (const w of worthyList) {
|
||||
const url = w?.url
|
||||
if (!url) continue
|
||||
const row = summaryByUrl.get(url)
|
||||
if (!row) {
|
||||
out.set(url, {
|
||||
url,
|
||||
pr: w.number ?? w.pr,
|
||||
reason: "no edit summary row (edit pass did not cover this PR)",
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (row.action === "pending") {
|
||||
out.set(url, {
|
||||
url,
|
||||
pr: row.pr ?? w.number ?? w.pr,
|
||||
reason: row.reason || "edit pass pending",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Summary pending rows for URLs not in worthy (defensive).
|
||||
for (const row of summaryList) {
|
||||
if (row?.action === "pending" && row.url && !out.has(row.url)) {
|
||||
out.set(row.url, {
|
||||
url: row.url,
|
||||
pr: row.pr,
|
||||
reason: row.reason || "edit pass pending",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const [url, e] of triagePendingByUrl) {
|
||||
if (out.has(url)) continue
|
||||
out.set(url, {
|
||||
url,
|
||||
pr: e.pr,
|
||||
reason: e.reason || "triage pending",
|
||||
})
|
||||
}
|
||||
|
||||
return [...out.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* processed-through = now when uncovered is empty; otherwise earliest
|
||||
* merged_at among uncovered PRs minus 1 ms (from digest-full.json).
|
||||
* When uncovered is non-empty but no merged_at resolves, hold at
|
||||
* `fallback` (the run's window start / SINCE): every uncovered PR was
|
||||
* collected via merged:>=since, so holding there re-collects all of them.
|
||||
* Never advance past unresolved uncovered PRs (Defect-B permanent-loss).
|
||||
*/
|
||||
export function computeProcessedThrough({ uncovered, digest, now, fallback }) {
|
||||
const nowIso = typeof now === "string" ? now : new Date(now).toISOString()
|
||||
if (!uncovered || uncovered.length === 0) return nowIso
|
||||
|
||||
const digestList = Array.isArray(digest) ? digest : []
|
||||
const byUrl = new Map(digestList.filter((d) => d?.url).map((d) => [d.url, d]))
|
||||
|
||||
let earliest = null
|
||||
for (const u of uncovered) {
|
||||
const d = byUrl.get(u.url)
|
||||
const mergedAt = d?.merged_at
|
||||
if (!mergedAt) continue
|
||||
const t = Date.parse(mergedAt)
|
||||
if (!Number.isFinite(t)) continue
|
||||
if (earliest === null || t < earliest) earliest = t
|
||||
}
|
||||
|
||||
if (earliest === null) {
|
||||
// digest-full missing/corrupt while uncovered is non-empty: hold at
|
||||
// window start so collect's merged:>=since re-collects every PR.
|
||||
// Never use now−1ms — that strands uncovered PRs permanently.
|
||||
const fallbackMs = fallback == null ? NaN : Date.parse(fallback)
|
||||
if (!Number.isFinite(fallbackMs)) {
|
||||
throw new Error(
|
||||
`docs-sync: cannot resolve merged_at for ${uncovered.length} uncovered PR(s) and fallback/SINCE is missing or unparseable; refusing to advance processed-through`,
|
||||
)
|
||||
}
|
||||
const fallbackIso = new Date(fallbackMs).toISOString()
|
||||
console.warn(
|
||||
`::warning::docs-sync: merge times for ${uncovered.length} uncovered PR(s) could not be resolved; holding watermark at window start ${fallbackIso}`,
|
||||
)
|
||||
return fallbackIso
|
||||
}
|
||||
|
||||
return new Date(earliest - 1).toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Route summary + triage into the three body sections.
|
||||
* changesRows = action neither skipped nor pending
|
||||
* pendingRows = uncovered from computeUncovered
|
||||
* skippedRows = action === "skipped" ∪ triage docs_worthy false && !pending
|
||||
*/
|
||||
export function routeRows({ summary, triage, uncovered }) {
|
||||
const summaryList = Array.isArray(summary) ? summary : []
|
||||
const triageList = Array.isArray(triage) ? triage : []
|
||||
const uncoveredList = Array.isArray(uncovered) ? uncovered : []
|
||||
|
||||
const changesEntries = summaryList.filter((e) => e.action !== "skipped" && e.action !== "pending")
|
||||
const skippedEntries = [
|
||||
...triageList.filter((e) => e.docs_worthy === false && e.pending !== true),
|
||||
...summaryList.filter((e) => e.action === "skipped"),
|
||||
]
|
||||
|
||||
return {
|
||||
changesRows: changesEntries.map(changeRow),
|
||||
pendingRows: uncoveredList.map(pendingRow),
|
||||
skippedRows: skippedEntries.map(skippedRow),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop pre-existing Considered rows whose reason contains any of the three
|
||||
* legacy failure literals (substring match — live rows carry longer strings).
|
||||
* Genuine no-doc-needed rows are untouched.
|
||||
*/
|
||||
export function dropLegacySkipped(rows) {
|
||||
const list = Array.isArray(rows) ? rows : []
|
||||
const needles = ["edit pass failed or timed out", "triage failed to classify", "not classified by triage"]
|
||||
return list.filter((row) => {
|
||||
const s = String(row ?? "")
|
||||
return !needles.some((n) => s.includes(n))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* No-diff early-return report. Returns summary markdown and an optional
|
||||
* replay warning. Warns IFF sinceOverride && uncovered non-empty (no commit
|
||||
* happened — that is the caller's situation).
|
||||
*/
|
||||
export function noDiffReport({ uncovered, sinceOverride }) {
|
||||
const list = Array.isArray(uncovered) ? uncovered : []
|
||||
const lines =
|
||||
list.length === 0
|
||||
? ["The agent found nothing worth documenting in this window."]
|
||||
: [
|
||||
`No packages/kilo-docs diff was produced, but ${list.length} PR(s) remain uncovered and will be re-collected on the next scheduled run:`,
|
||||
"",
|
||||
...list.map((u) => `- [${u.url}] ${u.reason || "uncovered"}`),
|
||||
]
|
||||
|
||||
const summary = `### docs-sync: no docs changes\n\n${lines.join("\n")}`
|
||||
|
||||
let warning = null
|
||||
if (sinceOverride && list.length > 0) {
|
||||
warning =
|
||||
"docs-sync since-override replay left uncovered PRs and wrote no PR body (no docs commit); re-run the override — the watermark was not held back in the body"
|
||||
}
|
||||
|
||||
return { summary, warning }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { api, appendOutput, appendSummary, repo } = await import("./lib.mjs")
|
||||
|
||||
const through = process.env.PROCESSED_THROUGH ?? new Date().toISOString()
|
||||
const now = process.env.PROCESSED_THROUGH ?? new Date().toISOString()
|
||||
const since = process.env.SINCE ?? "unknown"
|
||||
const sinceOverride = process.env.SINCE_OVERRIDE === "true"
|
||||
const mode = ["update", "conflict"].includes(process.env.PREP_MODE) ? process.env.PREP_MODE : "fresh"
|
||||
const existingPr = process.env.PR_NUMBER || ""
|
||||
const verified = process.env.VERIFIED === "true"
|
||||
const date = through.slice(0, 10)
|
||||
const date = now.slice(0, 10)
|
||||
|
||||
// The agent's run summary is consumed here and never committed.
|
||||
const agentSummary = readJson(SUMMARY_FILE, [])
|
||||
fs.rmSync(SUMMARY_FILE, { force: true })
|
||||
const triage = readJson("docs-sync-out/triage.json", [])
|
||||
const worthy = readJson("docs-sync-out/worthy.json", [])
|
||||
const digest = readJson("docs-sync-out/digest-full.json", [])
|
||||
|
||||
// Order matters: compute uncovered BEFORE the no-diff early return so
|
||||
// noDiffReport can name every held-back PR.
|
||||
const uncovered = computeUncovered({ worthy, summary: agentSummary, triage })
|
||||
|
||||
if (git(["status", "--porcelain", "--", DOCS_PATH]) === "") {
|
||||
console.log("no packages/kilo-docs changes produced; nothing to commit")
|
||||
appendSummary("### docs-sync: no docs changes\n\nThe agent found nothing worth documenting in this window.")
|
||||
const { summary, warning } = noDiffReport({ uncovered, sinceOverride })
|
||||
appendSummary(summary)
|
||||
if (warning) console.warn(`::warning::${warning}`)
|
||||
return
|
||||
}
|
||||
|
||||
git(["config", "user.name", "github-actions[bot]"])
|
||||
git(["config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"])
|
||||
// Git identity is configured once in docs-sync.yml (Configure git identity)
|
||||
// before any commit-creating step, including prepare-branch's merge.
|
||||
git(["add", DOCS_PATH])
|
||||
git(["commit", "-m", `docs: sync with merged PRs (${date})`])
|
||||
|
||||
// Watermark: now when fully covered; else earliest uncovered merged_at − 1ms.
|
||||
// Pass SINCE as fallback so missing digest-full cannot strand uncovered PRs.
|
||||
const through = computeProcessedThrough({ uncovered, digest, now, fallback: since })
|
||||
|
||||
// The draft cap bounds the cumulative PR diff, not just this run's commit.
|
||||
const changedFiles = git(["diff", "--name-only", "origin/main...HEAD", "--", DOCS_PATH])
|
||||
.split("\n")
|
||||
@@ -151,25 +371,33 @@ async function main() {
|
||||
|
||||
git(mode === "update" ? ["push", "origin", `HEAD:${BRANCH}`] : ["push", "--force-with-lease", "origin", `HEAD:${BRANCH}`])
|
||||
|
||||
const changesNew = agentSummary.filter((e) => e.action !== "skipped").map(changeRow)
|
||||
const skippedNew = [
|
||||
...triage.filter((e) => e.docs_worthy === false),
|
||||
...agentSummary.filter((e) => e.action === "skipped"),
|
||||
].map(skippedRow)
|
||||
const { changesRows: changesNew, pendingRows: pendingNew, skippedRows: skippedNew } = routeRows({
|
||||
summary: agentSummary,
|
||||
triage,
|
||||
uncovered,
|
||||
})
|
||||
|
||||
let oldChanges = []
|
||||
let oldSkipped = []
|
||||
let oldPending = []
|
||||
if (mode === "update" && existingPr) {
|
||||
const pr = await api(`/repos/${repo()}/pulls/${existingPr}`)
|
||||
oldChanges = extractSectionRows(pr.body, "changes")
|
||||
oldSkipped = extractSectionRows(pr.body, "skipped")
|
||||
oldSkipped = dropLegacySkipped(extractSectionRows(pr.body, "skipped"))
|
||||
oldPending = extractSectionRows(pr.body, "pending")
|
||||
}
|
||||
|
||||
// Pending is replaced each run (informational only); do not merge legacy
|
||||
// pending rows — uncovered is recomputed fresh. oldPending is read only so
|
||||
// extractSectionRows stays exercised; discarded deliberately.
|
||||
void oldPending
|
||||
|
||||
const body = renderBody({
|
||||
date,
|
||||
since,
|
||||
through,
|
||||
changesRows: mergeRows(oldChanges, changesNew),
|
||||
pendingRows: pendingNew,
|
||||
skippedRows: mergeRows(oldSkipped, skippedNew),
|
||||
verified,
|
||||
draftReasons,
|
||||
@@ -228,8 +456,10 @@ async function main() {
|
||||
}
|
||||
|
||||
appendOutput("pr_url", prUrl)
|
||||
appendSummary(`### docs-sync PR\n\n- ${prUrl}\n- changed files: ${changedFiles.length}\n- draft: ${draft}\n`)
|
||||
console.log(`PR ${prNumber}: ${prUrl} (draft=${draft}, files=${changedFiles.length})`)
|
||||
appendSummary(
|
||||
`### docs-sync PR\n\n- ${prUrl}\n- changed files: ${changedFiles.length}\n- draft: ${draft}\n- uncovered: ${uncovered.length}\n- processed-through: ${through}\n`,
|
||||
)
|
||||
console.log(`PR ${prNumber}: ${prUrl} (draft=${draft}, files=${changedFiles.length}, uncovered=${uncovered.length}, through=${through})`)
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
*
|
||||
* Priority: workflow_dispatch input `since` > latest open bot PR marker >
|
||||
* last merged bot PR marker > 72h ago. Hard cap: never look back more than
|
||||
* 14 days.
|
||||
* 14 days — unless the human explicitly requested a window via INPUT_SINCE.
|
||||
*/
|
||||
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { appendOutput, appendSummary, repo, searchIssues } from "./lib.mjs"
|
||||
|
||||
const FALLBACK_HOURS = 72
|
||||
@@ -42,34 +43,65 @@ async function findWatermark() {
|
||||
return null
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
let since
|
||||
|
||||
const input = (process.env.INPUT_SINCE ?? "").trim()
|
||||
if (input) {
|
||||
since = new Date(input)
|
||||
if (Number.isNaN(since.getTime())) {
|
||||
throw new Error(`Invalid INPUT_SINCE: ${input}`)
|
||||
/**
|
||||
* Apply the 14-day lookback cap. When `explicit` is true (dispatch override),
|
||||
* the cap is skipped so a human-requested recovery window is not silently
|
||||
* shortened. Returns `{ since, clamped }`.
|
||||
*/
|
||||
export function applyCap(since, now, { explicit = false } = {}) {
|
||||
if (explicit) {
|
||||
console.log(`14-day cap skipped: INPUT_SINCE was set explicitly (${since.toISOString()})`)
|
||||
return { since, clamped: false }
|
||||
}
|
||||
console.log(`watermark from dispatch input: ${since.toISOString()}`)
|
||||
} else {
|
||||
since =
|
||||
(await findWatermark()) ?? new Date(now.getTime() - FALLBACK_HOURS * 3600 * 1000)
|
||||
const cap = new Date(now.getTime() - CAP_DAYS * 24 * 3600 * 1000)
|
||||
if (since < cap) {
|
||||
const from = since.toISOString()
|
||||
const to = cap.toISOString()
|
||||
console.warn(
|
||||
`::warning::docs-sync watermark clamped from ${from} to ${to} (${CAP_DAYS}-day cap). ` +
|
||||
`Anything still uncovered before ${to} is abandoned and needs a human.`,
|
||||
)
|
||||
return { since: cap, clamped: true }
|
||||
}
|
||||
return { since, clamped: false }
|
||||
}
|
||||
|
||||
// A forged, edited, or malformed marker in the future would silently match
|
||||
// nothing in the merged:>= search; clamp it loudly.
|
||||
if (since > now) {
|
||||
console.warn(`watermark ${since.toISOString()} is in the future, clamping to now`)
|
||||
since = now
|
||||
async function main() {
|
||||
const now = new Date()
|
||||
let since
|
||||
let explicit = false
|
||||
|
||||
const input = (process.env.INPUT_SINCE ?? "").trim()
|
||||
if (input) {
|
||||
since = new Date(input)
|
||||
if (Number.isNaN(since.getTime())) {
|
||||
throw new Error(`Invalid INPUT_SINCE: ${input}`)
|
||||
}
|
||||
explicit = true
|
||||
console.log(`watermark from dispatch input: ${since.toISOString()}`)
|
||||
} else {
|
||||
since = (await findWatermark()) ?? new Date(now.getTime() - FALLBACK_HOURS * 3600 * 1000)
|
||||
}
|
||||
|
||||
// A forged, edited, or malformed marker in the future would silently match
|
||||
// nothing in the merged:>= search; clamp it loudly.
|
||||
if (since > now) {
|
||||
console.warn(`watermark ${since.toISOString()} is in the future, clamping to now`)
|
||||
since = now
|
||||
}
|
||||
|
||||
;({ since } = applyCap(since, now, { explicit }))
|
||||
|
||||
appendOutput("since", since.toISOString())
|
||||
appendOutput("now", now.toISOString())
|
||||
appendOutput("since_override", explicit ? "true" : "false")
|
||||
appendSummary(`### docs-sync watermark\n\n- since: \`${since.toISOString()}\`\n- now: \`${now.toISOString()}\`\n`)
|
||||
}
|
||||
|
||||
const cap = new Date(now.getTime() - CAP_DAYS * 24 * 3600 * 1000)
|
||||
if (since < cap) {
|
||||
console.log(`watermark ${since.toISOString()} older than ${CAP_DAYS}d cap, clamping`)
|
||||
since = cap
|
||||
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
if (isMain) {
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
appendOutput("since", since.toISOString())
|
||||
appendOutput("now", now.toISOString())
|
||||
appendSummary(`### docs-sync watermark\n\n- since: \`${since.toISOString()}\`\n- now: \`${now.toISOString()}\`\n`)
|
||||
|
||||
@@ -5,9 +5,13 @@ name: docs-sync
|
||||
# triages them for docs relevance, runs Kilo CLI headless to update
|
||||
# packages/kilo-docs, and maintains one rolling PR for human review.
|
||||
#
|
||||
# Security posture: scheduled/manual only, checks out main, never executes
|
||||
# code from PR branches. State is derived from the bot's own PRs (watermark
|
||||
# marker in the PR body), so missed or failed runs self-heal on the next run.
|
||||
# Security posture: scheduled/manual runs check out the dispatched ref and may
|
||||
# push/comment with write permissions and org secrets. PR runs (paths-limited to
|
||||
# this workflow and .github/docs-sync/**) execute branch code only in a
|
||||
# read-only, secretless `selftest` job that never pushes, comments, or calls an
|
||||
# LLM. `pull_request` (not `pull_request_target`) keeps fork tokens read-only.
|
||||
# State is derived from the bot's own PRs (watermark marker in the PR body), so
|
||||
# missed or failed runs self-heal on the next run.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -22,6 +26,10 @@ on:
|
||||
description: "Collect + triage only, no edits, no PR"
|
||||
type: boolean
|
||||
default: false
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/docs-sync/**"
|
||||
- ".github/workflows/docs-sync.yml"
|
||||
|
||||
permissions:
|
||||
contents: write # push the rolling branch, create the auto-docs label
|
||||
@@ -29,7 +37,7 @@ permissions:
|
||||
issues: write # comment on the rolling PR
|
||||
|
||||
concurrency:
|
||||
group: docs-sync
|
||||
group: ${{ github.event_name == 'pull_request' && format('docs-sync-pr-{0}', github.event.pull_request.number) || 'docs-sync' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
@@ -37,15 +45,14 @@ env:
|
||||
EDIT_MODEL: ${{ vars.DOCS_SYNC_EDIT_MODEL || 'kilo/moonshotai/kimi-k3' }}
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
selftest:
|
||||
if: github.repository == 'Kilo-Org/kilocode'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # prepare-branch merges main into the rolling branch
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
@@ -53,6 +60,39 @@ jobs:
|
||||
node-version: "24"
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Run docs-sync selftest
|
||||
run: node .github/docs-sync/selftest.mjs
|
||||
|
||||
sync:
|
||||
if: github.repository == 'Kilo-Org/kilocode' && github.event_name != 'pull_request'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
# Budget: 3 setup/collect + 35 triage + 50 edit + 2 verify + 10 fix + 2 upsert = 102 min, 18-minute reserve.
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
# Both are required: without KILO_ORG_ID the gateway bills the key
|
||||
# owner's personal balance (402 "Add credits") instead of the org.
|
||||
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
|
||||
KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # prepare-branch merges main into the rolling branch
|
||||
|
||||
- name: Configure git identity
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Run docs-sync selftest
|
||||
run: node .github/docs-sync/selftest.mjs
|
||||
|
||||
- name: Install Kilo CLI
|
||||
run: |
|
||||
npm install -g @kilocode/cli
|
||||
@@ -75,7 +115,7 @@ jobs:
|
||||
id: triage
|
||||
if: steps.collect.outputs.count != '0'
|
||||
env:
|
||||
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
|
||||
SINCE_OVERRIDE: ${{ steps.wm.outputs.since_override }}
|
||||
run: node .github/docs-sync/triage.mjs
|
||||
|
||||
- name: Filter docs-worthy PRs
|
||||
@@ -101,10 +141,18 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: node .github/docs-sync/prepare-branch.mjs
|
||||
|
||||
# After prepare-branch checks out the rolling branch and merges main, the
|
||||
# worktree holds main's scripts. Restore the dispatched ref's copies so a
|
||||
# branch-dispatch AC9 run actually exercises the fixed code. git restore
|
||||
# (not checkout) leaves them unstaged so upsert-pr's bare commit won't
|
||||
# include them in the docs PR.
|
||||
- name: Restore docs-sync scripts from the dispatched ref
|
||||
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
|
||||
run: git restore --source=${{ github.sha }} -- .github/docs-sync
|
||||
|
||||
- name: Update docs (Kilo CLI, batched)
|
||||
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
|
||||
env:
|
||||
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
|
||||
continue-on-error: true
|
||||
run: node .github/docs-sync/edit.mjs
|
||||
|
||||
- name: Verify docs build and tests
|
||||
@@ -121,8 +169,8 @@ jobs:
|
||||
id: fix
|
||||
if: steps.verify.outcome == 'failure'
|
||||
continue-on-error: true
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
|
||||
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
@@ -150,6 +198,7 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PROCESSED_THROUGH: ${{ steps.wm.outputs.now }}
|
||||
SINCE: ${{ steps.wm.outputs.since }}
|
||||
SINCE_OVERRIDE: ${{ steps.wm.outputs.since_override }}
|
||||
BRANCH: ${{ steps.prep.outputs.branch }}
|
||||
PREP_MODE: ${{ steps.prep.outputs.mode }}
|
||||
PR_NUMBER: ${{ steps.prep.outputs.pr_number }}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
# kilocode_change - new file
|
||||
name: publish-jetbrains-bundled
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr:
|
||||
description: Merged JetBrains release PR number to bundle
|
||||
required: true
|
||||
type: string
|
||||
merge_commit:
|
||||
description: Merge commit SHA from the reviewed release PR
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: publish-jetbrains-bundled-pr-${{ inputs.pr }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
if: github.repository == 'Kilo-Org/kilocode'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
kind: ${{ steps.release.outputs.kind }}
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
channel: ${{ steps.release.outputs.marketplace_channel }}
|
||||
steps:
|
||||
- name: Checkout trusted validation scripts
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
|
||||
- name: Setup Bun for validation
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Checkout merged release PR for validation
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
path: release
|
||||
persist-credentials: false
|
||||
ref: ${{ inputs.merge_commit }}
|
||||
|
||||
- name: Validate release PR and tag
|
||||
id: release
|
||||
working-directory: release
|
||||
run: bun ../script/jetbrains-release-validate.ts --pr "$PR_NUMBER"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ inputs.pr }}
|
||||
|
||||
bundle:
|
||||
needs: validate
|
||||
if: github.repository == 'Kilo-Org/kilocode'
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
outputs:
|
||||
version: ${{ needs.validate.outputs.version }}
|
||||
kind: ${{ needs.validate.outputs.kind }}
|
||||
steps:
|
||||
- name: Checkout merged release PR metadata
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
ref: ${{ inputs.merge_commit }}
|
||||
|
||||
- name: Save reviewed release metadata
|
||||
run: |
|
||||
cp packages/kilo-jetbrains/CHANGELOG.md "$RUNNER_TEMP/jetbrains-CHANGELOG.md"
|
||||
cp packages/kilo-jetbrains/gradle.properties "$RUNNER_TEMP/jetbrains-gradle.properties"
|
||||
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ needs.validate.outputs.tag }}
|
||||
|
||||
- name: Restore reviewed release metadata
|
||||
run: |
|
||||
cp "$RUNNER_TEMP/jetbrains-CHANGELOG.md" packages/kilo-jetbrains/CHANGELOG.md
|
||||
cp "$RUNNER_TEMP/jetbrains-gradle.properties" packages/kilo-jetbrains/gradle.properties
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y patchelf zip unzip
|
||||
curl --fail --location \
|
||||
https://ziglang.org/download/0.14.0/zig-linux-x86_64-0.14.0.tar.xz \
|
||||
--output "$RUNNER_TEMP/zig.tar.xz"
|
||||
echo "473ec26806133cf4d1918caf1a410f8403a13d979726a9045b421b685031a982 $RUNNER_TEMP/zig.tar.xz" | sha256sum --check --status
|
||||
tar -xJf "$RUNNER_TEMP/zig.tar.xz" -C "$RUNNER_TEMP"
|
||||
echo "$RUNNER_TEMP/zig-linux-x86_64-0.14.0" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Validate signing secrets
|
||||
run: |
|
||||
missing=0
|
||||
for name in JETBRAINS_CERTIFICATE_CHAIN JETBRAINS_PRIVATE_KEY JETBRAINS_PRIVATE_KEY_PASSWORD; do
|
||||
if [[ -z "${!name}" ]]; then
|
||||
echo "Missing required secret: $name" >&2
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
exit "$missing"
|
||||
env:
|
||||
JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
|
||||
JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
|
||||
JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: Write signing secrets to temp files
|
||||
run: |
|
||||
dir="$RUNNER_TEMP/jetbrains-signing"
|
||||
mkdir -m 700 -p "$dir"
|
||||
chain="$dir/certificate-chain.pem"
|
||||
key="$dir/private-key.pem"
|
||||
umask 077
|
||||
printf '%s' "$JETBRAINS_CERTIFICATE_CHAIN" > "$chain"
|
||||
printf '%s' "$JETBRAINS_PRIVATE_KEY" > "$key"
|
||||
chmod 600 "$chain" "$key"
|
||||
echo "JETBRAINS_CERTIFICATE_CHAIN_FILE=$chain" >> "$GITHUB_ENV"
|
||||
echo "JETBRAINS_PRIVATE_KEY_FILE=$key" >> "$GITHUB_ENV"
|
||||
env:
|
||||
JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
|
||||
JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
|
||||
|
||||
- name: Build signed bundled plugin
|
||||
working-directory: packages/kilo-jetbrains
|
||||
run: |
|
||||
args=(
|
||||
-Pproduction=true
|
||||
-Pkilo.version="$VERSION"
|
||||
-Pkilo.channel="$CHANNEL"
|
||||
-Pkilo.cli.bundled=true
|
||||
)
|
||||
./gradlew clean buildPlugin signPlugin verifyPluginSignature verifyPlugin "${args[@]}"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
CHANNEL: ${{ needs.validate.outputs.channel }}
|
||||
JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: Remove signing secret temp files
|
||||
if: always()
|
||||
run: rm -rf "$RUNNER_TEMP/jetbrains-signing"
|
||||
|
||||
- name: Resolve bundled archive
|
||||
id: archive
|
||||
run: |
|
||||
mapfile -t signed < <(compgen -G "packages/kilo-jetbrains/build/distributions/*-signed.zip")
|
||||
if [[ "${#signed[@]}" -ne 1 ]]; then
|
||||
echo "Expected exactly one signed bundled JetBrains plugin ZIP, found ${#signed[@]}." >&2
|
||||
printf '%s\n' "${signed[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
asset="kilo-code-${VERSION}-bundled.zip"
|
||||
dest="packages/kilo-jetbrains/build/release/$asset"
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
cp "${signed[0]}" "$dest"
|
||||
echo "asset=$asset" >> "$GITHUB_OUTPUT"
|
||||
echo "path=$dest" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
|
||||
- name: Upload bundled ZIP to GitHub Release
|
||||
run: gh release upload "$TAG" "$ARCHIVE" --clobber --repo "$GITHUB_REPOSITORY"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.validate.outputs.tag }}
|
||||
ARCHIVE: ${{ steps.archive.outputs.path }}
|
||||
|
||||
- name: Resolve bundled asset URL
|
||||
id: asset
|
||||
run: |
|
||||
url="$(gh release view "$TAG" --json assets --jq '.assets[] | select(.name == env.ASSET) | .url' --repo "$GITHUB_REPOSITORY")"
|
||||
if [[ -z "$url" ]]; then
|
||||
echo "Could not resolve GitHub Release URL for $ASSET" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.validate.outputs.tag }}
|
||||
ASSET: ${{ steps.archive.outputs.asset }}
|
||||
|
||||
- name: Generate stable plugin repository XML
|
||||
if: needs.validate.outputs.kind == 'stable'
|
||||
run: |
|
||||
mkdir -p pages/jetbrains
|
||||
python3 <<'PY'
|
||||
import html
|
||||
import io
|
||||
import os
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
archive = os.environ["ARCHIVE"]
|
||||
asset = os.environ["ASSET_URL"]
|
||||
version = os.environ["VERSION"]
|
||||
|
||||
def plugin_xml(path):
|
||||
with zipfile.ZipFile(path) as zip:
|
||||
for name in zip.namelist():
|
||||
if name.endswith("META-INF/plugin.xml"):
|
||||
return zip.read(name)
|
||||
for name in zip.namelist():
|
||||
if not name.endswith(".jar"):
|
||||
continue
|
||||
with zipfile.ZipFile(io.BytesIO(zip.read(name))) as jar:
|
||||
for item in jar.namelist():
|
||||
if item.endswith("META-INF/plugin.xml"):
|
||||
return jar.read(item)
|
||||
raise SystemExit("bundled plugin ZIP did not contain META-INF/plugin.xml")
|
||||
|
||||
root = ET.fromstring(plugin_xml(archive))
|
||||
|
||||
def text(name, default=""):
|
||||
item = root.find(name)
|
||||
return item.text.strip() if item is not None and item.text else default
|
||||
|
||||
def cdata(value):
|
||||
return "<![CDATA[" + value.replace("]]>", "]]]]><![CDATA[>") + "]]>"
|
||||
|
||||
plugin = text("id", "ai.kilocode.jetbrains")
|
||||
name = text("name", "Kilo Code")
|
||||
vendor = text("vendor", "Kilo Code")
|
||||
desc = text("description")
|
||||
notes = text("change-notes")
|
||||
idea = root.find("idea-version")
|
||||
attrs = ""
|
||||
if idea is not None:
|
||||
since = idea.attrib.get("since-build")
|
||||
until = idea.attrib.get("until-build")
|
||||
if since:
|
||||
attrs += f' since-build="{html.escape(since)}"'
|
||||
if until:
|
||||
attrs += f' until-build="{html.escape(until)}"'
|
||||
|
||||
xml = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<plugins>',
|
||||
f' <plugin id="{html.escape(plugin)}" version="{html.escape(version)}" url="{html.escape(asset)}">',
|
||||
f' <name>{html.escape(name)}</name>',
|
||||
f' <vendor>{html.escape(vendor)}</vendor>',
|
||||
f' <idea-version{attrs}/>',
|
||||
]
|
||||
if desc:
|
||||
xml.append(f' <description>{cdata(desc)}</description>')
|
||||
if notes:
|
||||
xml.append(f' <change-notes>{cdata(notes)}</change-notes>')
|
||||
xml.extend([' </plugin>', '</plugins>', ''])
|
||||
with open("pages/jetbrains/updatePlugins.xml", "w", encoding="utf-8") as file:
|
||||
file.write("\n".join(xml))
|
||||
PY
|
||||
env:
|
||||
ARCHIVE: ${{ steps.archive.outputs.path }}
|
||||
ASSET_URL: ${{ steps.asset.outputs.url }}
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
|
||||
- name: Upload stable Pages source
|
||||
if: needs.validate.outputs.kind == 'stable'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: jetbrains-pages-${{ needs.validate.outputs.version }}
|
||||
path: pages
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload workflow artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: kilo-jetbrains-bundled-${{ needs.validate.outputs.version }}
|
||||
path: |
|
||||
packages/kilo-jetbrains/build/release/*.zip
|
||||
pages/jetbrains/updatePlugins.xml
|
||||
if-no-files-found: ignore
|
||||
|
||||
pages:
|
||||
needs: bundle
|
||||
if: needs.bundle.outputs.kind == 'stable'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
actions: read
|
||||
id-token: write
|
||||
pages: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Download stable Pages source
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: jetbrains-pages-${{ needs.bundle.outputs.version }}
|
||||
path: pages
|
||||
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: pages
|
||||
|
||||
- name: Deploy Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -23,6 +23,7 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
pull-requests: read
|
||||
|
||||
@@ -199,6 +200,19 @@ jobs:
|
||||
ARCHIVE: ${{ steps.archive.outputs.path }}
|
||||
NOTES: packages/kilo-jetbrains/build/release-notes.md
|
||||
|
||||
- name: Dispatch bundled GitHub release build
|
||||
continue-on-error: true
|
||||
run: |
|
||||
gh workflow run publish-jetbrains-bundled.yml \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--ref main \
|
||||
-f pr="$PR_NUMBER" \
|
||||
-f merge_commit="$MERGE_COMMIT"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr }}
|
||||
MERGE_COMMIT: ${{ github.event.pull_request.merge_commit_sha || inputs.merge_commit }}
|
||||
|
||||
- name: Upload workflow artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
v1.17.5
|
||||
v1.17.9
|
||||
|
||||
@@ -52,6 +52,9 @@ _Avoid_: Request body, wire options
|
||||
**Generation Controls**:
|
||||
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
|
||||
|
||||
**PTY Environment**:
|
||||
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
|
||||
@@ -99,6 +102,8 @@ Provider-neutral sampling and output controls, partitioned from provider semanti
|
||||
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
|
||||
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
|
||||
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
|
||||
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `KILO_TERMINAL`.
|
||||
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
|
||||
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
|
||||
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
|
||||
- Ambient project instruction discovery honors `KILO_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
|
||||
|
||||
@@ -798,6 +798,7 @@
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@shikijs/stream": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
"@solid-primitives/bounds": "0.1.3",
|
||||
"@solid-primitives/event-listener": "2.4.5",
|
||||
@@ -844,22 +845,22 @@
|
||||
},
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"esbuild",
|
||||
"protobufjs",
|
||||
"web-tree-sitter",
|
||||
"esbuild",
|
||||
"tree-sitter-bash",
|
||||
"protobufjs",
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch",
|
||||
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
|
||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch",
|
||||
"@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch",
|
||||
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@effect/platform-node-shared": "4.0.0-beta.74",
|
||||
@@ -898,13 +899,15 @@
|
||||
"@opentui/core": "0.3.4",
|
||||
"@opentui/keymap": "0.3.4",
|
||||
"@opentui/solid": "0.3.4",
|
||||
"@pierre/diffs": "1.1.22",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@solid-primitives/storage": "4.3.3",
|
||||
"@solidjs/meta": "0.29.4",
|
||||
"@solidjs/router": "0.15.4",
|
||||
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"@tanstack/solid-virtual": "3.13.28",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@types/bun": "1.3.14",
|
||||
@@ -931,7 +934,7 @@
|
||||
"remeda": "2.26.0",
|
||||
"remend": "1.3.0",
|
||||
"semver": "7.7.4",
|
||||
"shiki": "3.20.0",
|
||||
"shiki": "4.2.0",
|
||||
"solid-js": "1.9.12",
|
||||
"solid-list": "0.3.0",
|
||||
"sst": "4.13.1",
|
||||
@@ -1956,9 +1959,11 @@
|
||||
|
||||
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="],
|
||||
|
||||
"@pierre/diffs": ["@pierre/diffs@1.1.22", "", { "dependencies": { "@pierre/theme": "0.0.28", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-1Iv7kdl6OABFCd1n2HQbGUiRHouXPaHoIjcb7Lwg8zeJKY5ph+cESFcGEyIiwW0NCbKGtYS2bTnXmI+Eze5dwg=="],
|
||||
"@pierre/diffs": ["@pierre/diffs@1.2.10", "", { "dependencies": { "@pierre/theme": "1.0.3", "@pierre/theming": "0.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-rPeAmDWarxFVTQpaf4y6wTxjZxU44xKJKoJti2zU21P06DVd9nRHZX+xSIObLB307Qjpaesyb1x/j0z94t7vLw=="],
|
||||
|
||||
"@pierre/theme": ["@pierre/theme@0.0.28", "", {}, "sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw=="],
|
||||
"@pierre/theme": ["@pierre/theme@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="],
|
||||
|
||||
"@pierre/theming": ["@pierre/theming@0.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-1thlEtJbqdyLzc1ZS2KQa1q7FzDGHT4dTEdKHoyQjOMeWWOmbVG5/ndEfOKfAb5Fzkz8cNJrOjFLiZoDH/A03A=="],
|
||||
|
||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||
|
||||
@@ -2076,15 +2081,19 @@
|
||||
|
||||
"@secretlint/types": ["@secretlint/types@10.2.2", "", {}, "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg=="],
|
||||
|
||||
"@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
|
||||
"@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="],
|
||||
|
||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="],
|
||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="],
|
||||
|
||||
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="],
|
||||
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g=="],
|
||||
|
||||
"@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="],
|
||||
"@shikijs/langs": ["@shikijs/langs@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ=="],
|
||||
|
||||
"@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="],
|
||||
"@shikijs/primitive": ["@shikijs/primitive@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA=="],
|
||||
|
||||
"@shikijs/stream": ["@shikijs/stream@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0" }, "peerDependencies": { "react": "^19.0.0", "solid-js": "^1.9.0", "vue": "^3.2.0" }, "optionalPeers": ["react", "solid-js", "vue"] }, "sha512-OaMUUStdIZ+l1GJad9uVACR3Xvgwo4y+RmEuDMU62cgFMMg1IBCaIFmvzAR2HiCpGtwoc/qPfpNnP+ivgrPXZg=="],
|
||||
|
||||
"@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="],
|
||||
|
||||
"@shikijs/transformers": ["@shikijs/transformers@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/types": "3.9.2" } }, "sha512-MW5hT4TyUp6bNAgTExRYLk1NNasVQMTCw1kgbxHcEC0O5cbepPWaB+1k+JzW9r3SP2/R8kiens8/3E6hGKfgsA=="],
|
||||
|
||||
@@ -4306,7 +4315,7 @@
|
||||
|
||||
"shell-quote": ["shell-quote@1.8.4", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="],
|
||||
|
||||
"shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="],
|
||||
"shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
|
||||
|
||||
@@ -5160,13 +5169,19 @@
|
||||
|
||||
"@qdrant/js-client-rest/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
|
||||
|
||||
"@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||
"@shikijs/core/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||
|
||||
"@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||
"@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||
|
||||
"@shikijs/langs/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||
"@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||
|
||||
"@shikijs/themes/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||
"@shikijs/langs/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||
|
||||
"@shikijs/primitive/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||
|
||||
"@shikijs/themes/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||
|
||||
"@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
|
||||
|
||||
"@smithy/config-resolver/@smithy/core": ["@smithy/core@3.24.6", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug=="],
|
||||
|
||||
@@ -5594,9 +5609,7 @@
|
||||
|
||||
"sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
||||
|
||||
"shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||
"shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||
|
||||
"slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
exact = true
|
||||
# Keep Kilo's longer supply-chain quarantine while allowing packages that must track coordinated releases.
|
||||
minimumReleaseAge = 410520 # seconds (~4.75 days / ~114 hours)
|
||||
minimumReleaseAgeExcludes = ["mermaid", "@mermaid-js/parser", "@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
|
||||
minimumReleaseAgeExcludes = ["mermaid", "@mermaid-js/parser", "@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
|
||||
|
||||
[test]
|
||||
root = "./do-not-run-tests-from-root"
|
||||
|
||||
@@ -21,59 +21,7 @@
|
||||
devShells = forEachSystem (pkgs: {
|
||||
default =
|
||||
let
|
||||
# Pin bun to the version declared in package.json (packageManager: "bun@1.3.14").
|
||||
# The locked nixpkgs revision ships 1.3.11, so we fetch the official release directly.
|
||||
bun =
|
||||
let
|
||||
sources = {
|
||||
"aarch64-linux" = {
|
||||
name = "bun-linux-aarch64";
|
||||
hash = "sha256-on/7Y6gxA3WDbg1vZorhf6jY0YuIw3yCHGUzGXOhmjs=";
|
||||
};
|
||||
"x86_64-linux" = {
|
||||
name = "bun-linux-x64";
|
||||
hash = "sha256-lR7iruhV8IWVruxiJSJqKY0/6oOj3NZGXAnLzN9+hI8=";
|
||||
};
|
||||
"aarch64-darwin" = {
|
||||
name = "bun-darwin-aarch64";
|
||||
hash = "sha256-2LliIYKK1vl6x6wKt+lYcjQa92MAHogD6CZ2UsJlJiA=";
|
||||
};
|
||||
"x86_64-darwin" = {
|
||||
name = "bun-darwin-x64";
|
||||
hash = "sha256-QYPfM3RiPlurMVxUfPoJdFM81FfYa3O2OfeoeXTNZjM=";
|
||||
};
|
||||
};
|
||||
source =
|
||||
sources.${pkgs.stdenv.hostPlatform.system}
|
||||
or (throw "Unsupported system for bun: ${pkgs.stdenv.hostPlatform.system}");
|
||||
in
|
||||
pkgs.stdenv.mkDerivation rec {
|
||||
pname = "bun";
|
||||
version = "1.3.14";
|
||||
src = pkgs.fetchurl {
|
||||
url = "https://github.com/oven-sh/bun/releases/download/bun-v${version}/${source.name}.zip";
|
||||
inherit (source) hash;
|
||||
};
|
||||
nativeBuildInputs = [
|
||||
pkgs.unzip
|
||||
] ++ pkgs.lib.optional pkgs.stdenv.isLinux pkgs.autoPatchelfHook;
|
||||
buildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.stdenv.cc.cc.lib ];
|
||||
dontConfigure = true;
|
||||
dontBuild = true;
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
install -Dm755 bun $out/bin/bun
|
||||
ln -s $out/bin/bun $out/bin/bunx
|
||||
runHook postInstall
|
||||
'';
|
||||
meta = {
|
||||
description = "Fast all-in-one JavaScript runtime";
|
||||
homepage = "https://bun.sh";
|
||||
license = pkgs.lib.licenses.mit;
|
||||
mainProgram = "bun";
|
||||
platforms = builtins.attrNames sources;
|
||||
};
|
||||
};
|
||||
bun = pkgs.callPackage ./nix/bun.nix { };
|
||||
|
||||
kilo-dev = pkgs.writeShellScriptBin "kilo-dev" ''
|
||||
set -euo pipefail
|
||||
@@ -279,11 +227,12 @@
|
||||
packages = forEachSystem (
|
||||
pkgs:
|
||||
let
|
||||
bun = pkgs.callPackage ./nix/bun.nix { };
|
||||
node_modules = pkgs.callPackage ./nix/node_modules.nix {
|
||||
inherit rev;
|
||||
inherit bun rev;
|
||||
};
|
||||
kilo = pkgs.callPackage ./nix/kilo.nix {
|
||||
inherit node_modules;
|
||||
inherit bun node_modules;
|
||||
};
|
||||
in
|
||||
{
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
fetchurl,
|
||||
unzip,
|
||||
autoPatchelfHook,
|
||||
}:
|
||||
let
|
||||
package = lib.pipe ../package.json [
|
||||
builtins.readFile
|
||||
builtins.fromJSON
|
||||
];
|
||||
version = lib.removePrefix "bun@" package.packageManager;
|
||||
sources = {
|
||||
"aarch64-linux" = {
|
||||
name = "bun-linux-aarch64";
|
||||
hash = "sha256-on/7Y6gxA3WDbg1vZorhf6jY0YuIw3yCHGUzGXOhmjs=";
|
||||
};
|
||||
"x86_64-linux" = {
|
||||
name = "bun-linux-x64";
|
||||
hash = "sha256-lR7iruhV8IWVruxiJSJqKY0/6oOj3NZGXAnLzN9+hI8=";
|
||||
};
|
||||
"aarch64-darwin" = {
|
||||
name = "bun-darwin-aarch64";
|
||||
hash = "sha256-2LliIYKK1vl6x6wKt+lYcjQa92MAHogD6CZ2UsJlJiA=";
|
||||
};
|
||||
"x86_64-darwin" = {
|
||||
name = "bun-darwin-x64";
|
||||
hash = "sha256-QYPfM3RiPlurMVxUfPoJdFM81FfYa3O2OfeoeXTNZjM=";
|
||||
};
|
||||
};
|
||||
source =
|
||||
sources.${stdenv.hostPlatform.system}
|
||||
or (throw "Unsupported system for bun: ${stdenv.hostPlatform.system}");
|
||||
in
|
||||
stdenv.mkDerivation {
|
||||
pname = "bun";
|
||||
inherit version;
|
||||
src = fetchurl {
|
||||
url = "https://github.com/oven-sh/bun/releases/download/bun-v${version}/${source.name}.zip";
|
||||
inherit (source) hash;
|
||||
};
|
||||
nativeBuildInputs = [ unzip ] ++ lib.optional stdenv.isLinux autoPatchelfHook;
|
||||
buildInputs = lib.optionals stdenv.isLinux [ stdenv.cc.cc.lib ];
|
||||
dontConfigure = true;
|
||||
dontBuild = true;
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
install -Dm755 bun $out/bin/bun
|
||||
ln -s $out/bin/bun $out/bin/bunx
|
||||
runHook postInstall
|
||||
'';
|
||||
meta = {
|
||||
description = "Fast all-in-one JavaScript runtime";
|
||||
homepage = "https://bun.sh";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "bun";
|
||||
platforms = builtins.attrNames sources;
|
||||
};
|
||||
}
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-UHxMHmx17Jex0yXgbpXCvIORubs9cFMsIXGacvs9+gA=",
|
||||
"aarch64-linux": "sha256-tTnW84VaNEbfo46H24ETKZgFumZMwu6pgNDlY8KYkqo=",
|
||||
"aarch64-darwin": "sha256-tBi4D1tfACA4ogYMdyjUK8sDb370rAGE2q8baeJjpdA=",
|
||||
"x86_64-darwin": "sha256-MPIai9M+EQps81qp7RBmTsW/qPjcrWd7GGJQyeNCz/U="
|
||||
"x86_64-linux": "sha256-Y64ujq2R4SiyOavsXmaYsQkVNfdjhkzmPolOEV1RVyc=",
|
||||
"aarch64-linux": "sha256-7S209ir2j9o9NXYmMUnEfo6m64cUIkexd8L95UeCMFE=",
|
||||
"aarch64-darwin": "sha256-bE1zw7EK+4rgUgw80t8hdc2yojAF2f8ihjorh6B7jJE=",
|
||||
"x86_64-darwin": "sha256-xukM9cEvZAKCXewun9xReGBKz09i63LACLN/eH54020="
|
||||
}
|
||||
}
|
||||
|
||||
+11
-5
@@ -45,7 +45,7 @@
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@pierre/diffs": "1.1.22",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"opentui-spinner": "0.0.7",
|
||||
"@solid-primitives/storage": "4.3.3",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
@@ -70,7 +70,7 @@
|
||||
"@typescript/native-preview": "7.0.0-dev.20260316.1",
|
||||
"zod": "4.1.8",
|
||||
"remeda": "2.26.0",
|
||||
"shiki": "3.20.0",
|
||||
"shiki": "4.2.0",
|
||||
"solid-list": "0.3.0",
|
||||
"tailwindcss": "4.1.11",
|
||||
"virtua": "0.49.1",
|
||||
@@ -85,7 +85,9 @@
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.74",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"sst": "4.13.1"
|
||||
"sst": "4.13.1",
|
||||
"@tanstack/solid-virtual": "3.13.28",
|
||||
"@shikijs/stream": "4.2.0"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -157,11 +159,15 @@
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch",
|
||||
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
|
||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
|
||||
"@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch",
|
||||
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
|
||||
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch"
|
||||
},
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
"migration": "bun run script/migration.ts",
|
||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||
"test": "bun test --only-failures",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
|
||||
},
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode"
|
||||
|
||||
@@ -10,8 +10,7 @@ import { Location } from "./location"
|
||||
import { EventV2 } from "./event"
|
||||
import { Policy } from "./policy"
|
||||
import { State } from "./state"
|
||||
import { Credential } from "./credential"
|
||||
import { IntegrationSchema } from "./integration/schema"
|
||||
import { Integration } from "./integration"
|
||||
|
||||
export type ProviderRecord = {
|
||||
provider: ProviderV2.Info
|
||||
@@ -35,12 +34,7 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundErr
|
||||
export const PolicyActions = Schema.Literals(["provider.use"])
|
||||
|
||||
export const Event = {
|
||||
ModelUpdated: EventV2.define({
|
||||
type: "catalog.model.updated",
|
||||
schema: {
|
||||
model: ModelV2.Info,
|
||||
},
|
||||
}),
|
||||
Updated: EventV2.define({ type: "catalog.updated", schema: {} }),
|
||||
}
|
||||
|
||||
type Data = {
|
||||
@@ -96,31 +90,17 @@ export const layer = Layer.effect(
|
||||
const plugin = yield* PluginV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const policy = yield* Policy.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const project = (provider: ProviderV2.Info, active: Map<IntegrationSchema.ID, Credential.Stored>) => {
|
||||
const credential = active.get(IntegrationSchema.ID.make(provider.id))
|
||||
if (!credential) return provider
|
||||
const body = { ...provider.request.body }
|
||||
if (credential.value.type === "key") {
|
||||
body.apiKey = credential.value.key
|
||||
Object.assign(body, credential.value.metadata ?? {})
|
||||
}
|
||||
// kilocode_change start - preserve Kilo organization routing from migrated OAuth credentials
|
||||
if (credential.value.type === "oauth") {
|
||||
body.apiKey = credential.value.access
|
||||
if (credential.value.metadata?.accountID) body.kilocodeOrganizationId = credential.value.metadata.accountID
|
||||
}
|
||||
// kilocode_change end
|
||||
return new ProviderV2.Info({
|
||||
...provider,
|
||||
enabled: { via: "credential", credentialID: credential.id },
|
||||
request: { ...provider.request, body },
|
||||
})
|
||||
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => {
|
||||
if (provider.disabled) return false
|
||||
if (typeof provider.request.body.apiKey === "string") return true
|
||||
if (connected) return true
|
||||
return !integration
|
||||
}
|
||||
|
||||
const resolve = (model: ModelV2.Info, provider: ProviderV2.Info) => {
|
||||
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
|
||||
const api =
|
||||
model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0
|
||||
? { ...provider.api, id: model.api.id }
|
||||
@@ -208,18 +188,16 @@ export const layer = Layer.effect(
|
||||
},
|
||||
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) {
|
||||
if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid)
|
||||
if (!policy.hasStatements()) return
|
||||
for (const record of [...catalog.provider.list()]) {
|
||||
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
|
||||
catalog.provider.remove(record.provider.id)
|
||||
if (policy.hasStatements()) {
|
||||
for (const record of [...catalog.provider.list()]) {
|
||||
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
|
||||
catalog.provider.remove(record.provider.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
})
|
||||
const active = Effect.fn("CatalogV2.active")(function* () {
|
||||
return new Map((yield* credentials.all()).map((credential) => [credential.integrationID, credential]))
|
||||
})
|
||||
|
||||
yield* events.subscribe(PluginV2.Event.Added).pipe(
|
||||
// Plugin registries are location scoped even though the event bus is process scoped.
|
||||
Stream.filter(
|
||||
@@ -238,18 +216,23 @@ export const layer = Layer.effect(
|
||||
provider: {
|
||||
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
|
||||
const record = yield* getRecord(providerID)
|
||||
return project(record.provider, yield* active())
|
||||
return record.provider
|
||||
}),
|
||||
|
||||
all: Effect.fn("CatalogV2.provider.all")(function* () {
|
||||
const credentials = yield* active()
|
||||
return Array.fromIterable(state.get().providers.values()).map((record) =>
|
||||
project(record.provider, credentials),
|
||||
)
|
||||
return Array.fromIterable(state.get().providers.values()).map((record) => record.provider)
|
||||
}),
|
||||
|
||||
available: Effect.fn("CatalogV2.provider.available")(function* () {
|
||||
return (yield* result.provider.all()).filter((provider) => provider.enabled)
|
||||
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
|
||||
const connections = yield* integrations.connection.list()
|
||||
return (yield* result.provider.all()).filter((provider) =>
|
||||
available(
|
||||
provider,
|
||||
active.get(Integration.ID.make(provider.id)),
|
||||
connections.has(Integration.ID.make(provider.id)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -258,33 +241,32 @@ export const layer = Layer.effect(
|
||||
const record = yield* getRecord(providerID)
|
||||
const model = record.models.get(modelID)
|
||||
if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
|
||||
return resolve(model, project(record.provider, yield* active()))
|
||||
return projectModel(model, record.provider)
|
||||
}),
|
||||
|
||||
all: Effect.fn("CatalogV2.model.all")(function* () {
|
||||
const credentials = yield* active()
|
||||
return pipe(
|
||||
Array.fromIterable(state.get().providers.values()),
|
||||
Array.flatMap((record) => {
|
||||
const provider = project(record.provider, credentials)
|
||||
return Array.fromIterable(record.models.values()).map((model) => resolve(model, provider))
|
||||
return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider))
|
||||
}),
|
||||
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
|
||||
)
|
||||
}),
|
||||
|
||||
available: Effect.fn("CatalogV2.model.available")(function* () {
|
||||
const providers = new Map((yield* result.provider.all()).map((provider) => [provider.id, provider]))
|
||||
return (yield* result.model.all()).filter(
|
||||
(model) => providers.get(model.providerID)?.enabled !== false && model.enabled,
|
||||
)
|
||||
const providers = new Set((yield* result.provider.available()).map((provider) => provider.id))
|
||||
return (yield* result.model.all()).filter((model) => providers.has(model.providerID) && model.enabled)
|
||||
}),
|
||||
|
||||
default: Effect.fn("CatalogV2.model.default")(function* () {
|
||||
const defaultModel = state.get().defaultModel
|
||||
if (defaultModel) {
|
||||
const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option)
|
||||
if (Option.isSome(provider) && provider.value.enabled !== false) {
|
||||
if (
|
||||
Option.isSome(provider) &&
|
||||
(yield* result.provider.available()).some((item) => item.id === provider.value.id)
|
||||
) {
|
||||
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
|
||||
if (Option.isSome(model) && model.value.enabled) return model
|
||||
}
|
||||
@@ -300,11 +282,11 @@ export const layer = Layer.effect(
|
||||
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
|
||||
const record = state.get().providers.get(providerID)
|
||||
if (!record) return Option.none<ModelV2.Info>()
|
||||
const provider = project(record.provider, yield* active())
|
||||
const provider = record.provider
|
||||
|
||||
if (providerID === ProviderV2.ID.opencode) {
|
||||
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
|
||||
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano, provider))
|
||||
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(projectModel(gpt5Nano, provider))
|
||||
}
|
||||
|
||||
const candidates = pipe(
|
||||
@@ -332,7 +314,7 @@ export const layer = Layer.effect(
|
||||
return pipe(
|
||||
items,
|
||||
Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number),
|
||||
Array.map((item) => resolve(item.model, provider)),
|
||||
Array.map((item) => projectModel(item.model, provider)),
|
||||
Array.head,
|
||||
)
|
||||
}
|
||||
@@ -353,6 +335,7 @@ export const layer = Layer.effect(
|
||||
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
|
||||
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(Integration.locationLayer),
|
||||
Layer.provideMerge(PluginV2.locationLayer),
|
||||
Layer.provideMerge(Policy.locationLayer),
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ export * as ConfigProviderPlugin from "./provider"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { Config } from "../../config"
|
||||
import { Integration } from "../../integration"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ModelRequest } from "../../model-request"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
@@ -13,9 +14,33 @@ export const Plugin = PluginV2.define({
|
||||
effect: Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const config = yield* Config.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const transform = yield* catalog.transform()
|
||||
const integrationTransform = yield* integrations.transform()
|
||||
const entries = yield* config.entries()
|
||||
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const configuredIntegrations = new Set(
|
||||
files.flatMap((file) =>
|
||||
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
|
||||
),
|
||||
)
|
||||
yield* integrationTransform((integrations) => {
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const integrationID = Integration.ID.make(id)
|
||||
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
|
||||
integrations.update(integrationID, (integration) => {
|
||||
integration.name = item.name ?? integration.name
|
||||
})
|
||||
if (item.env !== undefined) {
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: { type: "env", names: [...item.env] },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
yield* transform((catalog) => {
|
||||
const configuredDefault = Config.latest(entries, "model")
|
||||
@@ -28,8 +53,6 @@ export const Plugin = PluginV2.define({
|
||||
const providerID = ProviderV2.ID.make(id)
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.env !== undefined) provider.env = [...item.env]
|
||||
provider.enabled = { via: "custom", data: {} }
|
||||
if (item.api !== undefined) provider.api = { ...item.api }
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(provider.request.headers, item.request.headers)
|
||||
|
||||
@@ -102,6 +102,8 @@ export interface Interface {
|
||||
readonly all: () => Effect.Effect<Stored[]>
|
||||
/** Returns stored credentials belonging to one integration. */
|
||||
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Stored[]>
|
||||
/** Returns one stored credential by ID. */
|
||||
readonly get: (id: ID) => Effect.Effect<Stored | undefined>
|
||||
/** Replaces any credential for an integration and returns the new record. */
|
||||
readonly create: (input: {
|
||||
readonly integrationID: IntegrationSchema.ID
|
||||
@@ -350,6 +352,11 @@ export const layer = Layer.effect(
|
||||
return credential ? [credential] : []
|
||||
})
|
||||
}),
|
||||
get: Effect.fn("Credential.get")(function* (id) {
|
||||
if (isolated) return find(id) // kilocode_change - injected workspace credentials are process-local
|
||||
const row = yield* db.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get().pipe(Effect.orDie)
|
||||
return row ? stored(row) : undefined
|
||||
}),
|
||||
create: Effect.fn("Credential.create")(function* (input) {
|
||||
const credential = new Stored({
|
||||
id: ID.create(),
|
||||
|
||||
@@ -29,15 +29,16 @@ export const When = Schema.Struct({
|
||||
}).annotate({ identifier: "Integration.When" })
|
||||
export type When = typeof When.Type
|
||||
|
||||
export class TextPrompt extends Schema.Class<TextPrompt>("Integration.TextPrompt")({
|
||||
export const TextPrompt = Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
key: Schema.String,
|
||||
message: Schema.String,
|
||||
placeholder: Schema.optional(Schema.String),
|
||||
when: Schema.optional(When),
|
||||
}) {}
|
||||
}).annotate({ identifier: "Integration.TextPrompt" })
|
||||
export type TextPrompt = typeof TextPrompt.Type
|
||||
|
||||
export class SelectPrompt extends Schema.Class<SelectPrompt>("Integration.SelectPrompt")({
|
||||
export const SelectPrompt = Schema.Struct({
|
||||
type: Schema.Literal("select"),
|
||||
key: Schema.String,
|
||||
message: Schema.String,
|
||||
@@ -49,27 +50,31 @@ export class SelectPrompt extends Schema.Class<SelectPrompt>("Integration.Select
|
||||
}),
|
||||
),
|
||||
when: Schema.optional(When),
|
||||
}) {}
|
||||
}).annotate({ identifier: "Integration.SelectPrompt" })
|
||||
export type SelectPrompt = typeof SelectPrompt.Type
|
||||
|
||||
export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Prompt = typeof Prompt.Type
|
||||
|
||||
export class OAuthMethod extends Schema.Class<OAuthMethod>("Integration.OAuthMethod")({
|
||||
export const OAuthMethod = Schema.Struct({
|
||||
id: MethodID,
|
||||
type: Schema.Literal("oauth"),
|
||||
label: Schema.String,
|
||||
prompts: Schema.optional(Schema.Array(Prompt)),
|
||||
}) {}
|
||||
}).annotate({ identifier: "Integration.OAuthMethod" })
|
||||
export type OAuthMethod = typeof OAuthMethod.Type
|
||||
|
||||
export class KeyMethod extends Schema.Class<KeyMethod>("Integration.KeyMethod")({
|
||||
export const KeyMethod = Schema.Struct({
|
||||
type: Schema.Literal("key"),
|
||||
label: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
}).annotate({ identifier: "Integration.KeyMethod" })
|
||||
export type KeyMethod = typeof KeyMethod.Type
|
||||
|
||||
export class EnvMethod extends Schema.Class<EnvMethod>("Integration.EnvMethod")({
|
||||
export const EnvMethod = Schema.Struct({
|
||||
type: Schema.Literal("env"),
|
||||
names: Schema.Array(Schema.String),
|
||||
}) {}
|
||||
}).annotate({ identifier: "Integration.EnvMethod" })
|
||||
export type EnvMethod = typeof EnvMethod.Type
|
||||
|
||||
export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Method = typeof Method.Type
|
||||
@@ -197,7 +202,11 @@ export interface Interface {
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
/** Returns all integrations with their methods and current connections. */
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly connect: {
|
||||
readonly connection: {
|
||||
/** Returns active connections for every registered or credential-backed integration. */
|
||||
readonly list: () => Effect.Effect<Map<ID, IntegrationConnection.Info>>
|
||||
/** Returns the active connection for one integration. */
|
||||
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
|
||||
/** Runs a key method and stores the resulting credential. */
|
||||
readonly key: (input: {
|
||||
/** Integration receiving the credential. */
|
||||
@@ -218,6 +227,13 @@ export interface Interface {
|
||||
/** User-facing label for the credential created on completion. */
|
||||
readonly label?: string
|
||||
}) => Effect.Effect<Attempt, AuthorizationError>
|
||||
/** Updates a stored credential exposed as a connection. */
|
||||
readonly update: (
|
||||
credentialID: Credential.ID,
|
||||
updates: Partial<Pick<Credential.Stored, "label">>,
|
||||
) => Effect.Effect<void>
|
||||
/** Removes a stored credential connection. */
|
||||
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
|
||||
}
|
||||
readonly attempt: {
|
||||
/** Returns the current state of an OAuth attempt. */
|
||||
@@ -328,23 +344,32 @@ export const locationLayer = Layer.effect(
|
||||
})
|
||||
|
||||
const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => {
|
||||
const connected = saved.map(
|
||||
(credential) =>
|
||||
new IntegrationConnection.CredentialInfo({ type: "credential", id: credential.id, label: credential.label }),
|
||||
)
|
||||
const connected = saved.map((credential) => ({
|
||||
type: "credential" as const,
|
||||
id: credential.id,
|
||||
label: credential.label,
|
||||
}))
|
||||
const detected = entry.methods
|
||||
.filter((method) => method.type === "env")
|
||||
.flatMap((method) => method.names.filter((name) => process.env[name]))
|
||||
.map(
|
||||
(name, index) =>
|
||||
new IntegrationConnection.EnvInfo({
|
||||
type: "env",
|
||||
name,
|
||||
}),
|
||||
)
|
||||
.map((name) => ({ type: "env" as const, name }))
|
||||
return [...connected, ...detected]
|
||||
}
|
||||
|
||||
const activeConnection = (
|
||||
entry: Entry | undefined,
|
||||
saved: readonly Credential.Stored[],
|
||||
): IntegrationConnection.Info | undefined => {
|
||||
const credential = saved.at(-1)
|
||||
if (credential) return { type: "credential", id: credential.id, label: credential.label }
|
||||
if (!entry) return
|
||||
const name = entry.methods
|
||||
.filter((method) => method.type === "env")
|
||||
.flatMap((method) => method.names)
|
||||
.find((name) => process.env[name])
|
||||
if (name) return { type: "env", name }
|
||||
}
|
||||
|
||||
const project = (entry: Entry, saved: readonly Credential.Stored[]) =>
|
||||
new Info({
|
||||
id: entry.ref.id,
|
||||
@@ -412,6 +437,7 @@ export const locationLayer = Layer.effect(
|
||||
return [attempt, new Map(current).set(attemptID, terminal)]
|
||||
})
|
||||
if (!result) return settled
|
||||
if (Exit.isSuccess(settled)) yield* events.publish(Event.Updated, {})
|
||||
yield* close(result.scope)
|
||||
return settled
|
||||
}),
|
||||
@@ -454,8 +480,21 @@ export const locationLayer = Layer.effect(
|
||||
}),
|
||||
)).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
}),
|
||||
connect: {
|
||||
key: Effect.fn("Integration.connect.key")(function* (input) {
|
||||
connection: {
|
||||
list: Effect.fn("Integration.connection.list")(function* () {
|
||||
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
|
||||
return new Map(
|
||||
new Set([...state.get().integrations.keys(), ...saved.keys()]).values().flatMap((id) => {
|
||||
const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? [])
|
||||
return connection ? [[id, connection] as const] : []
|
||||
}),
|
||||
)
|
||||
}),
|
||||
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
|
||||
const entry = state.get().integrations.get(id)
|
||||
return activeConnection(entry, yield* credentials.list(id))
|
||||
}),
|
||||
key: Effect.fn("Integration.connection.key")(function* (input) {
|
||||
const method = state
|
||||
.get()
|
||||
.integrations.get(input.integrationID)
|
||||
@@ -466,8 +505,9 @@ export const locationLayer = Layer.effect(
|
||||
label: input.label,
|
||||
value: new Credential.Key({ type: "key", key: input.key }),
|
||||
})
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
oauth: Effect.fn("Integration.connect.oauth")(function* (input) {
|
||||
oauth: Effect.fn("Integration.connection.oauth")(function* (input) {
|
||||
const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID)
|
||||
if (!method) {
|
||||
return yield* Effect.die(`OAuth method not found: ${input.integrationID}/${input.methodID}`)
|
||||
@@ -510,6 +550,14 @@ export const locationLayer = Layer.effect(
|
||||
time,
|
||||
})
|
||||
}),
|
||||
update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) {
|
||||
yield* credentials.update(credentialID, updates)
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
|
||||
yield* credentials.remove(credentialID)
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
},
|
||||
attempt: {
|
||||
status: Effect.fn("Integration.attempt.status")(function* (attemptID) {
|
||||
|
||||
@@ -3,16 +3,18 @@ export * as IntegrationConnection from "./connection"
|
||||
import { Schema } from "effect"
|
||||
import { Credential } from "../credential"
|
||||
|
||||
export class CredentialInfo extends Schema.Class<CredentialInfo>("Connection.CredentialInfo")({
|
||||
export const CredentialInfo = Schema.Struct({
|
||||
type: Schema.Literal("credential"),
|
||||
id: Credential.ID,
|
||||
label: Schema.String,
|
||||
}) {}
|
||||
}).annotate({ identifier: "Connection.CredentialInfo" })
|
||||
export type CredentialInfo = typeof CredentialInfo.Type
|
||||
|
||||
export class EnvInfo extends Schema.Class<EnvInfo>("Connection.EnvInfo")({
|
||||
export const EnvInfo = Schema.Struct({
|
||||
type: Schema.Literal("env"),
|
||||
name: Schema.String,
|
||||
}) {}
|
||||
}).annotate({ identifier: "Connection.EnvInfo" })
|
||||
export type EnvInfo = typeof EnvInfo.Type
|
||||
|
||||
export const Info = Schema.Union([CredentialInfo, EnvInfo])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
export function args(command: string) {
|
||||
return ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script(command)]
|
||||
}
|
||||
|
||||
const setup = `[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false);
|
||||
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false);
|
||||
$OutputEncoding = [Console]::OutputEncoding;
|
||||
`
|
||||
|
||||
function script(command: string) {
|
||||
const pos = prologue(command)
|
||||
const head = command.slice(0, pos)
|
||||
const body = command.slice(pos)
|
||||
const gap = head && !/[;\r\n]\s*$/.test(head) ? "\n" : ""
|
||||
return `${head}${gap}${setup}${body}`
|
||||
}
|
||||
|
||||
function prologue(command: string) {
|
||||
const pos = scan(command, 0)
|
||||
const attr = attrs(command, pos)
|
||||
const body = command.slice(attr)
|
||||
const match = /^param\s*\(/i.exec(body)
|
||||
if (!match) return pos
|
||||
|
||||
const start = attr + match[0].lastIndexOf("(")
|
||||
const end = block(command, start, "(", ")")
|
||||
if (end === undefined) return pos
|
||||
return end
|
||||
}
|
||||
|
||||
function attrs(command: string, start: number) {
|
||||
let pos = start
|
||||
while (pos < command.length) {
|
||||
const next = scan(command, pos)
|
||||
if (command[next] !== "[") return next
|
||||
const end = block(command, next, "[", "]")
|
||||
if (end === undefined) return start
|
||||
pos = end
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
function scan(command: string, start: number) {
|
||||
let pos = start
|
||||
while (pos < command.length) {
|
||||
const next = trivia(command, pos)
|
||||
if (next !== pos) {
|
||||
pos = next
|
||||
continue
|
||||
}
|
||||
const end = line(command, pos)
|
||||
const value = command.slice(pos, end)
|
||||
if (/^using\s+(?:assembly|module|namespace|type)\b/i.test(value)) {
|
||||
pos = end
|
||||
continue
|
||||
}
|
||||
return pos
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
function trivia(command: string, start: number) {
|
||||
let pos = start
|
||||
while (pos < command.length) {
|
||||
while (/\s/.test(command[pos] ?? "")) pos++
|
||||
if (command[pos] === "#") {
|
||||
pos = line(command, pos)
|
||||
continue
|
||||
}
|
||||
if (command.startsWith("<#", pos)) {
|
||||
const end = command.indexOf("#>", pos + 2)
|
||||
if (end === -1) return command.length
|
||||
pos = end + 2
|
||||
continue
|
||||
}
|
||||
return pos
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
function line(command: string, start: number) {
|
||||
const index = command.indexOf("\n", start)
|
||||
if (index === -1) return command.length
|
||||
return index + 1
|
||||
}
|
||||
|
||||
function block(command: string, start: number, open: string, close: string) {
|
||||
let depth = 0
|
||||
let quote: string | undefined
|
||||
for (let pos = start; pos < command.length; pos++) {
|
||||
const char = command[pos]
|
||||
if (quote) {
|
||||
if (quote === "'" && char === "'" && command[pos + 1] === "'") {
|
||||
pos++
|
||||
continue
|
||||
}
|
||||
if (quote === '"' && char === "`") {
|
||||
pos++
|
||||
continue
|
||||
}
|
||||
if (char === quote) quote = undefined
|
||||
continue
|
||||
}
|
||||
if (char === "'" || char === '"') {
|
||||
quote = char
|
||||
continue
|
||||
}
|
||||
if (command.startsWith("<#", pos)) {
|
||||
const end = command.indexOf("#>", pos + 2)
|
||||
if (end === -1) return
|
||||
pos = end + 1
|
||||
continue
|
||||
}
|
||||
if (char === "#") {
|
||||
pos = line(command, pos) - 1
|
||||
continue
|
||||
}
|
||||
if (char === open) depth++
|
||||
if (char === close) {
|
||||
depth--
|
||||
if (depth === 0) return pos + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const PowerShell = { args }
|
||||
@@ -0,0 +1,61 @@
|
||||
import path from "path"
|
||||
|
||||
type Input = {
|
||||
command?: string
|
||||
args?: string[]
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
type Command = {
|
||||
command: string
|
||||
args: string[]
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
const names = new Set(["kilo", "kilocode"])
|
||||
const self = command()
|
||||
|
||||
function clean(input: string[]) {
|
||||
return input.filter((arg, index) => {
|
||||
if (arg === "--cwd") return false
|
||||
if (input[index - 1] === "--cwd") return false
|
||||
if (arg.startsWith("--cwd=")) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function full(input: string, cwd: string) {
|
||||
if (path.isAbsolute(input)) return input
|
||||
return path.resolve(cwd, input)
|
||||
}
|
||||
|
||||
export function command(
|
||||
proc = { argv: process.argv, execArgv: process.execArgv, execPath: process.execPath, cwd: process.cwd() },
|
||||
): Command {
|
||||
const script = proc.argv[1]
|
||||
const bundled = script?.startsWith("/$bunfs/") || (script ? /^[A-Za-z]:[\\/]~BUN[\\/]/.test(script) : false)
|
||||
if (script && !bundled && /\.(ts|js|mjs|cjs)$/.test(script)) {
|
||||
const file = full(script, proc.cwd)
|
||||
const dir = path.dirname(file)
|
||||
const root = path.basename(dir) === "src" ? path.dirname(dir) : proc.cwd
|
||||
return { command: full(proc.execPath, proc.cwd), args: [...clean(proc.execArgv), file], cwd: root }
|
||||
}
|
||||
return { command: full(proc.execPath, proc.cwd), args: [] }
|
||||
}
|
||||
|
||||
export function resolve(input: Input, cmd = self): Input {
|
||||
if (!input.command || !names.has(input.command)) return input
|
||||
const args = input.args ?? []
|
||||
const project = cmd.cwd && args.length === 0 && input.cwd ? [input.cwd] : []
|
||||
return {
|
||||
...input,
|
||||
command: cmd.command,
|
||||
args: [...cmd.args, ...project, ...args],
|
||||
cwd: cmd.cwd ?? input.cwd,
|
||||
}
|
||||
}
|
||||
|
||||
export const KiloPtySelfCommand = {
|
||||
command,
|
||||
resolve,
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Project } from "./project"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { AbsolutePath, optionalOmitUndefined } from "./schema"
|
||||
import { WorkspaceV2 } from "./workspace"
|
||||
|
||||
export * as Location from "./location"
|
||||
@@ -12,7 +12,7 @@ export class Ref extends Schema.Class<Ref>("Location.Ref")({
|
||||
|
||||
export class Info extends Schema.Class<Info>("Location.Info")({
|
||||
directory: AbsolutePath,
|
||||
workspaceID: WorkspaceV2.ID.pipe(Schema.optional),
|
||||
workspaceID: optionalOmitUndefined(WorkspaceV2.ID),
|
||||
project: Schema.Struct({
|
||||
id: Project.ID,
|
||||
directory: AbsolutePath,
|
||||
|
||||
@@ -21,7 +21,6 @@ import { PluginV2 } from "../plugin"
|
||||
import { AgentPlugin } from "./agent"
|
||||
import { CommandPlugin } from "./command"
|
||||
import { ConfigProviderPlugin } from "../config/plugin/provider"
|
||||
import { EnvPlugin } from "./env"
|
||||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { SkillV2 } from "../skill"
|
||||
@@ -98,7 +97,6 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const boot = Effect.gen(function* () {
|
||||
yield* add(EnvPlugin)
|
||||
yield* add(AgentPlugin.Plugin)
|
||||
yield* add(CommandPlugin.Plugin)
|
||||
// kilocode_change - Kilo's CLI registry supplies `kilo-config`; do not register the redundant opencode skill.
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../plugin"
|
||||
|
||||
export const EnvPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("env"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
const key = item.provider.env.find((env) => process.env[env])
|
||||
if (!key) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.enabled = {
|
||||
via: "env",
|
||||
name: key,
|
||||
}
|
||||
})
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -70,16 +70,11 @@ export const ModelsDevPlugin = PluginV2.define({
|
||||
integrations.update(integrationID, (integration) => (integration.name = item.name))
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: new Integration.KeyMethod({
|
||||
type: "key",
|
||||
}),
|
||||
method: { type: "key" },
|
||||
})
|
||||
integrations.method.update({
|
||||
integrationID,
|
||||
method: new Integration.EnvMethod({
|
||||
type: "env",
|
||||
names: [...item.env],
|
||||
}),
|
||||
method: { type: "env", names: [...item.env] },
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -88,7 +83,6 @@ export const ModelsDevPlugin = PluginV2.define({
|
||||
const providerID = ProviderV2.ID.make(item.id)
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.name = item.name
|
||||
provider.env = [...item.env]
|
||||
provider.api = item.npm
|
||||
? {
|
||||
type: "aisdk",
|
||||
|
||||
@@ -24,7 +24,7 @@ export const CloudflareAIGatewayPlugin = PluginV2.define({
|
||||
apiKey: config.apiKey,
|
||||
options: gatewayOptions(evt.options, metadata),
|
||||
} as any)
|
||||
const unified = createUnified()
|
||||
const unified = createUnified({ apiKey: config.apiKey })
|
||||
evt.sdk = {
|
||||
languageModel(modelID: string) {
|
||||
return gateway(unified(modelID))
|
||||
|
||||
@@ -27,9 +27,9 @@ export const KiloPlugin = PluginV2.define({
|
||||
provider.request.headers["HTTP-Referer"] = "https://kilo.ai/"
|
||||
// kilocode_change start
|
||||
provider.request.headers["X-Title"] = "Kilo Code"
|
||||
options.kilocodeToken = token ?? "anonymous"
|
||||
options.apiKey = token ?? "anonymous"
|
||||
options.kilocodeToken = options.apiKey
|
||||
if (org) options.kilocodeOrganizationId = org
|
||||
if (!provider.enabled) provider.enabled = { via: "custom", data: { anonymous: true } }
|
||||
// kilocode_change end
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import { Effect } from "effect"
|
||||
import { Integration } from "../../integration"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider" // kilocode_change
|
||||
|
||||
export const LLMGatewayPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("llmgateway"),
|
||||
effect: Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.enabled === false) continue
|
||||
if (item.provider.disabled) continue
|
||||
if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue
|
||||
if (item.provider.id !== ProviderV2.ID.make("llmgateway")) continue // kilocode_change
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.request.headers["HTTP-Referer"] = "https://kilo.ai/" // kilocode_change
|
||||
provider.request.headers["HTTP-Referer"] = "https://kilo.ai/"
|
||||
// kilocode_change start
|
||||
provider.request.headers["X-Title"] = "Kilo Code"
|
||||
provider.request.headers["X-Source"] = "kilo"
|
||||
|
||||
@@ -32,11 +32,11 @@ const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
|
||||
|
||||
export const browser = {
|
||||
integrationID: Integration.ID.make("openai"),
|
||||
method: new Integration.OAuthMethod({
|
||||
method: {
|
||||
id: browserMethodID,
|
||||
type: "oauth",
|
||||
label: "ChatGPT Pro/Plus (browser)",
|
||||
}),
|
||||
},
|
||||
authorize: () =>
|
||||
Effect.gen(function* () {
|
||||
const pkce = yield* Effect.promise(generatePKCE)
|
||||
@@ -95,11 +95,11 @@ export const browser = {
|
||||
|
||||
export const headless = {
|
||||
integrationID: Integration.ID.make("openai"),
|
||||
method: new Integration.OAuthMethod({
|
||||
method: {
|
||||
id: headlessMethodID,
|
||||
type: "oauth",
|
||||
label: "ChatGPT Pro/Plus (headless)",
|
||||
}),
|
||||
},
|
||||
authorize: () =>
|
||||
Effect.gen(function* () {
|
||||
const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>(
|
||||
@@ -258,6 +258,6 @@ function claim(token: string) {
|
||||
}
|
||||
|
||||
const successPage =
|
||||
"<!doctype html><title>Kilo</title><h1>Authorization successful</h1><p>You can close this window.</p>" // kilocode_change
|
||||
"<!doctype html><title>Kilo</title><h1>Authorization successful</h1><p>You can close this window.</p>"
|
||||
const errorPage = (message: string) =>
|
||||
`<!doctype html><title>Kilo</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>` // kilocode_change
|
||||
`<!doctype html><title>Kilo</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>`
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { Effect } from "effect"
|
||||
import { Integration } from "../../integration"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const OpencodePlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("opencode"),
|
||||
effect: Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
let hasKey = false
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
const item = evt.provider.get(ProviderV2.ID.opencode)
|
||||
if (!item) return
|
||||
const integration = yield* integrations.get(Integration.ID.make(item.provider.id))
|
||||
hasKey = Boolean(
|
||||
process.env.OPENCODE_API_KEY ||
|
||||
item.provider.env.some((env) => process.env[env]) ||
|
||||
item.provider.request.body.apiKey ||
|
||||
(item.provider.enabled && item.provider.enabled.via === "credential"),
|
||||
process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey,
|
||||
)
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (!hasKey) provider.request.body.apiKey = "public"
|
||||
|
||||
@@ -23,7 +23,7 @@ export const Plugin = PluginV2.define({
|
||||
skill: new SkillV2.Info({
|
||||
name: "customize-opencode",
|
||||
description:
|
||||
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.",
|
||||
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.",
|
||||
location: AbsolutePath.make("/builtin/customize-opencode.md"),
|
||||
content: CustomizeOpencodeContent,
|
||||
}),
|
||||
|
||||
@@ -43,6 +43,8 @@ already-loaded config until then.
|
||||
| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) |
|
||||
| Project agents | `.opencode/agent/<name>.md` or `.opencode/agents/<name>.md` |
|
||||
| Global agents | `~/.config/opencode/agent(s)/<name>.md` |
|
||||
| Project commands | `.opencode/command/<name>.md` or `.opencode/commands/<name>.md` |
|
||||
| Global commands | `~/.config/opencode/command(s)/<name>.md` |
|
||||
| Project skills | `.opencode/skill(s)/<name>/SKILL.md` |
|
||||
| Global skills | `~/.config/opencode/skill(s)/<name>/SKILL.md` |
|
||||
| External skills (auto-loaded) | `~/.claude/skills/<name>/SKILL.md`, `~/.agents/skills/<name>/SKILL.md` |
|
||||
@@ -96,7 +98,7 @@ Every field is optional.
|
||||
},
|
||||
|
||||
"command": {
|
||||
"deploy": { "description": "...", "prompt": "..." }
|
||||
"deploy": { "description": "...", "template": "..." }
|
||||
},
|
||||
|
||||
"provider": {
|
||||
@@ -151,6 +153,7 @@ Shape notes worth being explicit about:
|
||||
- `skills` is an object with `paths` and/or `urls`, not an array.
|
||||
- `references` is an object keyed by alias. Each value is a local path, Git repository, or string shorthand.
|
||||
- `agent` is an object keyed by agent name, not an array.
|
||||
- `command` is an object keyed by command name, not an array.
|
||||
- `plugin` is an array of strings or `[name, options]` tuples, not an object.
|
||||
- `mcp[name].command` is an array of strings, never a single string. `type` is required.
|
||||
- `permission` is either a string action or an object keyed by tool name.
|
||||
@@ -277,6 +280,31 @@ opencode ships with `build`, `plan`, `general`, `explore`. Hidden internal agent
|
||||
`compaction`, `title`, `summary`. To override a built-in's fields, define the
|
||||
same key in `agent: { <name>: { ... } }`.
|
||||
|
||||
## Commands
|
||||
|
||||
opencode's command loader scans for `**/*.md` inside command directories. The
|
||||
file is named after the command, and lives directly inside the `command` folder:
|
||||
|
||||
```
|
||||
.opencode/command/deploy.md
|
||||
```
|
||||
|
||||
Frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: One sentence describing what the command does.
|
||||
agent: build
|
||||
model: anthropic/claude-sonnet-4-6
|
||||
---
|
||||
|
||||
(command body in markdown: the prompt opencode runs, with $ARGUMENTS for the user's input)
|
||||
```
|
||||
|
||||
- `template` is the command body — everything below the frontmatter — and is required: it is the prompt opencode runs when the command is invoked. Do not also put a `template:` key in the frontmatter.
|
||||
- `$ARGUMENTS` is replaced with everything the user typed after the command; `$1`, `$2`, … pull individual positional arguments.
|
||||
- Optional: `description`, `agent`, `model`, `variant`, `subtask`.
|
||||
|
||||
## Plugins
|
||||
|
||||
`plugin:` is an array. Each entry is one of:
|
||||
@@ -300,7 +328,7 @@ function, not a plain object literal, and the function returns an object
|
||||
(return `{}` if there is nothing to register).
|
||||
|
||||
```ts
|
||||
import type { Plugin } from "@opencode-ai/plugin"
|
||||
import type { Plugin } from "@kilocode/plugin"
|
||||
|
||||
export default (async ({ client, project, directory, $ }) => {
|
||||
return {
|
||||
@@ -397,16 +425,16 @@ the `plan` agent's permission ruleset (`edit: deny *`).
|
||||
|
||||
When a user's config is broken and opencode won't start, these env vars help:
|
||||
|
||||
- `OPENCODE_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json`
|
||||
- `KILO_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json`
|
||||
and start from globals only. Run from the project directory, opencode loads,
|
||||
the user edits the broken file, then they restart without the flag.
|
||||
- `OPENCODE_CONFIG=/path/to/file.json`: load an additional explicit config.
|
||||
- `OPENCODE_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`:
|
||||
- `KILO_CONFIG=/path/to/file.json`: load an additional explicit config.
|
||||
- `KILO_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`:
|
||||
inject inline JSON as a final local-scope merge.
|
||||
- `OPENCODE_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins.
|
||||
- `OPENCODE_PURE=1`: skip external plugins entirely.
|
||||
- `OPENCODE_DISABLE_EXTERNAL_SKILLS=1`,
|
||||
`OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under
|
||||
- `KILO_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins.
|
||||
- `KILO_PURE=1`: skip external plugins entirely.
|
||||
- `KILO_DISABLE_EXTERNAL_SKILLS=1`,
|
||||
`KILO_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under
|
||||
`~/.claude/` and `~/.agents/`.
|
||||
|
||||
## When proposing edits
|
||||
@@ -415,8 +443,8 @@ When a user's config is broken and opencode won't start, these env vars help:
|
||||
exact shape, or the field is not covered in this skill, fetch
|
||||
`https://opencode.ai/config.json` and read the schema rather than guessing.
|
||||
- Preserve `$schema` and any existing fields the user did not ask to change.
|
||||
- For agent, skill, and plugin definitions, prefer creating new files in the
|
||||
correct location over inlining everything in `opencode.json`.
|
||||
- For agent, command, skill, and plugin definitions, prefer creating new files
|
||||
in the correct location over inlining everything in `opencode.json`.
|
||||
- If the user's existing config is malformed, point them at the env-var escape
|
||||
hatches above so they can edit from inside opencode without breaking their
|
||||
session.
|
||||
|
||||
@@ -245,6 +245,7 @@ export const layer = Layer.effect(
|
||||
(sourceDirectory) =>
|
||||
Effect.forEach(strategies(), (strategy) =>
|
||||
strategy.list(sourceDirectory).pipe(
|
||||
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed([])),
|
||||
Effect.map((items) =>
|
||||
items.map((item) => ({
|
||||
directory: item.directory,
|
||||
|
||||
@@ -2,7 +2,6 @@ export * as ProviderV2 from "./provider"
|
||||
|
||||
import { withStatics } from "./schema"
|
||||
import { Schema } from "effect"
|
||||
import { Credential } from "./credential"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
Schema.brand("ProviderV2.ID"),
|
||||
@@ -49,22 +48,7 @@ export type Request = typeof Request.Type
|
||||
export class Info extends Schema.Class<Info>("ProviderV2.Info")({
|
||||
id: ID,
|
||||
name: Schema.String,
|
||||
enabled: Schema.Union([
|
||||
Schema.Literal(false),
|
||||
Schema.Struct({
|
||||
via: Schema.Literal("env"),
|
||||
name: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
via: Schema.Literal("credential"),
|
||||
credentialID: Credential.ID,
|
||||
}),
|
||||
Schema.Struct({
|
||||
via: Schema.Literal("custom"),
|
||||
data: Schema.Record(Schema.String, Schema.Any),
|
||||
}),
|
||||
]),
|
||||
env: Schema.String.pipe(Schema.Array),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
api: Api,
|
||||
request: Request,
|
||||
}) {
|
||||
@@ -72,8 +56,6 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
|
||||
return new Info({
|
||||
id: providerID,
|
||||
name: providerID,
|
||||
enabled: false,
|
||||
env: [],
|
||||
api: {
|
||||
type: "native",
|
||||
settings: {},
|
||||
|
||||
+156
-107
@@ -2,23 +2,29 @@ export * as Pty from "./pty"
|
||||
|
||||
import type { Disp, Proc } from "#pty"
|
||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { Config } from "./config"
|
||||
import { EventV2 } from "./event"
|
||||
import { Location } from "./location"
|
||||
import { NonNegativeInt, PositiveInt } from "./schema"
|
||||
import { PtyID } from "./pty/schema"
|
||||
import { SessionSchema } from "./session/schema" // kilocode_change
|
||||
import { Shell } from "./shell"
|
||||
import { lazy } from "./util/lazy"
|
||||
import { KiloPtySelfCommand } from "./kilocode/pty-self-command" // kilocode_change
|
||||
|
||||
const BUFFER_LIMIT = 1024 * 1024 * 2
|
||||
const BUFFER_CHUNK = 64 * 1024
|
||||
const encoder = new TextEncoder()
|
||||
// Exited sessions stay observable (status, exit code, retained output) until removed explicitly.
|
||||
// Cap retention so abandoned terminals do not accumulate unbounded buffers.
|
||||
const EXITED_LIMIT = 25
|
||||
const pty = lazy(() => import("#pty"))
|
||||
|
||||
type Socket = {
|
||||
readyState: number
|
||||
data?: unknown
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => void
|
||||
close: (code?: number, reason?: string) => void
|
||||
type Subscriber = {
|
||||
readonly onData: (chunk: string) => void
|
||||
readonly onEnd: (event: { exitCode?: number }) => void
|
||||
active: boolean
|
||||
detached: boolean
|
||||
pending: string[]
|
||||
end?: { exitCode?: number }
|
||||
}
|
||||
|
||||
type Active = {
|
||||
@@ -27,22 +33,10 @@ type Active = {
|
||||
buffer: string
|
||||
bufferCursor: number
|
||||
cursor: number
|
||||
subscribers: Map<unknown, Socket>
|
||||
subscribers: Map<object, Subscriber>
|
||||
listeners: Disp[]
|
||||
}
|
||||
|
||||
const sock = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
|
||||
|
||||
// WebSocket control frame: 0x00 + UTF-8 JSON.
|
||||
const meta = (cursor: number) => {
|
||||
const json = JSON.stringify({ cursor })
|
||||
const bytes = encoder.encode(json)
|
||||
const out = new Uint8Array(bytes.length + 1)
|
||||
out[0] = 0
|
||||
out.set(bytes, 1)
|
||||
return out
|
||||
}
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
id: PtyID,
|
||||
title: Schema.String,
|
||||
@@ -52,6 +46,8 @@ export const Info = Schema.Struct({
|
||||
status: Schema.Literals(["running", "exited"]),
|
||||
// Windows ConPTY assigns the child pid asynchronously, so 0 is valid at spawn time.
|
||||
pid: NonNegativeInt,
|
||||
// Present once status is "exited".
|
||||
exitCode: Schema.optional(NonNegativeInt),
|
||||
sessionID: Schema.optional(Schema.NullOr(SessionSchema.ID)), // kilocode_change
|
||||
}).annotate({ identifier: "Pty" })
|
||||
|
||||
@@ -67,14 +63,6 @@ export const CreateInput = Schema.Struct({
|
||||
|
||||
export type CreateInput = Types.DeepMutable<typeof CreateInput.Type>
|
||||
|
||||
export type PreparedCreate = {
|
||||
readonly command: string
|
||||
readonly args: string[]
|
||||
readonly cwd: string
|
||||
readonly title?: string
|
||||
readonly env: Record<string, string>
|
||||
}
|
||||
|
||||
export const UpdateInput = Schema.Struct({
|
||||
title: Schema.optional(Schema.String),
|
||||
sessionID: Schema.optional(Schema.NullOr(SessionSchema.ID)), // kilocode_change
|
||||
@@ -88,10 +76,34 @@ export const UpdateInput = Schema.Struct({
|
||||
|
||||
export type UpdateInput = Types.DeepMutable<typeof UpdateInput.Type>
|
||||
|
||||
export type AttachInput = {
|
||||
// Absolute output cursor to replay from. -1 tails from the current end; omitted replays the full retained buffer.
|
||||
readonly cursor?: number
|
||||
// Callbacks fire synchronously from the native PTY data path; keep them non-blocking.
|
||||
readonly onData: (chunk: string) => void
|
||||
// Fired once when the session stops producing output: process exit (exitCode set), removal, or service teardown.
|
||||
readonly onEnd: (event: { exitCode?: number }) => void
|
||||
}
|
||||
|
||||
export type Attachment = {
|
||||
// Retained output from the requested cursor to the current end.
|
||||
readonly replay: string
|
||||
// Absolute output cursor after replay.
|
||||
readonly cursor: number
|
||||
readonly write: (data: string) => void
|
||||
// Starts live delivery after the caller has applied replay and cursor metadata.
|
||||
readonly activate: () => void
|
||||
readonly detach: () => void
|
||||
}
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Pty.NotFoundError", {
|
||||
ptyID: PtyID,
|
||||
}) {}
|
||||
|
||||
export class ExitedError extends Schema.TaggedErrorClass<ExitedError>()("Pty.ExitedError", {
|
||||
ptyID: PtyID,
|
||||
}) {}
|
||||
|
||||
export const Event = {
|
||||
Created: EventV2.define({ type: "pty.created", schema: { info: Info } }),
|
||||
Updated: EventV2.define({ type: "pty.updated", schema: { info: Info } }),
|
||||
@@ -102,19 +114,11 @@ export const Event = {
|
||||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly get: (id: PtyID) => Effect.Effect<Info, NotFoundError>
|
||||
readonly create: (input: PreparedCreate) => Effect.Effect<Info>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<Info>
|
||||
readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info, NotFoundError>
|
||||
readonly remove: (id: PtyID) => Effect.Effect<void, NotFoundError>
|
||||
readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect<void, NotFoundError>
|
||||
readonly write: (id: PtyID, data: string) => Effect.Effect<void, NotFoundError>
|
||||
readonly connect: (
|
||||
id: PtyID,
|
||||
ws: Socket,
|
||||
cursor?: number,
|
||||
) => Effect.Effect<
|
||||
{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined,
|
||||
NotFoundError
|
||||
>
|
||||
readonly attach: (id: PtyID, input: AttachInput) => Effect.Effect<Attachment, NotFoundError | ExitedError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Pty") {}
|
||||
@@ -124,28 +128,41 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const location = yield* Location.Service
|
||||
const config = yield* Config.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const sessions = new Map<PtyID, Active>()
|
||||
const exitOrder: PtyID[] = []
|
||||
|
||||
function notifyEnd(session: Active, event: { exitCode?: number }) {
|
||||
for (const subscriber of session.subscribers.values()) {
|
||||
if (!subscriber.active) {
|
||||
subscriber.end = event
|
||||
continue
|
||||
}
|
||||
try {
|
||||
subscriber.onEnd(event)
|
||||
} catch {}
|
||||
}
|
||||
session.subscribers.clear()
|
||||
}
|
||||
|
||||
function teardown(session: Active) {
|
||||
for (const listener of session.listeners) listener.dispose()
|
||||
session.listeners.length = 0
|
||||
try {
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
for (const [sub, ws] of session.subscribers.entries()) {
|
||||
if (session.info.status === "running") {
|
||||
try {
|
||||
if (sock(ws) === sub) ws.close()
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
}
|
||||
session.subscribers.clear()
|
||||
notifyEnd(session, {})
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
for (const session of sessions.values()) teardown(session)
|
||||
sessions.clear()
|
||||
exitOrder.length = 0
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -157,12 +174,13 @@ export const layer = Layer.effect(
|
||||
|
||||
const removeSession = Effect.fnUntraced(function* (id: PtyID) {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return false
|
||||
if (!session) return
|
||||
sessions.delete(id)
|
||||
const index = exitOrder.indexOf(id)
|
||||
if (index !== -1) exitOrder.splice(index, 1)
|
||||
yield* Effect.logInfo("removing session", { id })
|
||||
teardown(session)
|
||||
yield* events.publish(Event.Deleted, { id: session.info.id })
|
||||
return true
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
|
||||
@@ -178,29 +196,48 @@ export const layer = Layer.effect(
|
||||
return (yield* requireSession(id)).info
|
||||
})
|
||||
|
||||
const create = Effect.fn("Pty.create")(function* (input: PreparedCreate) {
|
||||
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
||||
const id = PtyID.ascending()
|
||||
yield* Effect.logInfo("creating session", { id, cmd: input.command, args: input.args, cwd: input.cwd })
|
||||
// kilocode_change start - resolve Kilo self-commands to the real binary, arguments, and project cwd
|
||||
const resolved = KiloPtySelfCommand.resolve({
|
||||
command: input.command,
|
||||
args: input.args ? [...input.args] : undefined,
|
||||
cwd: input.cwd,
|
||||
})
|
||||
const command = resolved.command || Shell.preferred(Config.latest(yield* config.entries(), "shell"))
|
||||
const base = resolved.args ?? []
|
||||
const args = Shell.login(command) ? [...base, "-l"] : [...base]
|
||||
const cwd = resolved.cwd || location.directory
|
||||
// kilocode_change end
|
||||
const env = {
|
||||
...process.env,
|
||||
...input.env,
|
||||
TERM: "xterm-256color",
|
||||
KILO_TERMINAL: "1",
|
||||
KILO_PTY_ID: id, // kilocode_change - let nested Kilo processes identify their parent terminal
|
||||
} as Record<string, string>
|
||||
// kilocode_change start - do not expose local server credentials to user terminals.
|
||||
// node-pty inherits parent values for omitted keys, so empty tombstones are required.
|
||||
env.KILO_SERVER_PASSWORD = ""
|
||||
env.KILO_SERVER_USERNAME = ""
|
||||
// kilocode_change end
|
||||
if (process.platform === "win32") {
|
||||
env.LC_ALL = "C.UTF-8"
|
||||
env.LC_CTYPE = "C.UTF-8"
|
||||
env.LANG = "C.UTF-8"
|
||||
}
|
||||
yield* Effect.logInfo("creating session", { id, cmd: command, args, cwd })
|
||||
const { spawn } = yield* Effect.promise(() => pty())
|
||||
// kilocode_change - expose the pty id to the spawned shell so a nested `kilo tui`/`kilo run` can
|
||||
// detect it is running inside a kilo-spawned terminal (read via process.env.KILO_PTY_ID)
|
||||
const env = { ...input.env, KILO_PTY_ID: id }
|
||||
const proc = yield* Effect.sync(() =>
|
||||
spawn(input.command, input.args, {
|
||||
name: "xterm-256color",
|
||||
cwd: input.cwd,
|
||||
env,
|
||||
}),
|
||||
)
|
||||
const info = {
|
||||
const proc = yield* Effect.sync(() => spawn(command, args, { name: "xterm-256color", cwd, env }))
|
||||
const info: Info = {
|
||||
id,
|
||||
title: input.title || `Terminal ${id.slice(-4)}`,
|
||||
command: input.command,
|
||||
args: input.args,
|
||||
cwd: input.cwd,
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
status: "running",
|
||||
pid: proc.pid,
|
||||
} as const
|
||||
}
|
||||
const session: Active = {
|
||||
info,
|
||||
process: proc,
|
||||
@@ -214,15 +251,15 @@ export const layer = Layer.effect(
|
||||
session.listeners.push(
|
||||
proc.onData((chunk) => {
|
||||
session.cursor += chunk.length
|
||||
for (const [key, ws] of session.subscribers.entries()) {
|
||||
if (ws.readyState !== 1 || sock(ws) !== key) {
|
||||
session.subscribers.delete(key)
|
||||
for (const [token, subscriber] of session.subscribers.entries()) {
|
||||
if (!subscriber.active) {
|
||||
subscriber.pending.push(chunk)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
ws.send(chunk)
|
||||
subscriber.onData(chunk)
|
||||
} catch {
|
||||
session.subscribers.delete(key)
|
||||
session.subscribers.delete(token)
|
||||
}
|
||||
}
|
||||
session.buffer += chunk
|
||||
@@ -233,12 +270,19 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
proc.onExit(({ exitCode }) => {
|
||||
if (session.info.status === "exited") return
|
||||
session.info.status = "exited"
|
||||
session.info.exitCode = exitCode
|
||||
notifyEnd(session, { exitCode })
|
||||
exitOrder.push(id)
|
||||
runFork(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.logInfo("session exited", { id, exitCode })
|
||||
session.info.status = "exited"
|
||||
yield* events.publish(Event.Exited, { id, exitCode })
|
||||
yield* removeSession(id)
|
||||
while (exitOrder.length > EXITED_LIMIT) {
|
||||
const oldest = exitOrder[0]
|
||||
if (!oldest) break
|
||||
yield* removeSession(oldest)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
@@ -253,66 +297,71 @@ export const layer = Layer.effect(
|
||||
// kilocode_change start - associate nested Kilo TUI terminals with the viewed session
|
||||
if ("sessionID" in input) session.info.sessionID = input.sessionID ?? undefined
|
||||
// kilocode_change end
|
||||
if (input.size) session.process.resize(input.size.cols, input.size.rows)
|
||||
if (input.size && session.info.status === "running") session.process.resize(input.size.cols, input.size.rows)
|
||||
yield* events.publish(Event.Updated, { info: session.info })
|
||||
return session.info
|
||||
})
|
||||
|
||||
const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
|
||||
const session = yield* requireSession(id)
|
||||
if (session.info.status === "running") session.process.resize(cols, rows)
|
||||
})
|
||||
|
||||
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
|
||||
const session = yield* requireSession(id)
|
||||
if (session.info.status === "running") session.process.write(data)
|
||||
})
|
||||
|
||||
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
|
||||
const session = yield* requireSession(id).pipe(Effect.tapError(() => Effect.sync(() => ws.close())))
|
||||
yield* Effect.logInfo("client connected to session", { id, directory: location.directory })
|
||||
const sub = sock(ws)
|
||||
session.subscribers.delete(sub)
|
||||
session.subscribers.set(sub, ws)
|
||||
const cleanup = () => session.subscribers.delete(sub)
|
||||
const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) {
|
||||
const session = yield* requireSession(id)
|
||||
if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id })
|
||||
yield* Effect.logInfo("client attached to session", { id, directory: location.directory })
|
||||
const token = {}
|
||||
const subscriber: Subscriber = {
|
||||
onData: input.onData,
|
||||
onEnd: input.onEnd,
|
||||
active: false,
|
||||
detached: false,
|
||||
pending: [],
|
||||
}
|
||||
session.subscribers.set(token, subscriber)
|
||||
const start = session.bufferCursor
|
||||
const end = session.cursor
|
||||
const from =
|
||||
cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0
|
||||
const data = (() => {
|
||||
input.cursor === -1
|
||||
? end
|
||||
: typeof input.cursor === "number" && Number.isSafeInteger(input.cursor)
|
||||
? Math.max(0, input.cursor)
|
||||
: 0
|
||||
const replay = (() => {
|
||||
if (!session.buffer || from >= end) return ""
|
||||
const offset = Math.max(0, from - start)
|
||||
if (offset >= session.buffer.length) return ""
|
||||
return session.buffer.slice(offset)
|
||||
})()
|
||||
if (data) {
|
||||
try {
|
||||
for (let i = 0; i < data.length; i += BUFFER_CHUNK) ws.send(data.slice(i, i + BUFFER_CHUNK))
|
||||
} catch {
|
||||
cleanup()
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
ws.send(meta(end))
|
||||
} catch {
|
||||
cleanup()
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
return {
|
||||
onMessage: (message: string | ArrayBuffer) => {
|
||||
session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message))
|
||||
replay,
|
||||
cursor: end,
|
||||
write: (data: string) => {
|
||||
if (session.info.status === "running") session.process.write(data)
|
||||
},
|
||||
onClose: () => {
|
||||
cleanup()
|
||||
activate: () => {
|
||||
if (subscriber.active || subscriber.detached) return
|
||||
subscriber.active = true
|
||||
try {
|
||||
for (const chunk of subscriber.pending) subscriber.onData(chunk)
|
||||
subscriber.pending.length = 0
|
||||
if (subscriber.end) subscriber.onEnd(subscriber.end)
|
||||
} catch {
|
||||
session.subscribers.delete(token)
|
||||
}
|
||||
},
|
||||
detach: () => {
|
||||
subscriber.detached = true
|
||||
subscriber.pending.length = 0
|
||||
subscriber.end = undefined
|
||||
session.subscribers.delete(token)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ list, get, create, update, remove, resize, write, connect })
|
||||
return Service.of({ list, get, create, update, remove, write, attach })
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
|
||||
const inputDecoder = new TextDecoder("utf-8", { fatal: true })
|
||||
|
||||
export function handlePtyInput(
|
||||
handler: { onMessage: (message: string | ArrayBuffer) => void },
|
||||
message: string | Uint8Array,
|
||||
) {
|
||||
if (typeof message === "string") {
|
||||
handler.onMessage(message)
|
||||
return Effect.void
|
||||
}
|
||||
return Effect.try({
|
||||
try: () => inputDecoder.decode(message),
|
||||
catch: () => new Error("invalid PTY websocket input"),
|
||||
}).pipe(
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
Effect.flatMap((decoded) => {
|
||||
if (decoded === undefined) return Effect.void
|
||||
handler.onMessage(decoded)
|
||||
return Effect.void
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export * as PtyProtocol from "./protocol"
|
||||
|
||||
// Wire protocol for PTY websocket transports. The PTY domain service is transport-free; server
|
||||
// routes adapt Pty.attach to websockets with these helpers so every surface speaks one protocol.
|
||||
//
|
||||
// Outbound frames are raw UTF-8 terminal chunks. One control frame — a 0x00 byte followed by
|
||||
// UTF-8 JSON — carries the absolute output cursor after replay so clients can resume later.
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true })
|
||||
|
||||
// Replay can be megabytes; send it in bounded frames.
|
||||
export const REPLAY_CHUNK = 64 * 1024
|
||||
|
||||
export function metaFrame(cursor: number) {
|
||||
const bytes = encoder.encode(JSON.stringify({ cursor }))
|
||||
const out = new Uint8Array(bytes.length + 1)
|
||||
out[0] = 0
|
||||
out.set(bytes, 1)
|
||||
return out
|
||||
}
|
||||
|
||||
export function chunks(data: string) {
|
||||
const out: string[] = []
|
||||
for (let i = 0; i < data.length; i += REPLAY_CHUNK) out.push(data.slice(i, i + REPLAY_CHUNK))
|
||||
return out
|
||||
}
|
||||
|
||||
// Inbound client frames are UTF-8 text or binary; invalid UTF-8 input is dropped.
|
||||
export function decodeInput(message: string | Uint8Array | ArrayBuffer) {
|
||||
if (typeof message === "string") return message
|
||||
try {
|
||||
return decoder.decode(message instanceof ArrayBuffer ? new Uint8Array(message) : message)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as SessionRunner from "./index"
|
||||
|
||||
import type { LLMError } from "@opencode-ai/llm"
|
||||
import { Context, Effect, Schema } from "effect"
|
||||
import { Context, Effect } from "effect"
|
||||
import { SessionSchema } from "../schema"
|
||||
import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
@@ -9,20 +9,11 @@ import type { SystemContext } from "../../system-context/index"
|
||||
import type { SessionContextEpoch } from "../context-epoch"
|
||||
import type { ToolOutputStore } from "../../tool-output-store"
|
||||
|
||||
export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExceededError>()(
|
||||
"SessionRunner.StepLimitExceededError",
|
||||
{
|
||||
sessionID: SessionSchema.ID,
|
||||
limit: Schema.Int,
|
||||
},
|
||||
) {}
|
||||
|
||||
export type RunError =
|
||||
| LLMError
|
||||
| SessionRunnerModel.Error
|
||||
| MessageDecodeError
|
||||
| ContextSnapshotDecodeError
|
||||
| StepLimitExceededError
|
||||
| SystemContext.InitializationBlocked
|
||||
| SessionContextEpoch.AgentReplacementBlocked
|
||||
| ToolOutputStore.Error
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
LLMClient,
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
Message,
|
||||
SystemPart,
|
||||
isContextOverflowFailure,
|
||||
type ProviderErrorEvent,
|
||||
@@ -29,10 +30,11 @@ import { SessionHistory } from "../history"
|
||||
import { SessionInput } from "../input"
|
||||
import { SessionSchema } from "../schema"
|
||||
import { SessionStore } from "../store"
|
||||
import { type RunError, Service, StepLimitExceededError } from "./index"
|
||||
import { type RunError, Service } from "./index"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
import { createLLMEventPublisher } from "./publish-llm-event"
|
||||
import { toLLMMessages } from "./to-llm-message"
|
||||
import { MAX_STEPS_PROMPT } from "./max-steps"
|
||||
|
||||
/**
|
||||
* Runs one durable coding-agent Session until it settles.
|
||||
@@ -45,7 +47,7 @@ import { toLLMMessages } from "./to-llm-message"
|
||||
* - [ ] Replace local ownership with durable multi-node ownership when clustered.
|
||||
* - [ ] Mark busy, retrying, idle, interrupted, or terminal-failure status durably.
|
||||
* - [ ] Honor interruption and reject stale work after runtime attachment replacement.
|
||||
* - [x] Bound model steps.
|
||||
* - [x] Honor optional agent step limits.
|
||||
* - [ ] Bound provider retries and repeated identical tool calls.
|
||||
*
|
||||
* - Runtime context assembly
|
||||
@@ -80,13 +82,10 @@ import { toLLMMessages } from "./to-llm-message"
|
||||
* Durable activity recovery remains a separate future slice with an explicit retry policy.
|
||||
*
|
||||
* The current slice loads V2 history, translates it, resolves a model through a core service, and persists one
|
||||
* provider turn. Registry definitions are advertised, local tool calls are settled durably, and a
|
||||
* bounded explicit loop starts the next provider turn after local settlement.
|
||||
* provider turn. Registry definitions are advertised, local tool calls are settled durably, and an
|
||||
* explicit loop starts the next provider turn after local settlement. Configured agent step limits bound the loop.
|
||||
*/
|
||||
|
||||
// QUESTION: Did this exist previously, or did we add this limit? Does it make sense?
|
||||
const MAX_STEPS = 25
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -175,6 +174,7 @@ export const layer = Layer.effect(
|
||||
const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotion: SessionInput.Delivery | undefined,
|
||||
step: number,
|
||||
recoverOverflow?: typeof compaction.compactAfterOverflow,
|
||||
) {
|
||||
const session = yield* getSession(sessionID)
|
||||
@@ -214,7 +214,8 @@ export const layer = Layer.effect(
|
||||
const model = yield* models.resolve(session)
|
||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
|
||||
const context = entries.map((entry) => entry.message)
|
||||
const toolMaterialization = yield* tools.materialize(agent.info?.permissions)
|
||||
const isLastStep = agent.info?.steps !== undefined && step >= agent.info.steps
|
||||
const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions)
|
||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
||||
const request = LLM.request({
|
||||
model,
|
||||
@@ -222,8 +223,9 @@ export const layer = Layer.effect(
|
||||
system: [agent.info?.system, system.baseline]
|
||||
.filter((part): part is string => part !== undefined && part.length > 0)
|
||||
.map(SystemPart.make),
|
||||
messages: toLLMMessages(context, model),
|
||||
tools: toolMaterialization.definitions,
|
||||
messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
|
||||
tools: toolMaterialization?.definitions ?? [],
|
||||
toolChoice: isLastStep ? "none" : undefined,
|
||||
})
|
||||
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
|
||||
return yield* Effect.die(rebuildPreparedTurn())
|
||||
@@ -254,6 +256,10 @@ export const layer = Layer.effect(
|
||||
}
|
||||
yield* publish(event)
|
||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||
if (!toolMaterialization) {
|
||||
yield* withPublication(publisher.failUnsettledTools("Tools are disabled after the maximum agent steps"))
|
||||
return
|
||||
}
|
||||
needsContinuation = true
|
||||
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
|
||||
yield* Effect.uninterruptibleMask((restore) =>
|
||||
@@ -340,31 +346,32 @@ export const layer = Layer.effect(
|
||||
type RunTurn = (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotion: SessionInput.Delivery | undefined,
|
||||
step: number,
|
||||
) => Effect.Effect<boolean, RunError>
|
||||
|
||||
const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion) {
|
||||
return yield* runTurnAttempt(sessionID, promotion).pipe(
|
||||
const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) {
|
||||
return yield* runTurnAttempt(sessionID, promotion, step).pipe(
|
||||
Effect.catchDefect(
|
||||
Effect.fnUntraced(function* (defect) {
|
||||
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
|
||||
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
|
||||
return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow")
|
||||
yield* Effect.yieldNow
|
||||
return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion)
|
||||
return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion, step)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion) {
|
||||
return yield* runTurnAttempt(sessionID, promotion, compaction.compactAfterOverflow).pipe(
|
||||
const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) {
|
||||
return yield* runTurnAttempt(sessionID, promotion, step, compaction.compactAfterOverflow).pipe(
|
||||
Effect.catchDefect(
|
||||
Effect.fnUntraced(function* (defect) {
|
||||
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
|
||||
yield* Effect.yieldNow
|
||||
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
|
||||
return yield* runAfterOverflowCompaction(sessionID, undefined)
|
||||
return yield* runTurn(sessionID, defect.transition.promotion)
|
||||
return yield* runAfterOverflowCompaction(sessionID, undefined, step)
|
||||
return yield* runTurn(sessionID, defect.transition.promotion, step)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -382,14 +389,11 @@ export const layer = Layer.effect(
|
||||
let openActivity = input.force === true || hasSteer || hasQueue
|
||||
while (openActivity) {
|
||||
let needsContinuation = true
|
||||
for (let step = 0; step < MAX_STEPS; step++) {
|
||||
needsContinuation = yield* runTurn(input.sessionID, promotion)
|
||||
for (let step = 1; needsContinuation; step++) {
|
||||
needsContinuation = yield* runTurn(input.sessionID, promotion, step)
|
||||
promotion = "steer"
|
||||
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
||||
if (!needsContinuation) break
|
||||
}
|
||||
if (needsContinuation)
|
||||
return yield* new StepLimitExceededError({ sessionID: input.sessionID, limit: MAX_STEPS })
|
||||
openActivity = yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
||||
promotion = openActivity ? "queue" : undefined
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
CRITICAL - MAXIMUM STEPS REACHED
|
||||
export const MAX_STEPS_PROMPT = `CRITICAL - MAXIMUM STEPS REACHED
|
||||
|
||||
The maximum number of steps allowed for this task has been reached. Tools are disabled until next user input. Respond with text only.
|
||||
|
||||
@@ -13,4 +13,4 @@ Response must include:
|
||||
- List of any remaining tasks that were not completed
|
||||
- Recommendations for what should be done next
|
||||
|
||||
Any attempt to use tools is a critical violation. Respond with text ONLY.
|
||||
Any attempt to use tools is a critical violation. Respond with text ONLY.`
|
||||
@@ -8,6 +8,9 @@ import { Auth, type AnyRoute } from "@opencode-ai/llm/route"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { produce } from "immer"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { Credential } from "../../credential"
|
||||
import { Integration } from "../../integration"
|
||||
import { IntegrationConnection } from "../../integration/connection"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ModelRequest } from "../../model-request"
|
||||
import { PluginBoot } from "../../plugin/boot"
|
||||
@@ -45,10 +48,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
/** Test or embedding seam for supplying a model resolver directly. */
|
||||
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
|
||||
|
||||
const apiKey = (model: ModelV2.Info, provider?: ProviderV2.Info) => {
|
||||
const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Stored) => {
|
||||
if (credential?.value.type === "key") return Auth.value(credential.value.key)
|
||||
if (credential?.value.type === "oauth") return Auth.value(credential.value.access)
|
||||
const value = model.request.body.apiKey ?? model.api.settings?.apiKey
|
||||
if (typeof value === "string") return Auth.value(value)
|
||||
return provider?.enabled !== false && provider?.enabled.via === "env" ? Auth.config(provider.enabled.name) : undefined
|
||||
return connection?.type === "env" ? Auth.config(connection.name) : undefined
|
||||
}
|
||||
|
||||
const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
|
||||
@@ -83,41 +88,54 @@ const apiName = (model: ModelV2.Info) =>
|
||||
|
||||
export const fromCatalogModel = (
|
||||
model: ModelV2.Info,
|
||||
provider?: ProviderV2.Info,
|
||||
connection?: IntegrationConnection.Info,
|
||||
credential?: Credential.Stored,
|
||||
): Effect.Effect<Model, UnsupportedApiError> => {
|
||||
const key = apiKey(model, provider)
|
||||
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai") {
|
||||
const resolved =
|
||||
credential?.value.metadata === undefined
|
||||
? model
|
||||
: produce(model, (draft) => {
|
||||
Object.assign(draft.request.body, credential.value.metadata)
|
||||
// kilocode_change start - Kilo Gateway consumes the migrated OAuth account as its organization route
|
||||
if (credential.value.type === "oauth" && credential.value.metadata?.accountID) {
|
||||
draft.request.body.kilocodeOrganizationId = credential.value.metadata.accountID
|
||||
delete draft.request.body.accountID
|
||||
}
|
||||
// kilocode_change end
|
||||
})
|
||||
const key = apiKey(resolved, connection, credential)
|
||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") {
|
||||
return Effect.succeed(
|
||||
withDefaults(model, OpenAIResponses.route)
|
||||
withDefaults(resolved, OpenAIResponses.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: model.api.id }),
|
||||
.model({ id: resolved.api.id }),
|
||||
)
|
||||
}
|
||||
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/anthropic") {
|
||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/anthropic") {
|
||||
return Effect.succeed(
|
||||
withDefaults(model, AnthropicMessages.route)
|
||||
withDefaults(resolved, AnthropicMessages.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
|
||||
.model({ id: model.api.id }),
|
||||
.model({ id: resolved.api.id }),
|
||||
)
|
||||
}
|
||||
if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai-compatible" && model.api.url) {
|
||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai-compatible" && resolved.api.url) {
|
||||
return Effect.succeed(
|
||||
withDefaults(model, OpenAICompatibleChat.route)
|
||||
withDefaults(resolved, OpenAICompatibleChat.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: model.api.id }),
|
||||
.model({ id: resolved.api.id }),
|
||||
)
|
||||
}
|
||||
return Effect.fail(
|
||||
new UnsupportedApiError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
api: apiName(model),
|
||||
providerID: resolved.providerID,
|
||||
modelID: resolved.id,
|
||||
api: apiName(resolved),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, provider?: ProviderV2.Info) =>
|
||||
fromCatalogModel(withVariant(model, session.model?.variant), provider)
|
||||
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) =>
|
||||
fromCatalogModel(withVariant(model, session.model?.variant))
|
||||
|
||||
export const supported = (model: ModelV2.Info) =>
|
||||
model.api.type === "aisdk" &&
|
||||
@@ -130,6 +148,8 @@ export const locationLayer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const boot = yield* PluginBoot.Service
|
||||
return Service.of({
|
||||
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
|
||||
@@ -140,7 +160,12 @@ export const locationLayer = Layer.effect(
|
||||
: (Option.getOrUndefined((yield* catalog.model.default()).pipe(Option.filter(supported))) ??
|
||||
(yield* catalog.model.available()).find(supported))
|
||||
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
|
||||
return yield* resolve(session, selected, yield* catalog.provider.get(selected.providerID))
|
||||
const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID))
|
||||
return yield* fromCatalogModel(
|
||||
withVariant(selected, session.model?.variant),
|
||||
connection,
|
||||
connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined,
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import * as PowerShell from "@/kilocode/shell/shell" // kilocode_change - PowerShell args
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { which } from "@opencode-ai/core/util/which"
|
||||
export * as Shell from "./shell"
|
||||
|
||||
import path from "path"
|
||||
import { spawn, type ChildProcess } from "child_process"
|
||||
import { readFile } from "fs/promises"
|
||||
import { statSync } from "fs"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { Flag } from "./flag/flag"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { which } from "./util/which"
|
||||
import { PowerShell } from "./kilocode/powershell" // kilocode_change
|
||||
|
||||
const SIGKILL_TIMEOUT_MS = 200
|
||||
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
|
||||
@@ -48,7 +51,7 @@ export async function killTree(proc: ChildProcess, opts?: { exited?: () => boole
|
||||
if (!opts?.exited?.()) {
|
||||
process.kill(-pid, "SIGKILL")
|
||||
}
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
proc.kill("SIGTERM")
|
||||
await sleep(SIGKILL_TIMEOUT_MS)
|
||||
if (!opts?.exited?.()) {
|
||||
@@ -57,9 +60,13 @@ export async function killTree(proc: ChildProcess, opts?: { exited?: () => boole
|
||||
}
|
||||
}
|
||||
|
||||
function stat(file: string) {
|
||||
return statSync(file, { throwIfNoEntry: false }) ?? undefined
|
||||
}
|
||||
|
||||
function full(file: string) {
|
||||
if (process.platform !== "win32") return file
|
||||
const shell = Filesystem.windowsPath(file)
|
||||
const shell = FSUtil.windowsPath(file)
|
||||
if (path.win32.dirname(shell) !== ".") {
|
||||
if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell
|
||||
return shell
|
||||
@@ -77,13 +84,13 @@ function ok(file: string) {
|
||||
}
|
||||
|
||||
function rooted(file: string) {
|
||||
return path.isAbsolute(Filesystem.windowsPath(file))
|
||||
return path.isAbsolute(FSUtil.windowsPath(file))
|
||||
}
|
||||
|
||||
function resolve(file: string) {
|
||||
const shell = full(file)
|
||||
if (rooted(shell)) {
|
||||
if (Filesystem.stat(shell)?.isFile()) return shell
|
||||
if (stat(shell)?.isFile()) return shell
|
||||
return
|
||||
}
|
||||
return which(shell) ?? undefined
|
||||
@@ -100,7 +107,7 @@ function win() {
|
||||
}
|
||||
|
||||
async function unix() {
|
||||
const text = await Filesystem.readText("/etc/shells").catch(() => "")
|
||||
const text = await readFile("/etc/shells", "utf8").catch(() => "")
|
||||
if (text) return Array.from(new Set(text.split("\n").filter((line) => line.trim() && !line.startsWith("#"))))
|
||||
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
|
||||
}
|
||||
@@ -110,7 +117,7 @@ function select(file: string | undefined, opts?: { acceptable?: boolean }) {
|
||||
const shell = resolve(file)
|
||||
if (shell) return shell
|
||||
}
|
||||
if (process.platform === "win32") return win()[0]!
|
||||
if (process.platform === "win32") return win()[0]
|
||||
return fallback()
|
||||
}
|
||||
|
||||
@@ -120,7 +127,7 @@ export function gitbash() {
|
||||
const git = which("git")
|
||||
if (!git) return
|
||||
const file = path.join(git, "..", "..", "bin", "bash.exe")
|
||||
if (Filesystem.stat(file)?.size) return file
|
||||
if (stat(file)?.size) return file
|
||||
}
|
||||
|
||||
function fallback() {
|
||||
@@ -131,7 +138,7 @@ function fallback() {
|
||||
}
|
||||
|
||||
export function name(file: string) {
|
||||
if (process.platform === "win32") return path.win32.parse(Filesystem.windowsPath(file)).name.toLowerCase()
|
||||
if (process.platform === "win32") return path.win32.parse(FSUtil.windowsPath(file)).name.toLowerCase()
|
||||
return path.basename(file).toLowerCase()
|
||||
}
|
||||
|
||||
@@ -189,28 +196,32 @@ export function args(file: string, command: string, cwd: string) {
|
||||
]
|
||||
}
|
||||
if (n === "cmd") return ["/c", command]
|
||||
if (ps(file)) return PowerShell.args(command) // kilocode_change - PowerShell args
|
||||
if (ps(file)) return PowerShell.args(command) // kilocode_change - preserve UTF-8 and script prologues
|
||||
return ["-c", command]
|
||||
}
|
||||
|
||||
const defaultPreferred = lazy(() => select(process.env.SHELL))
|
||||
const defaultAcceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))
|
||||
let defaultPreferred: string | undefined
|
||||
let defaultAcceptable: string | undefined
|
||||
|
||||
export function preferred(configShell?: string) {
|
||||
if (configShell) return select(configShell)
|
||||
return defaultPreferred()
|
||||
defaultPreferred ??= select(process.env.SHELL)
|
||||
return defaultPreferred
|
||||
}
|
||||
preferred.reset = () => {
|
||||
defaultPreferred = undefined
|
||||
}
|
||||
preferred.reset = () => defaultPreferred.reset()
|
||||
|
||||
export function acceptable(configShell?: string) {
|
||||
if (configShell) return select(configShell, { acceptable: true })
|
||||
return defaultAcceptable()
|
||||
defaultAcceptable ??= select(process.env.SHELL, { acceptable: true })
|
||||
return defaultAcceptable
|
||||
}
|
||||
acceptable.reset = () => {
|
||||
defaultAcceptable = undefined
|
||||
}
|
||||
acceptable.reset = () => defaultAcceptable.reset()
|
||||
|
||||
export async function list(): Promise<Item[]> {
|
||||
const shells = process.platform === "win32" ? win() : await unix()
|
||||
return shells.filter((s) => resolve(s)).map(info)
|
||||
}
|
||||
|
||||
export * as Shell from "./shell"
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect, Layer, Option } from "effect"
|
||||
import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
@@ -25,47 +25,29 @@ const it = testEffect(
|
||||
Layer.provideMerge(
|
||||
Layer.mock(Credential.Service)({
|
||||
all: () => Effect.succeed([]),
|
||||
list: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
describe("CatalogV2", () => {
|
||||
it.effect("projects Kilo organization routing from OAuth credentials", () => {
|
||||
const integrationID = Integration.ID.make("kilocode")
|
||||
const credential = new Credential.Stored({
|
||||
id: Credential.ID.create(),
|
||||
integrationID,
|
||||
label: "Organization",
|
||||
value: new Credential.OAuth({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("oauth"),
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
expires: 1,
|
||||
metadata: { accountID: "organization" },
|
||||
}),
|
||||
})
|
||||
const layer = Catalog.locationLayer.pipe(
|
||||
Layer.fresh,
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(locationLayer),
|
||||
Layer.provideMerge(
|
||||
Layer.mock(Credential.Service)({ all: () => Effect.succeed([credential]) }),
|
||||
),
|
||||
)
|
||||
|
||||
return Effect.gen(function* () {
|
||||
it.effect("publishes an updated event after catalog changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("kilocode"), () => {}))
|
||||
expect(yield* catalog.provider.get(ProviderV2.ID.make("kilocode"))).toMatchObject({
|
||||
request: { body: { apiKey: "access", kilocodeOrganizationId: "organization" } },
|
||||
})
|
||||
}).pipe(Effect.provide(layer))
|
||||
})
|
||||
const events = yield* EventV2.Service
|
||||
const updated = yield* events
|
||||
.subscribe(Catalog.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
it.effect("projects active credentials without rebuilding catalog state", () => {
|
||||
yield* (yield* catalog.transform())((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
||||
|
||||
expect((yield* Fiber.join(updated)).length).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("derives availability from active credentials without changing provider state", () => {
|
||||
const integrationID = Integration.ID.make("test")
|
||||
const first = {
|
||||
id: Credential.ID.create(),
|
||||
@@ -87,6 +69,7 @@ describe("CatalogV2", () => {
|
||||
Layer.provideMerge(
|
||||
Layer.mock(Credential.Service)({
|
||||
all: () => Effect.sync(() => [active]),
|
||||
list: () => Effect.sync(() => [active]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -96,18 +79,44 @@ describe("CatalogV2", () => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
||||
|
||||
expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({
|
||||
enabled: { via: "credential", credentialID: first.id },
|
||||
request: { body: { apiKey: "first", tenant: "one" } },
|
||||
})
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
|
||||
active = second
|
||||
expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({
|
||||
enabled: { via: "credential", credentialID: second.id },
|
||||
request: { body: { apiKey: "second", tenant: "two" } },
|
||||
})
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
|
||||
}).pipe(Effect.provide(layer))
|
||||
})
|
||||
|
||||
it.effect("projects environment connections without a catalog plugin", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = process.env.CATALOG_TEST_API_KEY
|
||||
process.env.CATALOG_TEST_API_KEY = "secret"
|
||||
return previous
|
||||
}),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const providerID = ProviderV2.ID.make("test")
|
||||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID: Integration.ID.make(providerID),
|
||||
method: { type: "env", names: ["CATALOG_TEST_API_KEY"] },
|
||||
}),
|
||||
)
|
||||
yield* (yield* catalog.transform())((editor) => editor.provider.update(providerID, () => {}))
|
||||
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
|
||||
}),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
if (previous === undefined) delete process.env.CATALOG_TEST_API_KEY
|
||||
else process.env.CATALOG_TEST_API_KEY = previous
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("normalizes provider baseURL into api url", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
@@ -326,9 +335,7 @@ describe("CatalogV2", () => {
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.enabled = { via: "custom", data: {} }
|
||||
})
|
||||
catalog.provider.update(providerID, () => {})
|
||||
catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => {
|
||||
model.time.released = DateTime.makeUnsafe(1000)
|
||||
})
|
||||
@@ -350,9 +357,7 @@ describe("CatalogV2", () => {
|
||||
const transform = yield* catalog.transform()
|
||||
|
||||
const models = (catalog: Catalog.Editor) => {
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.enabled = { via: "custom", data: {} }
|
||||
})
|
||||
catalog.provider.update(providerID, () => {})
|
||||
catalog.model.update(providerID, old, (model) => {
|
||||
model.time.released = DateTime.makeUnsafe(1000)
|
||||
})
|
||||
@@ -383,12 +388,10 @@ describe("CatalogV2", () => {
|
||||
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(disabledProvider, (provider) => {
|
||||
provider.enabled = false
|
||||
provider.disabled = true
|
||||
})
|
||||
catalog.model.update(disabledProvider, disabledModel, () => {})
|
||||
catalog.provider.update(enabledProvider, (provider) => {
|
||||
provider.enabled = { via: "custom", data: {} }
|
||||
})
|
||||
catalog.provider.update(enabledProvider, () => {})
|
||||
catalog.model.update(enabledProvider, fallbackModel, () => {})
|
||||
catalog.model.default.set(disabledProvider, disabledModel)
|
||||
})
|
||||
|
||||
@@ -3,10 +3,11 @@ import { Effect, Option, Schema } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { it } from "../plugin/provider-helper"
|
||||
import { it, withEnv } from "../plugin/provider-helper"
|
||||
|
||||
function request(headers: Record<string, string>, variant?: string) {
|
||||
return {
|
||||
@@ -21,6 +22,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
it.effect("partitions existing model variant bodies without changing config shape", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providerID = ProviderV2.ID.opencode
|
||||
const modelID = ModelV2.ID.make("alpha-gpt-next")
|
||||
@@ -59,6 +61,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
effect: ConfigProviderPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(Integration.Service, integrations),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -80,6 +83,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
it.effect("uses the effective provider package across layered config", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providerID = ProviderV2.ID.opencode
|
||||
const modelID = ModelV2.ID.make("alpha-gpt-next")
|
||||
@@ -118,6 +122,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
effect: ConfigProviderPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(Integration.Service, integrations),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -131,118 +136,126 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
)
|
||||
|
||||
it.effect("loads configured providers and applies later model overrides", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providerID = ProviderV2.ID.make("custom")
|
||||
const modelID = ModelV2.ID.make("chat")
|
||||
const config = Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
model: "custom/first",
|
||||
providers: {
|
||||
custom: {
|
||||
name: "Configured",
|
||||
env: ["CUSTOM_API_KEY"],
|
||||
api: { type: "native", settings: {} },
|
||||
request: request({ first: "first", shared: "first" }),
|
||||
models: {
|
||||
chat: {
|
||||
name: "First",
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
disabled: true,
|
||||
limit: { context: 100, output: 50 },
|
||||
cost: { input: 1, output: 2 },
|
||||
request: request({ first: "first", shared: "first" }, "retained"),
|
||||
variants: [
|
||||
{
|
||||
id: "fast",
|
||||
headers: { first: "first", shared: "first" },
|
||||
},
|
||||
],
|
||||
withEnv({ CUSTOM_API_KEY: "secret" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const providerID = ProviderV2.ID.make("custom")
|
||||
const modelID = ModelV2.ID.make("chat")
|
||||
const config = Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
model: "custom/first",
|
||||
providers: {
|
||||
custom: {
|
||||
name: "Configured",
|
||||
env: ["CUSTOM_API_KEY"],
|
||||
api: { type: "native", settings: {} },
|
||||
request: request({ first: "first", shared: "first" }),
|
||||
models: {
|
||||
chat: {
|
||||
name: "First",
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
disabled: true,
|
||||
limit: { context: 100, output: 50 },
|
||||
cost: { input: 1, output: 2 },
|
||||
request: request({ first: "first", shared: "first" }, "retained"),
|
||||
variants: [
|
||||
{
|
||||
id: "fast",
|
||||
headers: { first: "first", shared: "first" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
model: "custom/default",
|
||||
providers: {
|
||||
custom: {
|
||||
api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" },
|
||||
request: request({ last: "last", shared: "last" }),
|
||||
models: {
|
||||
default: {
|
||||
name: "Default",
|
||||
},
|
||||
chat: {
|
||||
api: { id: "api-chat" },
|
||||
name: "Last",
|
||||
limit: { output: 75 },
|
||||
request: request({ last: "last", shared: "last" }),
|
||||
variants: [
|
||||
{
|
||||
id: "fast",
|
||||
headers: { last: "last", shared: "last" },
|
||||
},
|
||||
{
|
||||
id: "slow",
|
||||
headers: { slow: "slow" },
|
||||
},
|
||||
],
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
model: "custom/default",
|
||||
providers: {
|
||||
custom: {
|
||||
api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" },
|
||||
request: request({ last: "last", shared: "last" }),
|
||||
models: {
|
||||
default: {
|
||||
name: "Default",
|
||||
},
|
||||
chat: {
|
||||
api: { id: "api-chat" },
|
||||
name: "Last",
|
||||
limit: { output: 75 },
|
||||
request: request({ last: "last", shared: "last" }),
|
||||
variants: [
|
||||
{
|
||||
id: "fast",
|
||||
headers: { last: "last", shared: "last" },
|
||||
},
|
||||
{
|
||||
id: "slow",
|
||||
headers: { slow: "slow" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
custom: { name: "Renamed" },
|
||||
},
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
custom: { name: "Renamed" },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
]),
|
||||
})
|
||||
|
||||
yield* plugin.add({
|
||||
...ConfigProviderPlugin.Plugin,
|
||||
effect: ConfigProviderPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
),
|
||||
})
|
||||
yield* plugin.add({
|
||||
...ConfigProviderPlugin.Plugin,
|
||||
effect: ConfigProviderPlugin.Plugin.effect.pipe(
|
||||
Effect.provideService(Config.Service, config),
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(Integration.Service, integrations),
|
||||
),
|
||||
})
|
||||
|
||||
const provider = yield* catalog.provider.get(providerID)
|
||||
const model = yield* catalog.model.get(providerID, modelID)
|
||||
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
|
||||
expect(provider.name).toBe("Renamed")
|
||||
expect(provider.env).toEqual(["CUSTOM_API_KEY"])
|
||||
expect(provider.enabled).toEqual({ via: "custom", data: {} })
|
||||
expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" })
|
||||
expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.api.id).toBe(ModelV2.ID.make("api-chat"))
|
||||
expect(model.name).toBe("Last")
|
||||
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
|
||||
expect(model.enabled).toBe(false)
|
||||
expect(model.limit).toEqual({ context: 100, output: 75 })
|
||||
expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }])
|
||||
expect(model.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.request.variant).toBe("retained")
|
||||
expect(model.variants.map((variant) => variant.id)).toEqual([
|
||||
ModelV2.VariantID.make("fast"),
|
||||
ModelV2.VariantID.make("slow"),
|
||||
])
|
||||
expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.variants[1]?.headers).toEqual({ slow: "slow" })
|
||||
}),
|
||||
const provider = yield* catalog.provider.get(providerID)
|
||||
const model = yield* catalog.model.get(providerID, modelID)
|
||||
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
|
||||
expect(provider.name).toBe("Renamed")
|
||||
expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({
|
||||
type: "env",
|
||||
names: ["CUSTOM_API_KEY"],
|
||||
})
|
||||
expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed")
|
||||
expect(provider.disabled).toBeUndefined()
|
||||
expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" })
|
||||
expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.api.id).toBe(ModelV2.ID.make("api-chat"))
|
||||
expect(model.name).toBe("Last")
|
||||
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
|
||||
expect(model.enabled).toBe(false)
|
||||
expect(model.limit).toEqual({ context: 100, output: 75 })
|
||||
expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }])
|
||||
expect(model.request.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.request.variant).toBe("retained")
|
||||
expect(model.variants.map((variant) => variant.id)).toEqual([
|
||||
ModelV2.VariantID.make("fast"),
|
||||
ModelV2.VariantID.make("slow"),
|
||||
])
|
||||
expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
expect(model.variants[1]?.headers).toEqual({ slow: "slow" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Duration, Effect, Exit, Layer, Scope } from "effect"
|
||||
import { Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { IntegrationConnection } from "@opencode-ai/core/integration/connection"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { it } from "./lib/effect"
|
||||
@@ -25,7 +24,7 @@ function connectionLayer(
|
||||
}>,
|
||||
) {
|
||||
return Integration.locationLayer.pipe(
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provide(
|
||||
Layer.mock(Credential.Service)({
|
||||
create: (input) =>
|
||||
@@ -103,7 +102,7 @@ describe("Integration", () => {
|
||||
.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
|
||||
method: { id: methodID, type: "oauth", label: "ChatGPT" },
|
||||
authorize,
|
||||
}),
|
||||
)
|
||||
@@ -117,7 +116,7 @@ describe("Integration", () => {
|
||||
])
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT Override" }),
|
||||
method: { id: methodID, type: "oauth", label: "ChatGPT Override" },
|
||||
authorize,
|
||||
})
|
||||
})
|
||||
@@ -140,15 +139,20 @@ describe("Integration", () => {
|
||||
}> = []
|
||||
return Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const events = yield* EventV2.Service
|
||||
const integrationID = Integration.ID.make("openai")
|
||||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.KeyMethod({ type: "key", label: "API key" }),
|
||||
method: { type: "key", label: "API key" },
|
||||
}),
|
||||
)
|
||||
const updated = yield* events
|
||||
.subscribe(Integration.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* integrations.connect.key({
|
||||
yield* integrations.connection.key({
|
||||
integrationID,
|
||||
key: "secret",
|
||||
label: "Work",
|
||||
@@ -161,6 +165,7 @@ describe("Integration", () => {
|
||||
value: new Credential.Key({ type: "key", key: "secret" }),
|
||||
},
|
||||
])
|
||||
expect((yield* Fiber.join(updated)).length).toBe(1)
|
||||
}).pipe(Effect.provide(connectionLayer(created)))
|
||||
})
|
||||
|
||||
@@ -177,7 +182,7 @@ describe("Integration", () => {
|
||||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
|
||||
method: { id: methodID, type: "oauth", label: "ChatGPT" },
|
||||
authorize: () =>
|
||||
Effect.succeed({
|
||||
mode: "code" as const,
|
||||
@@ -198,7 +203,7 @@ describe("Integration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const attempt = yield* integrations.connect.oauth({
|
||||
const attempt = yield* integrations.connection.oauth({
|
||||
integrationID,
|
||||
methodID,
|
||||
inputs: {},
|
||||
@@ -236,7 +241,7 @@ describe("Integration", () => {
|
||||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
|
||||
method: { id: methodID, type: "oauth", label: "ChatGPT" },
|
||||
authorize: () =>
|
||||
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
|
||||
Effect.as({
|
||||
@@ -249,7 +254,7 @@ describe("Integration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
|
||||
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
|
||||
expect(yield* integrations.attempt.complete({ attemptID: attempt.attemptID }).pipe(Effect.flip)).toBeInstanceOf(
|
||||
Integration.CodeRequiredError,
|
||||
)
|
||||
@@ -273,7 +278,7 @@ describe("Integration", () => {
|
||||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }),
|
||||
method: { id: methodID, type: "oauth", label: "Browser" },
|
||||
authorize: () =>
|
||||
Effect.succeed({
|
||||
mode: "auto" as const,
|
||||
@@ -286,7 +291,7 @@ describe("Integration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
|
||||
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({
|
||||
status: "complete",
|
||||
@@ -310,7 +315,7 @@ describe("Integration", () => {
|
||||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }),
|
||||
method: { id: methodID, type: "oauth", label: "Browser" },
|
||||
authorize: () =>
|
||||
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
|
||||
Effect.as({
|
||||
@@ -323,7 +328,7 @@ describe("Integration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
|
||||
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
|
||||
expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10)))
|
||||
yield* TestClock.adjust(Duration.minutes(10))
|
||||
yield* Effect.yieldNow
|
||||
@@ -373,23 +378,28 @@ describe("Integration", () => {
|
||||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.EnvMethod({
|
||||
method: {
|
||||
type: "env",
|
||||
names: ["INTEGRATION_TEST_ACME_KEY", "INTEGRATION_TEST_ACME_MISSING"],
|
||||
}),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// Stored credentials and detected env vars appear as connections.
|
||||
expect((yield* integrations.get(integrationID))?.connections).toEqual([
|
||||
new IntegrationConnection.CredentialInfo({ type: "credential", id: rows[0]!.id, label: "Work" }),
|
||||
new IntegrationConnection.CredentialInfo({
|
||||
{ type: "credential", id: rows[0]!.id, label: "Work" },
|
||||
{
|
||||
type: "credential",
|
||||
id: rows[1]!.id,
|
||||
label: "Personal",
|
||||
}),
|
||||
new IntegrationConnection.EnvInfo({ type: "env", name: "INTEGRATION_TEST_ACME_KEY" }),
|
||||
},
|
||||
{ type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
|
||||
])
|
||||
expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({
|
||||
type: "credential",
|
||||
id: rows[1]!.id,
|
||||
label: "Personal",
|
||||
})
|
||||
}).pipe(Effect.provide(projectionLayer)),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("Integration settlement guards", () => {
|
||||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }),
|
||||
method: { id: methodID, type: "oauth", label: "Browser" },
|
||||
authorize: () =>
|
||||
Effect.succeed({
|
||||
mode: "auto" as const,
|
||||
@@ -78,7 +78,7 @@ describe("Integration settlement guards", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
|
||||
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* integrations.attempt.status(attempt.attemptID)).toMatchObject({
|
||||
status: "failed",
|
||||
@@ -104,7 +104,7 @@ describe("Integration settlement guards", () => {
|
||||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
|
||||
method: { id: methodID, type: "oauth", label: "ChatGPT" },
|
||||
authorize: () =>
|
||||
Effect.succeed({
|
||||
mode: "code" as const,
|
||||
@@ -118,10 +118,10 @@ describe("Integration settlement guards", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
|
||||
const exit = yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: "1234" }).pipe(
|
||||
Effect.exit,
|
||||
)
|
||||
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
|
||||
const exit = yield* integrations.attempt
|
||||
.complete({ attemptID: attempt.attemptID, code: "1234" })
|
||||
.pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("database unavailable")
|
||||
expect(yield* integrations.attempt.status(attempt.attemptID)).toMatchObject({
|
||||
@@ -165,7 +165,7 @@ describe("Integration settlement guards", () => {
|
||||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
|
||||
method: { id: methodID, type: "oauth", label: "ChatGPT" },
|
||||
authorize: () =>
|
||||
Effect.succeed({
|
||||
mode: "code" as const,
|
||||
@@ -179,7 +179,7 @@ describe("Integration settlement guards", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
|
||||
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
|
||||
const fiber = yield* integrations.attempt
|
||||
.complete({ attemptID: attempt.attemptID, code: "1234" })
|
||||
.pipe(Effect.forkScoped)
|
||||
@@ -212,7 +212,7 @@ describe("Integration settlement guards", () => {
|
||||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
|
||||
method: { id: methodID, type: "oauth", label: "ChatGPT" },
|
||||
authorize: () =>
|
||||
Effect.succeed({
|
||||
mode: "code" as const,
|
||||
@@ -229,7 +229,7 @@ describe("Integration settlement guards", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
|
||||
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
|
||||
const fiber = yield* integrations.attempt
|
||||
.complete({ attemptID: attempt.attemptID, code: "1234" })
|
||||
.pipe(Effect.forkScoped)
|
||||
@@ -253,7 +253,7 @@ describe("Integration settlement guards", () => {
|
||||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
|
||||
method: { id: methodID, type: "oauth", label: "ChatGPT" },
|
||||
authorize: () =>
|
||||
Effect.addFinalizer(() => Effect.sync(() => (state.closed = true))).pipe(
|
||||
Effect.as({
|
||||
@@ -266,7 +266,7 @@ describe("Integration settlement guards", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
|
||||
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
|
||||
const fiber = yield* integrations.attempt
|
||||
.complete({ attemptID: attempt.attemptID, code: "1234" })
|
||||
.pipe(Effect.exit, Effect.forkScoped)
|
||||
@@ -301,7 +301,7 @@ describe("Integration settlement guards", () => {
|
||||
yield* integrations.update((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
|
||||
method: { id: methodID, type: "oauth", label: "ChatGPT" },
|
||||
authorize: () =>
|
||||
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
|
||||
Effect.as({
|
||||
@@ -310,14 +310,20 @@ describe("Integration settlement guards", () => {
|
||||
instructions: "Paste the code",
|
||||
callback: () =>
|
||||
Effect.succeed(
|
||||
new Credential.OAuth({ type: "oauth", methodID, access: "access", refresh: "refresh", expires: 1 }),
|
||||
new Credential.OAuth({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
expires: 1,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} })
|
||||
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
|
||||
const fiber = yield* integrations.attempt
|
||||
.complete({ attemptID: attempt.attemptID, code: "1234" })
|
||||
.pipe(Effect.exit, Effect.forkScoped)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway"
|
||||
@@ -16,7 +17,12 @@ describe("provider attribution isolation", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
for (const plugin of [LLMGatewayPlugin, NvidiaPlugin, OpenRouterPlugin, VercelPlugin, ZenmuxPlugin]) {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* plugins.add({
|
||||
...LLMGatewayPlugin,
|
||||
effect: LLMGatewayPlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)),
|
||||
})
|
||||
for (const plugin of [NvidiaPlugin, OpenRouterPlugin, VercelPlugin, ZenmuxPlugin]) {
|
||||
yield* plugins.add(plugin)
|
||||
}
|
||||
|
||||
@@ -24,7 +30,6 @@ describe("provider attribution isolation", () => {
|
||||
yield* transform((catalog) => {
|
||||
const items = [
|
||||
provider("custom-llmgateway", {
|
||||
enabled: { via: "env", name: "CUSTOM_LLMGATEWAY_API_KEY" },
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
|
||||
}),
|
||||
provider("custom-nvidia", {
|
||||
@@ -47,7 +52,6 @@ describe("provider attribution isolation", () => {
|
||||
|
||||
for (const item of items) {
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.enabled = item.enabled
|
||||
draft.api = item.api
|
||||
draft.request.headers.Existing = "value"
|
||||
})
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
describe("SessionRunnerModel Kilo credentials", () => {
|
||||
it.effect("maps OAuth account IDs to Kilo organization routing", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = new ModelV2.Info({
|
||||
id: ModelV2.ID.make("test-model"),
|
||||
providerID: ProviderV2.ID.make("kilo"),
|
||||
name: "Test model",
|
||||
api: {
|
||||
id: ModelV2.ID.make("api-test-model"),
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://api.kilo.ai/openrouter",
|
||||
},
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
request: { headers: {}, body: {}, generation: {}, options: {} },
|
||||
variants: [],
|
||||
time: { released: DateTime.makeUnsafe(0) },
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 100, output: 20 },
|
||||
})
|
||||
const credential = new Credential.Stored({
|
||||
id: Credential.ID.create(),
|
||||
integrationID: Integration.ID.make("kilo"),
|
||||
label: "Work",
|
||||
value: new Credential.OAuth({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("oauth"),
|
||||
refresh: "refresh",
|
||||
access: "access",
|
||||
expires: 1,
|
||||
metadata: { accountID: "org-enterprise" },
|
||||
}),
|
||||
})
|
||||
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
model,
|
||||
{ type: "credential", id: credential.id, label: credential.label },
|
||||
credential,
|
||||
)
|
||||
|
||||
expect(resolved.route.defaults.http?.body).toMatchObject({ kilocodeOrganizationId: "org-enterprise" })
|
||||
expect(resolved.route.defaults.http?.body).not.toHaveProperty("accountID")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -28,8 +28,10 @@ const connections = Credential.layer.pipe(
|
||||
Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)),
|
||||
Layer.provide(events),
|
||||
)
|
||||
const catalog = Catalog.layer.pipe(Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections)))
|
||||
const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections))
|
||||
const catalog = Catalog.layer.pipe(
|
||||
Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)),
|
||||
)
|
||||
const layer = Layer.mergeAll(
|
||||
catalog.pipe(Layer.provide(connections)),
|
||||
integrations,
|
||||
@@ -61,11 +63,11 @@ describe("ModelsDevPlugin", () => {
|
||||
id: Integration.ID.make("acme"),
|
||||
name: "Acme",
|
||||
methods: [
|
||||
new Integration.KeyMethod({ type: "key" }),
|
||||
new Integration.EnvMethod({
|
||||
{ type: "key" },
|
||||
{
|
||||
type: "env",
|
||||
names: ["ACME_API_KEY"],
|
||||
}),
|
||||
},
|
||||
],
|
||||
connections: [],
|
||||
}),
|
||||
|
||||
@@ -1,35 +1,10 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
|
||||
|
||||
const database = Database.layerFromPath(":memory:").pipe(Layer.fresh)
|
||||
const preferences = Credential.layer.pipe(Layer.provide(database))
|
||||
const accounts = Layer.merge(
|
||||
Credential.layer.pipe(Layer.provide(database), Layer.provide(preferences), Layer.provide(EventV2.defaultLayer)),
|
||||
preferences,
|
||||
)
|
||||
const itWithAccount = testEffect(
|
||||
Catalog.locationLayer.pipe(
|
||||
Layer.provideMerge(accounts),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
|
||||
),
|
||||
Layer.provideMerge(npmLayer),
|
||||
),
|
||||
)
|
||||
import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper"
|
||||
|
||||
describe("AzurePlugin", () => {
|
||||
it.effect("resolves resourceName from env", () =>
|
||||
@@ -73,35 +48,7 @@ describe("AzurePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
itWithAccount.effect("prefers account resourceName over env", () =>
|
||||
withEnv(
|
||||
{
|
||||
AZURE_RESOURCE_NAME: "from-env",
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("azure"),
|
||||
value: new Credential.Key({
|
||||
type: "key",
|
||||
key: "key",
|
||||
metadata: { resourceName: "from-account" },
|
||||
}),
|
||||
})
|
||||
yield* plugin.add(AzurePlugin)
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.azure, (item) => {
|
||||
item.api = { type: "aisdk", package: "@ai-sdk/azure" }
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-account")
|
||||
}),
|
||||
),
|
||||
)
|
||||
// kilocode_change - remove stale account projection coverage, matching the v1.17.10 upstream follow-up
|
||||
|
||||
it.effect("falls back to env when configured resourceName is blank", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
|
||||
|
||||
@@ -1,36 +1,11 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper"
|
||||
|
||||
const database = Database.layerFromPath(":memory:").pipe(Layer.fresh)
|
||||
const preferences = Credential.layer.pipe(Layer.provide(database))
|
||||
const accounts = Layer.merge(
|
||||
Credential.layer.pipe(Layer.provide(database), Layer.provide(preferences), Layer.provide(EventV2.defaultLayer)),
|
||||
preferences,
|
||||
)
|
||||
const itWithAccount = testEffect(
|
||||
Catalog.locationLayer.pipe(
|
||||
Layer.provideMerge(accounts),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
|
||||
),
|
||||
Layer.provideMerge(npmLayer),
|
||||
),
|
||||
)
|
||||
import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper"
|
||||
|
||||
function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") {
|
||||
return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel(
|
||||
@@ -126,41 +101,7 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
itWithAccount.effect("falls back to account metadata when account env is absent", () =>
|
||||
withEnv(
|
||||
{
|
||||
CLOUDFLARE_ACCOUNT_ID: undefined,
|
||||
CLOUDFLARE_API_KEY: undefined,
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("cloudflare-workers-ai"),
|
||||
value: new Credential.Key({
|
||||
type: "key",
|
||||
key: "account-key",
|
||||
metadata: { accountId: "account-acct" },
|
||||
}),
|
||||
})
|
||||
yield* plugin.add(CloudflareWorkersAIPlugin)
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) =>
|
||||
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
|
||||
provider.api = { type: "aisdk", package: "test-provider" }
|
||||
}),
|
||||
)
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).request.body).toMatchObject(
|
||||
{
|
||||
apiKey: "account-key",
|
||||
accountId: "account-acct",
|
||||
},
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
// kilocode_change - remove stale account projection coverage, matching the v1.17.10 upstream follow-up
|
||||
|
||||
it.effect("uses env account ID over configured account ID", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
|
||||
|
||||
@@ -1,26 +1,10 @@
|
||||
import { describe, expect, mock } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { it, model, npmLayer, withEnv } from "./provider-helper"
|
||||
import { it, model, withEnv } from "./provider-helper"
|
||||
|
||||
const gitlabSDKOptions: Record<string, unknown>[] = []
|
||||
const database = Database.layerFromPath(":memory:").pipe(Layer.fresh)
|
||||
const preferences = Credential.layer.pipe(Layer.provide(database))
|
||||
const accounts = Layer.merge(
|
||||
Credential.layer.pipe(Layer.provide(database), Layer.provide(preferences), Layer.provide(EventV2.defaultLayer)),
|
||||
preferences,
|
||||
)
|
||||
|
||||
void mock.module("gitlab-ai-provider", () => ({
|
||||
VERSION: "test-version",
|
||||
@@ -35,17 +19,6 @@ void mock.module("gitlab-ai-provider", () => ({
|
||||
isWorkflowModel: (id: string) => id === "duo-workflow" || id === "duo-workflow-exact",
|
||||
}))
|
||||
|
||||
const itWithAccount = testEffect(
|
||||
Catalog.locationLayer.pipe(
|
||||
Layer.provideMerge(accounts),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/") }))),
|
||||
),
|
||||
Layer.provideMerge(npmLayer),
|
||||
),
|
||||
)
|
||||
|
||||
describe("GitLabPlugin", () => {
|
||||
it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () =>
|
||||
withEnv(
|
||||
@@ -163,77 +136,7 @@ describe("GitLabPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
itWithAccount.effect("uses active account API token over GITLAB_TOKEN", () =>
|
||||
withEnv(
|
||||
{
|
||||
GITLAB_TOKEN: "env-token",
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
gitlabSDKOptions.length = 0
|
||||
const plugin = yield* PluginV2.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("gitlab"),
|
||||
value: new Credential.Key({ type: "key", key: "account-token" }),
|
||||
})
|
||||
yield* plugin.add(GitLabPlugin)
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
|
||||
const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab"))
|
||||
yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("gitlab", "claude"),
|
||||
package: "gitlab-ai-provider",
|
||||
options: provider.request.body,
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(gitlabSDKOptions[0].apiKey).toBe("account-token")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
itWithAccount.effect("uses active account OAuth access token when no API token exists", () =>
|
||||
withEnv(
|
||||
{
|
||||
GITLAB_TOKEN: undefined,
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
gitlabSDKOptions.length = 0
|
||||
const plugin = yield* PluginV2.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("gitlab"),
|
||||
value: new Credential.OAuth({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("oauth"),
|
||||
refresh: "refresh-token",
|
||||
access: "account-oauth-token",
|
||||
expires: 9999999999999,
|
||||
}),
|
||||
})
|
||||
yield* plugin.add(GitLabPlugin)
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
|
||||
const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab"))
|
||||
yield* plugin.trigger(
|
||||
"aisdk.sdk",
|
||||
{
|
||||
model: model("gitlab", "claude"),
|
||||
package: "gitlab-ai-provider",
|
||||
options: provider.request.body,
|
||||
},
|
||||
{},
|
||||
)
|
||||
expect(gitlabSDKOptions[0].apiKey).toBe("account-oauth-token")
|
||||
}),
|
||||
),
|
||||
)
|
||||
// kilocode_change - remove stale account projection coverage, matching the v1.17.10 upstream follow-up
|
||||
|
||||
it.effect("uses workflowChat for duo workflow models and preserves selectedModelRef", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -53,6 +53,7 @@ const integrations = Integration.locationLayer.pipe(
|
||||
Layer.provide(
|
||||
Layer.mock(Credential.Service)({
|
||||
create: () => Effect.die("unexpected credential creation"),
|
||||
all: () => Effect.succeed([]),
|
||||
list: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo"
|
||||
@@ -151,20 +150,18 @@ describe("KiloPlugin", () => {
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("kilo", {
|
||||
enabled: { via: "credential", credentialID: Credential.ID.make("cred_kilo") },
|
||||
request: {
|
||||
headers: {},
|
||||
body: { apiKey: "authenticated-token", kilocodeOrganizationId: "authenticated-org" },
|
||||
},
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.enabled = item.enabled
|
||||
draft.request = item.request
|
||||
})
|
||||
})
|
||||
const result = yield* catalog.provider.get(ProviderV2.ID.make("kilo"))
|
||||
|
||||
expect(result.enabled).toEqual({ via: "credential", credentialID: Credential.ID.make("cred_kilo") })
|
||||
expect(result.request.body.apiKey).toBe("authenticated-token")
|
||||
expect(result.request.body.kilocodeToken).toBe("authenticated-token")
|
||||
expect(result.request.body.kilocodeOrganizationId).toBe("environment-org")
|
||||
}),
|
||||
@@ -181,7 +178,10 @@ describe("KiloPlugin", () => {
|
||||
yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("kilo"), () => {}))
|
||||
const result = yield* catalog.provider.get(ProviderV2.ID.make("kilo"))
|
||||
|
||||
expect(result.enabled).toEqual({ via: "custom", data: { anonymous: true } })
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(
|
||||
ProviderV2.ID.make("kilo"),
|
||||
)
|
||||
expect(result.request.body.apiKey).toBe("anonymous")
|
||||
expect(result.request.body.kilocodeToken).toBe("anonymous")
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway"
|
||||
@@ -8,6 +9,14 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { expectPluginRegistered, it, provider } from "./provider-helper"
|
||||
|
||||
describe("LLMGatewayPlugin", () => {
|
||||
const add = Effect.fnUntraced(function* (plugin: PluginV2.Interface) {
|
||||
const integrations = yield* Integration.Service
|
||||
yield* plugin.add({
|
||||
...LLMGatewayPlugin,
|
||||
effect: LLMGatewayPlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)),
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||
Effect.sync(() =>
|
||||
expectPluginRegistered(
|
||||
@@ -21,31 +30,29 @@ describe("LLMGatewayPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(LLMGatewayPlugin)
|
||||
yield* add(plugin)
|
||||
const integrations = yield* Integration.Service
|
||||
yield* integrations.update((editor) => {
|
||||
editor.update(Integration.ID.make("llmgateway"), () => {})
|
||||
editor.update(Integration.ID.make("openrouter"), () => {})
|
||||
})
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const llmgateway = provider("llmgateway", {
|
||||
enabled: { via: "env", name: "LLMGATEWAY_API_KEY" },
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
|
||||
request: { headers: { Existing: "value" }, body: {} },
|
||||
})
|
||||
catalog.provider.update(llmgateway.id, (draft) => {
|
||||
draft.enabled = llmgateway.enabled
|
||||
draft.api = llmgateway.api
|
||||
draft.request = llmgateway.request
|
||||
})
|
||||
const openrouter = provider("openrouter", {
|
||||
enabled: { via: "env", name: "OPENROUTER_API_KEY" },
|
||||
})
|
||||
catalog.provider.update(openrouter.id, (draft) => {
|
||||
draft.enabled = openrouter.enabled
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({
|
||||
Existing: "value",
|
||||
"HTTP-Referer": "https://kilo.ai/",
|
||||
"X-Title": "Kilo Code",
|
||||
"X-Source": "kilo",
|
||||
"X-Title": "Kilo Code", // kilocode_change
|
||||
"X-Source": "kilo", // kilocode_change
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({})
|
||||
}),
|
||||
@@ -55,7 +62,7 @@ describe("LLMGatewayPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(LLMGatewayPlugin)
|
||||
yield* add(plugin)
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("llmgateway", {
|
||||
@@ -66,7 +73,7 @@ describe("LLMGatewayPlugin", () => {
|
||||
})
|
||||
})
|
||||
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).enabled).toBe(false)
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).disabled).toBeUndefined()
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -21,16 +21,16 @@ describe("OpenAIPlugin", () => {
|
||||
const plugin = yield* PluginV2.Service
|
||||
yield* add(plugin, yield* Integration.Service)
|
||||
expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([
|
||||
new Integration.OAuthMethod({
|
||||
{
|
||||
id: Integration.MethodID.make("chatgpt-browser"),
|
||||
type: "oauth",
|
||||
label: "ChatGPT Pro/Plus (browser)",
|
||||
}),
|
||||
new Integration.OAuthMethod({
|
||||
},
|
||||
{
|
||||
id: Integration.MethodID.make("chatgpt-headless"),
|
||||
type: "oauth",
|
||||
label: "ChatGPT Pro/Plus (headless)",
|
||||
}),
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DateTime, Effect, Layer, Option } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
@@ -18,13 +19,18 @@ const locationLayer = Layer.succeed(
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
|
||||
)
|
||||
|
||||
const pluginWithIntegrations = (integrations: Integration.Interface) => ({
|
||||
...OpencodePlugin,
|
||||
effect: OpencodePlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)),
|
||||
})
|
||||
|
||||
describe("OpencodePlugin", () => {
|
||||
it.effect("uses a public key and disables paid models without credentials", () =>
|
||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode")
|
||||
@@ -45,7 +51,7 @@ describe("OpencodePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode")
|
||||
@@ -66,7 +72,7 @@ describe("OpencodePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode")
|
||||
@@ -87,7 +93,7 @@ describe("OpencodePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode")
|
||||
@@ -108,13 +114,18 @@ describe("OpencodePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
const integrations = yield* Integration.Service
|
||||
yield* plugin.add(pluginWithIntegrations(integrations))
|
||||
yield* integrations.update((editor) => {
|
||||
editor.method.update({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
method: { type: "env", names: ["CUSTOM_OPENCODE_API_KEY"] },
|
||||
})
|
||||
})
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] })
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.env = [...item.env]
|
||||
})
|
||||
const item = provider("opencode")
|
||||
catalog.provider.update(item.id, () => {})
|
||||
const paid = model("opencode", "paid", { cost: cost(1) })
|
||||
catalog.model.update(item.id, paid.id, (draft) => {
|
||||
draft.cost = [...paid.cost]
|
||||
@@ -131,7 +142,7 @@ describe("OpencodePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode", {
|
||||
@@ -154,37 +165,12 @@ describe("OpencodePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses auth-enabled providers as credentials", () =>
|
||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("opencode", {
|
||||
enabled: { via: "credential", credentialID: Credential.ID.make("credential") },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.enabled = item.enabled
|
||||
})
|
||||
const paid = model("opencode", "paid", { cost: cost(1) })
|
||||
catalog.model.update(item.id, paid.id, (draft) => {
|
||||
draft.cost = [...paid.cost]
|
||||
})
|
||||
})
|
||||
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined()
|
||||
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("ignores non-opencode providers and models", () =>
|
||||
withEnv({ OPENCODE_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* plugin.add(OpencodePlugin)
|
||||
yield* plugin.add(pluginWithIntegrations(yield* Integration.Service))
|
||||
const transform = yield* catalog.transform()
|
||||
yield* transform((catalog) => {
|
||||
const item = provider("openai")
|
||||
|
||||
@@ -365,6 +365,18 @@ describe("ProjectCopy", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh ignores existing directories that are no longer git checkouts", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
yield* Effect.promise(() => fs.rm(path.join(input.sourceDirectory, ".git"), { recursive: true }))
|
||||
const copy = yield* ProjectCopy.Service
|
||||
|
||||
yield* copy.refresh({ projectID: input.projectID })
|
||||
|
||||
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh with no roots is a no-op", () =>
|
||||
Effect.gen(function* () {
|
||||
const copy = yield* ProjectCopy.Service
|
||||
|
||||
@@ -24,4 +24,9 @@ describe("Pty.Info", () => {
|
||||
test("rejects a negative pid", () => {
|
||||
expect(() => Schema.decodeUnknownSync(Pty.Info)(sample(-1))).toThrow()
|
||||
})
|
||||
|
||||
test("accepts an exit code for retained exited sessions", () => {
|
||||
const info = Schema.decodeUnknownSync(Pty.Info)({ ...sample(48012), status: "exited", exitCode: 4 })
|
||||
expect(info.exitCode).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { handlePtyInput } from "@opencode-ai/core/pty/input"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
describe("pty websocket input", () => {
|
||||
it.effect("does not forward invalid binary frames to the PTY handler", () =>
|
||||
Effect.gen(function* () {
|
||||
const messages: Array<string | ArrayBuffer> = []
|
||||
const handler = { onMessage: (message: string | ArrayBuffer) => messages.push(message) }
|
||||
|
||||
yield* handlePtyInput(handler, "ready")
|
||||
yield* handlePtyInput(handler, new Uint8Array([0xff, 0xfe, 0xfd]))
|
||||
yield* handlePtyInput(handler, new TextEncoder().encode("hello"))
|
||||
|
||||
expect(messages).toEqual(["ready", "hello"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { PtyProtocol } from "@opencode-ai/core/pty/protocol"
|
||||
|
||||
describe("pty protocol", () => {
|
||||
test("drops invalid binary input frames and decodes valid ones", () => {
|
||||
expect(PtyProtocol.decodeInput("ready")).toBe("ready")
|
||||
expect(PtyProtocol.decodeInput(new Uint8Array([0xff, 0xfe, 0xfd]))).toBeUndefined()
|
||||
expect(PtyProtocol.decodeInput(new TextEncoder().encode("hello"))).toBe("hello")
|
||||
expect(PtyProtocol.decodeInput(new TextEncoder().encode("hello").buffer)).toBe("hello")
|
||||
})
|
||||
|
||||
test("encodes the cursor as a 0x00-prefixed JSON control frame", () => {
|
||||
const frame = PtyProtocol.metaFrame(42)
|
||||
expect(frame[0]).toBe(0)
|
||||
expect(JSON.parse(new TextDecoder().decode(frame.subarray(1)))).toEqual({ cursor: 42 })
|
||||
})
|
||||
|
||||
test("splits replay into bounded frames", () => {
|
||||
expect(PtyProtocol.chunks("")).toEqual([])
|
||||
expect(PtyProtocol.chunks("abc")).toEqual(["abc"])
|
||||
const big = "x".repeat(PtyProtocol.REPLAY_CHUNK + 1)
|
||||
const frames = PtyProtocol.chunks(big)
|
||||
expect(frames.length).toBe(2)
|
||||
expect(frames[0].length).toBe(PtyProtocol.REPLAY_CHUNK)
|
||||
expect(frames.join("")).toBe(big)
|
||||
})
|
||||
})
|
||||
@@ -1,110 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Duration, Effect, Layer, Queue } from "effect"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Pty } from "@opencode-ai/core/pty"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
type Socket = Parameters<Pty.Interface["connect"]>[1]
|
||||
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
|
||||
)
|
||||
const it = testEffect(Pty.layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer)))
|
||||
const ptyTest = process.platform === "win32" ? it.live.skip : it.live
|
||||
|
||||
const createPty = Effect.fn("PtyOutputIsolationTest.createPty")(function* (command: string) {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* Effect.acquireRelease(
|
||||
pty.create({ command, args: [], cwd: "/tmp", env: { TERM: "xterm-256color", KILO_TERMINAL: "1" } }),
|
||||
(info) => pty.remove(info.id).pipe(Effect.ignore),
|
||||
)
|
||||
})
|
||||
|
||||
const decodeOutput = (data: string | Uint8Array | ArrayBuffer) =>
|
||||
typeof data === "string"
|
||||
? data
|
||||
: Buffer.from(data instanceof Uint8Array ? data : new Uint8Array(data)).toString("utf8")
|
||||
|
||||
const makeSocket = Effect.fn("PtyOutputIsolationTest.makeSocket")(function* (data: unknown) {
|
||||
const output = yield* Queue.unbounded<string>()
|
||||
const socket: Socket = {
|
||||
readyState: 1,
|
||||
data,
|
||||
send: (data) => Queue.offerUnsafe(output, decodeOutput(data)),
|
||||
close: () => {},
|
||||
}
|
||||
return { socket, output }
|
||||
})
|
||||
|
||||
const waitForOutput = (output: Queue.Queue<string>, text: string, duration: Duration.Input = "5 seconds") =>
|
||||
Effect.gen(function* () {
|
||||
let received = ""
|
||||
while (!received.includes(text)) received += yield* Queue.take(output)
|
||||
return received
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration,
|
||||
orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)),
|
||||
}),
|
||||
)
|
||||
|
||||
describe("pty output isolation", () => {
|
||||
ptyTest("does not leak output when websocket objects are reused", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const a = yield* createPty("cat")
|
||||
const b = yield* createPty("cat")
|
||||
const shared = yield* makeSocket({ events: { connection: "a" } })
|
||||
const outB = yield* Queue.unbounded<string>()
|
||||
|
||||
yield* pty.connect(a.id, shared.socket)
|
||||
shared.socket.data = { events: { connection: "b" } }
|
||||
shared.socket.send = (data) => Queue.offerUnsafe(outB, decodeOutput(data))
|
||||
yield* pty.connect(b.id, shared.socket)
|
||||
yield* pty.write(a.id, "AAA\n")
|
||||
|
||||
const verify = yield* makeSocket({ events: { connection: "verify-a" } })
|
||||
yield* pty.connect(a.id, verify.socket)
|
||||
expect(yield* waitForOutput(verify.output, "AAA")).toContain("AAA")
|
||||
expect(yield* waitForOutput(outB, "AAA", "100 millis").pipe(Effect.option)).toMatchObject({ _tag: "None" })
|
||||
}),
|
||||
)
|
||||
|
||||
ptyTest("does not leak output when Bun recycles websocket objects before re-connect", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const info = yield* createPty("cat")
|
||||
const first = yield* makeSocket({ events: { connection: "a" } })
|
||||
const recycled = yield* Queue.unbounded<string>()
|
||||
|
||||
yield* pty.connect(info.id, first.socket)
|
||||
first.socket.data = { events: { connection: "b" } }
|
||||
first.socket.send = (data) => Queue.offerUnsafe(recycled, decodeOutput(data))
|
||||
yield* pty.write(info.id, "AAA\n")
|
||||
|
||||
const verify = yield* makeSocket({ events: { connection: "verify" } })
|
||||
yield* pty.connect(info.id, verify.socket)
|
||||
expect(yield* waitForOutput(verify.output, "AAA")).toContain("AAA")
|
||||
expect(yield* waitForOutput(recycled, "AAA", "100 millis").pipe(Effect.option)).toMatchObject({ _tag: "None" })
|
||||
}),
|
||||
)
|
||||
|
||||
ptyTest("treats in-place socket data mutation as the same connection", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const info = yield* createPty("cat")
|
||||
const data = { connId: 1 }
|
||||
const socket = yield* makeSocket(data)
|
||||
|
||||
yield* pty.connect(info.id, socket.socket)
|
||||
data.connId = 2
|
||||
yield* pty.write(info.id, "AAA\n")
|
||||
|
||||
expect(yield* waitForOutput(socket.output, "AAA")).toContain("AAA")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Effect, Exit, Layer, Queue } from "effect"
|
||||
import { Cause, Deferred, Effect, Exit, Layer, Queue } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Pty } from "@opencode-ai/core/pty"
|
||||
@@ -14,7 +15,14 @@ const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
|
||||
)
|
||||
const it = testEffect(Pty.layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer)))
|
||||
const configLayer = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
||||
const it = testEffect(
|
||||
Pty.layer.pipe(
|
||||
Layer.provide(configLayer),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(locationLayer),
|
||||
),
|
||||
)
|
||||
const ptyTest = process.platform === "win32" ? it.live.skip : it.live
|
||||
|
||||
const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () {
|
||||
@@ -56,36 +64,176 @@ const waitForEvents = (events: Queue.Queue<PtyEvent>, id: PtyID, count: number)
|
||||
}),
|
||||
)
|
||||
|
||||
const attachCollecting = Effect.fn("PtySessionTest.attachCollecting")(function* (id: PtyID, cursor?: number) {
|
||||
const pty = yield* Pty.Service
|
||||
const output = yield* Queue.unbounded<string>()
|
||||
const ended = yield* Deferred.make<{ exitCode?: number }>()
|
||||
const attachment = yield* pty.attach(id, {
|
||||
cursor,
|
||||
onData: (chunk) => Queue.offerUnsafe(output, chunk),
|
||||
onEnd: (event) => Deferred.doneUnsafe(ended, Effect.succeed(event)),
|
||||
})
|
||||
attachment.activate()
|
||||
return { attachment, output, ended }
|
||||
})
|
||||
|
||||
const waitForOutput = (output: Queue.Queue<string>, text: string) =>
|
||||
Effect.gen(function* () {
|
||||
let received = ""
|
||||
while (!received.includes(text)) received += yield* Queue.take(output)
|
||||
return received
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "5 seconds",
|
||||
orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)),
|
||||
}),
|
||||
)
|
||||
|
||||
describe("pty", () => {
|
||||
it.live("returns typed not found errors for missing sessions", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const id = "pty_missing" as PtyID
|
||||
let closed = false
|
||||
const socket = { readyState: 1, send: () => {}, close: () => void (closed = true) }
|
||||
|
||||
for (const result of [
|
||||
yield* pty.get(id).pipe(Effect.asVoid, Effect.exit),
|
||||
yield* pty.update(id, { title: "missing" }).pipe(Effect.asVoid, Effect.exit),
|
||||
yield* pty.remove(id).pipe(Effect.exit),
|
||||
yield* pty.resize(id, 80, 24).pipe(Effect.exit),
|
||||
yield* pty.write(id, "input").pipe(Effect.exit),
|
||||
yield* pty.connect(id, socket).pipe(Effect.asVoid, Effect.exit),
|
||||
yield* pty.attach(id, { onData: () => {}, onEnd: () => {} }).pipe(Effect.asVoid, Effect.exit),
|
||||
]) {
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
if (Exit.isFailure(result))
|
||||
expect(Cause.squash(result.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id })
|
||||
}
|
||||
expect(closed).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
ptyTest("publishes created, exited, deleted in order for a short-lived process", () =>
|
||||
ptyTest("retains exited sessions until removed", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const events = yield* subscribePtyEvents()
|
||||
const info = yield* createPty("/usr/bin/env", ["sh", "-c", "sleep 0.1"])
|
||||
const info = yield* createPty("/usr/bin/env", ["sh", "-c", "exit 3"])
|
||||
|
||||
expect(yield* waitForEvents(events, info.id, 3)).toEqual(["created", "exited", "deleted"])
|
||||
expect(yield* waitForEvents(events, info.id, 2)).toEqual(["created", "exited"])
|
||||
const exited = yield* pty.get(info.id)
|
||||
expect(exited.status).toBe("exited")
|
||||
expect(exited.exitCode).toBe(3)
|
||||
|
||||
yield* pty.remove(info.id)
|
||||
expect(yield* waitForEvents(events, info.id, 1)).toEqual(["deleted"])
|
||||
const missing = yield* pty.get(info.id).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(missing)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
ptyTest("replays buffered output and streams live output to attachments", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const info = yield* createPty("cat")
|
||||
yield* pty.write(info.id, "AAA\n")
|
||||
|
||||
const first = yield* attachCollecting(info.id)
|
||||
expect(yield* waitForOutput(first.output, "AAA")).toContain("AAA")
|
||||
|
||||
first.attachment.write("BBB\n")
|
||||
yield* waitForOutput(first.output, "BBB")
|
||||
|
||||
// A later attachment replays everything already buffered.
|
||||
const replayed = yield* attachCollecting(info.id)
|
||||
expect(replayed.attachment.replay).toContain("AAA")
|
||||
expect(replayed.attachment.replay).toContain("BBB")
|
||||
expect(replayed.attachment.cursor).toBeGreaterThan(0)
|
||||
|
||||
// Tail attachments skip the buffer and only see subsequent output.
|
||||
const tail = yield* attachCollecting(info.id, -1)
|
||||
expect(tail.attachment.replay).toBe("")
|
||||
expect(tail.attachment.cursor).toBe(replayed.attachment.cursor)
|
||||
}),
|
||||
)
|
||||
|
||||
ptyTest("stops delivering output after detach", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const info = yield* createPty("cat")
|
||||
const attached = yield* attachCollecting(info.id, -1)
|
||||
|
||||
attached.attachment.detach()
|
||||
yield* pty.write(info.id, "AAA\n")
|
||||
|
||||
const verify = yield* attachCollecting(info.id)
|
||||
yield* waitForOutput(verify.output, "AAA")
|
||||
const leaked = yield* Queue.poll(attached.output)
|
||||
expect(leaked._tag).toBe("None")
|
||||
}),
|
||||
)
|
||||
|
||||
ptyTest("isolates output between sessions", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const a = yield* createPty("cat")
|
||||
const b = yield* createPty("cat")
|
||||
const attachedA = yield* attachCollecting(a.id)
|
||||
const attachedB = yield* attachCollecting(b.id)
|
||||
|
||||
yield* pty.write(a.id, "AAA\n")
|
||||
yield* waitForOutput(attachedA.output, "AAA")
|
||||
|
||||
const leaked = yield* Queue.poll(attachedB.output)
|
||||
expect(leaked._tag).toBe("None")
|
||||
}),
|
||||
)
|
||||
|
||||
ptyTest("notifies attachments with the exit code and rejects attach after exit", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const events = yield* subscribePtyEvents()
|
||||
const info = yield* createPty("cat")
|
||||
const attached = yield* attachCollecting(info.id)
|
||||
|
||||
yield* pty.write(info.id, "\u0004")
|
||||
expect(yield* Deferred.await(attached.ended).pipe(Effect.timeout("5 seconds"))).toEqual({ exitCode: 0 })
|
||||
yield* waitForEvents(events, info.id, 2)
|
||||
|
||||
const result = yield* pty.attach(info.id, { onData: () => {}, onEnd: () => {} }).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
if (Exit.isFailure(result))
|
||||
expect(Cause.squash(result.cause)).toMatchObject({ _tag: "Pty.ExitedError", ptyID: info.id })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash")
|
||||
const configuredIt = testEffect(
|
||||
Pty.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.mock(Config.Service)({
|
||||
entries: () =>
|
||||
Effect.succeed(
|
||||
configuredShell
|
||||
? [new Config.Document({ type: "document", info: new Config.Info({ shell: configuredShell }) })]
|
||||
: [],
|
||||
),
|
||||
}),
|
||||
),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(locationLayer),
|
||||
),
|
||||
)
|
||||
const configuredTest = process.platform === "win32" ? configuredIt.live.skip : configuredIt.live
|
||||
|
||||
describe("pty create defaults", () => {
|
||||
configuredTest("defaults command, login args, and cwd from config and location", () =>
|
||||
Effect.gen(function* () {
|
||||
if (!configuredShell) return
|
||||
const pty = yield* Pty.Service
|
||||
const info = yield* Effect.acquireRelease(pty.create({ title: "configured" }), (created) =>
|
||||
pty.remove(created.id).pipe(Effect.ignore),
|
||||
)
|
||||
expect(info.command).toBe(configuredShell)
|
||||
expect(info.args).toEqual(["-l"])
|
||||
expect(info.cwd).toBe("/tmp")
|
||||
expect(info.title).toBe("configured")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -3,6 +3,8 @@ import { LLM } from "@opencode-ai/llm"
|
||||
import { LLMClient } from "@opencode-ai/llm/route"
|
||||
import { ConfigProvider, DateTime, Effect } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
@@ -45,8 +47,6 @@ const provider = (api: ProviderV2.Info["api"]) =>
|
||||
new ProviderV2.Info({
|
||||
id: ProviderV2.ID.make("test-provider"),
|
||||
name: "Test provider",
|
||||
enabled: { via: "env", name: "TEST_PROVIDER_API_KEY" },
|
||||
env: ["TEST_PROVIDER_API_KEY"],
|
||||
api,
|
||||
request: { headers: {}, body: {} },
|
||||
})
|
||||
@@ -247,7 +247,7 @@ describe("SessionRunnerModel", () => {
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { headers: {}, body: {}, generation: {}, options: {} },
|
||||
}),
|
||||
provider({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
{ type: "env", name: "TEST_PROVIDER_API_KEY" },
|
||||
)
|
||||
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||
const headers = yield* resolved.route.auth
|
||||
@@ -266,6 +266,35 @@ describe("SessionRunnerModel", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prefers stored credentials over configured auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const credential = new Credential.Stored({
|
||||
id: Credential.ID.create(),
|
||||
integrationID: Integration.ID.make("test-provider"),
|
||||
label: "Work",
|
||||
value: new Credential.Key({ type: "key", key: "stored-secret", metadata: { tenant: "work" } }),
|
||||
})
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
new ModelV2.Info({
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { headers: {}, body: { apiKey: "configured-secret" }, generation: {}, options: {} },
|
||||
}),
|
||||
{ type: "credential", id: credential.id, label: credential.label },
|
||||
credential,
|
||||
)
|
||||
const headers = yield* resolved.route.auth.apply({
|
||||
request: LLM.request({ model: resolved, prompt: "Hello" }),
|
||||
method: "POST",
|
||||
url: "https://openai.example/v1/responses",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
})
|
||||
|
||||
expect(headers.authorization).toBe("Bearer stored-secret")
|
||||
expect(resolved.route.defaults.http?.body).toEqual({ tenant: "work" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects catalog APIs without a native route", () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* SessionRunnerModel.fromCatalogModel(
|
||||
|
||||
@@ -704,7 +704,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* events.publish(SessionEvent.Moved, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }), // kilocode_change
|
||||
})
|
||||
expect(
|
||||
yield* db
|
||||
@@ -762,7 +762,7 @@ describe("SessionRunnerLLM", () => {
|
||||
.publish(SessionEvent.Moved, {
|
||||
sessionID,
|
||||
timestamp: DateTime.makeUnsafe(1),
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }), // kilocode_change
|
||||
})
|
||||
.pipe(Effect.asVoid)
|
||||
})
|
||||
@@ -1461,6 +1461,7 @@ describe("SessionRunnerLLM", () => {
|
||||
})
|
||||
|
||||
requests.length = 0
|
||||
executions.length = 0
|
||||
responses = [
|
||||
fragmentFixture("text", "text-summary-2", ["## Goal\n- Preserve the updated task"]).completeEvents,
|
||||
fragmentFixture("text", "text-final-2", ["Continued again"]).completeEvents,
|
||||
@@ -3177,7 +3178,7 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails after the bounded number of local tool continuation steps", () =>
|
||||
it.effect("continues past 25 local tool steps when the agent has no step limit", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
@@ -3188,62 +3189,10 @@ describe("SessionRunnerLLM", () => {
|
||||
executions.length = 0
|
||||
streamGate = undefined
|
||||
streamStarted = undefined
|
||||
responses = Array.from({ length: 25 }, (_, index) => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
])
|
||||
|
||||
const failure = yield* session.resume(sessionID).pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({ _tag: "SessionRunner.StepLimitExceededError", sessionID, limit: 25 })
|
||||
expect(requests).toHaveLength(25)
|
||||
expect(executions).toHaveLength(25)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not restart a capped tool loop for a coalesced stale wake", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const coordinator = yield* SessionRunCoordinator.Service
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Loop forever" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = Array.from({ length: 25 }, (_, index) => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: `call-capped-${index}`, name: "echo", input: { text: `${index}` } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
])
|
||||
streamGate = yield* Deferred.make<void>()
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
yield* coordinator.wake(sessionID)
|
||||
yield* Deferred.succeed(streamGate, undefined)
|
||||
expect(yield* Fiber.join(run).pipe(Effect.flip)).toMatchObject({ _tag: "SessionRunner.StepLimitExceededError" })
|
||||
streamGate = undefined
|
||||
streamStarted = undefined
|
||||
yield* Effect.yieldNow
|
||||
|
||||
expect(requests).toHaveLength(25)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts a terminal response on the final bounded provider turn", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = [
|
||||
...Array.from({ length: 24 }, (_, index) => [
|
||||
...Array.from({ length: 25 }, (_, index) => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: `call-terminal-${index}`, name: "echo", input: { text: `${index}` } }),
|
||||
LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
]),
|
||||
@@ -3256,7 +3205,56 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(25)
|
||||
expect(requests).toHaveLength(26)
|
||||
expect(executions).toHaveLength(25)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forces a text response on an agent's configured final step", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const agents = yield* AgentV2.Service
|
||||
yield* agents.update((editor) =>
|
||||
editor.update(AgentV2.ID.make("build"), (agent) => {
|
||||
agent.steps = 2
|
||||
}),
|
||||
)
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
executions.length = 0
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-terminal", name: "echo", input: { text: "done" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-forbidden", name: "echo", input: { text: "forbidden" } }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
]
|
||||
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[0]?.toolChoice).toBeUndefined()
|
||||
expect(requests[1]?.toolChoice).toMatchObject({ type: "none" })
|
||||
expect(requests[1]?.tools).toEqual([])
|
||||
expect(requests[1]?.messages.at(-1)).toMatchObject({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }],
|
||||
})
|
||||
expect(executions).toEqual(["done"])
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Finish at the limit" },
|
||||
{ type: "assistant", content: [{ type: "tool", id: "call-terminal", state: { status: "completed" } }] },
|
||||
{ type: "assistant", content: [{ type: "tool", id: "call-forbidden", state: { status: "error" } }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Shell } from "../../src/shell/shell"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { which } from "@opencode-ai/core/util/which"
|
||||
|
||||
const withShell = async (shell: string | undefined, fn: () => void | Promise<void>) => {
|
||||
@@ -54,6 +54,15 @@ describe("shell", () => {
|
||||
expect(Shell.name(Shell.acceptable("nu"))).not.toBe("nu")
|
||||
})
|
||||
|
||||
test("builds command args per shell family", () => {
|
||||
expect(Shell.args("/bin/sh", "echo hi", "/tmp")).toEqual(["-c", "echo hi"])
|
||||
expect(Shell.args("/usr/bin/fish", "echo hi", "/tmp")).toEqual(["-c", "echo hi"])
|
||||
const zsh = Shell.args("/bin/zsh", "echo hi", "/tmp")
|
||||
expect(zsh[0]).toBe("-l")
|
||||
expect(zsh[1]).toBe("-c")
|
||||
expect(zsh.at(-1)).toBe("/tmp")
|
||||
})
|
||||
|
||||
if (process.platform === "win32") {
|
||||
test("rejects blacklisted shells case-insensitively", async () => {
|
||||
await withShell("NU.EXE", async () => {
|
||||
@@ -64,7 +73,7 @@ describe("shell", () => {
|
||||
test("normalizes Git Bash shell paths from env", async () => {
|
||||
const shell = "/cygdrive/c/Program Files/Git/bin/bash.exe"
|
||||
await withShell(shell, async () => {
|
||||
expect(Shell.preferred()).toBe(Filesystem.windowsPath(shell))
|
||||
expect(Shell.preferred()).toBe(FSUtil.windowsPath(shell))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -28,10 +28,10 @@
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"build": "bun ./script/build.ts",
|
||||
"verify:package": "bun ./script/verify-package.ts"
|
||||
"verify:package": "bun ./script/verify-package.ts",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/* This file is auto-generated by SST. Do not edit. */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
/* biome-ignore-all lint: auto-generated */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||
@@ -60,6 +60,9 @@ exclude = [
|
||||
# Consistently times out in CI
|
||||
'^https?://opncd\.ai',
|
||||
'^https?://zod\.dev/v4/changelog',
|
||||
# Moonshot sites consistently time out from GitHub Actions runners.
|
||||
'^https?://(www\.)?moonshot\.cn/?$',
|
||||
'^https?://platform\.moonshot\.cn/?$',
|
||||
# Example punycode domain used in homograph attack documentation — does not exist
|
||||
'^https?://xn--pitest',
|
||||
# OpenAI docs return 404 to plain GET link checks but resolve in browsers
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user