@tavaressan/vetor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/bin/vetor.js +6 -0
- package/lib/banner.js +35 -0
- package/lib/commands/install.js +71 -0
- package/lib/commands/status.js +59 -0
- package/lib/commands/uninstall.js +119 -0
- package/lib/commands/update.js +63 -0
- package/lib/installer/command-exists.js +30 -0
- package/lib/installer/cursor-hooks.js +181 -0
- package/lib/installer/detector.js +79 -0
- package/lib/installer/manifest.js +76 -0
- package/lib/installer/prompts.js +97 -0
- package/lib/installer/writer.js +382 -0
- package/lib/router.js +50 -0
- package/package.json +39 -0
- package/templates/.gitkeep +0 -0
- package/templates/agents/code-review/agent.json +27 -0
- package/templates/agents/code-review/codex.toml +37 -0
- package/templates/agents/code-review.md +99 -0
- package/templates/agents/issue-worker/agent.json +33 -0
- package/templates/agents/issue-worker/codex.toml +57 -0
- package/templates/agents/issue-worker.md +112 -0
- package/templates/hooks/hooks-codex.json +48 -0
- package/templates/hooks/hooks.json +62 -0
- package/templates/opencode/agent/code-review.md +73 -0
- package/templates/opencode/agent/issue-coordinator.md +521 -0
- package/templates/opencode/agent/issue-worker.md +64 -0
- package/templates/opencode/mcp.jsonc +39 -0
- package/templates/opencode/plugin/vetor.ts +207 -0
- package/templates/opencode/scripts/agent-registration_test.ts +92 -0
- package/templates/opencode/scripts/check-edit.ts +147 -0
- package/templates/opencode/scripts/ensure-external-directory-permission.ts +110 -0
- package/templates/opencode/scripts/ensure-external-directory-permission_test.ts +142 -0
- package/templates/opencode/scripts/lib/guard.ts +45 -0
- package/templates/opencode/scripts/lib/model-health.ts +133 -0
- package/templates/opencode/scripts/lib/model-health_test.ts +181 -0
- package/templates/opencode/scripts/lib/project.ts +240 -0
- package/templates/opencode/scripts/lib/project_test.ts +45 -0
- package/templates/opencode/scripts/lib/status.ts +69 -0
- package/templates/opencode/scripts/lib/worktree.ts +41 -0
- package/templates/opencode/scripts/model-health.ts +50 -0
- package/templates/opencode/scripts/model-health_test.ts +80 -0
- package/templates/opencode/scripts/resolve-model.ts +112 -0
- package/templates/opencode/scripts/resolve-model_test.ts +185 -0
- package/templates/opencode/scripts/safety-check.ts +203 -0
- package/templates/opencode/scripts/vetor-checks.sh +217 -0
- package/templates/opencode/scripts/vetor-status.sh +99 -0
- package/templates/skills/architecture-review/SKILL.md +187 -0
- package/templates/skills/backlog-ideator/SKILL.md +277 -0
- package/templates/skills/design/SKILL.md +468 -0
- package/templates/skills/design/examples/design-contract-example.md +46 -0
- package/templates/skills/design/examples/prototype-handoff-example.md +142 -0
- package/templates/skills/fix-loop-agent/SKILL.md +255 -0
- package/templates/skills/guardian/SKILL.md +343 -0
- package/templates/skills/issue-coordinator/SKILL.md +596 -0
- package/templates/skills/retro/SKILL.md +156 -0
- package/templates/skills/shared/references/agent-status.template.md +68 -0
- package/templates/skills/shared/references/codebase-design-vocabulary.md +54 -0
- package/templates/skills/shared/references/conflict-resolution.md +94 -0
- package/templates/skills/shared/references/delegate-to-runtime.md +239 -0
- package/templates/skills/shared/references/design-vocabulary.md +508 -0
- package/templates/skills/shared/references/evidence-state.md +365 -0
- package/templates/skills/shared/references/frontend-design-enforcement.md +33 -0
- package/templates/skills/shared/references/grilling-conventions.md +64 -0
- package/templates/skills/shared/references/knowledge-provider-contract.md +150 -0
- package/templates/skills/shared/references/mcp-availability.md +104 -0
- package/templates/skills/shared/references/module-test-map.template.md +72 -0
- package/templates/skills/shared/references/planning-conventions.md +97 -0
- package/templates/skills/shared/references/project-conventions.md +63 -0
- package/templates/skills/shared/references/tdd-conventions.md +81 -0
- package/templates/skills/shared/references/touched-files-cache.md +30 -0
- package/templates/skills/spec/SKILL.md +524 -0
- package/templates/skills/spec-validate/SKILL.md +195 -0
- package/templates/skills/spec-validate/references/traceability.md +169 -0
- package/templates/skills/stack-practices/SKILL.md +151 -0
- package/templates/skills/vetor/SKILL.md +174 -0
- package/templates/skills/worktree-create/SKILL.md +142 -0
- package/templates/skills/worktree-ship/SKILL.md +394 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Reprodução da issue #313: `issue-worker`/`code-review` rodam com `--dir <worktree>`, mas
|
|
2
|
+
// `config.json`/`module-test-map.md`/status file vivem em `<repo-root>/.claude/vetor/`, fora da
|
|
3
|
+
// árvore do worktree. O OpenCode trata esse acesso como `external_directory`, default `"*": "ask"`
|
|
4
|
+
// — em `opencode run` não-interativo (sem TTY) isso auto-rejeita em vez de bloquear esperando
|
|
5
|
+
// input. Este CLI garante, de forma idempotente, uma regra `permission.external_directory` em
|
|
6
|
+
// `<repo-root>/opencode.json` apontando para `<repo-root>/.claude/vetor/**`, para que o
|
|
7
|
+
// issue-coordinator a gere uma única vez (Fase 3) antes do primeiro `opencode run --dir` da sessão.
|
|
8
|
+
|
|
9
|
+
import { assertEquals } from "@std/assert";
|
|
10
|
+
|
|
11
|
+
const SCRIPT = new URL("./ensure-external-directory-permission.ts", import.meta.url).pathname;
|
|
12
|
+
|
|
13
|
+
async function git(args: string[], cwd: string): Promise<void> {
|
|
14
|
+
await new Deno.Command("git", { args, cwd, stdout: "null", stderr: "null" }).output();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function makeRepo(): Promise<string> {
|
|
18
|
+
const dir = await Deno.makeTempDir();
|
|
19
|
+
await git(["init", "-q", "-b", "main"], dir);
|
|
20
|
+
await git(["config", "user.email", "test@example.com"], dir);
|
|
21
|
+
await git(["config", "user.name", "Test"], dir);
|
|
22
|
+
await git(["commit", "-q", "--allow-empty", "-m", "init"], dir);
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function runCli(
|
|
27
|
+
input: unknown,
|
|
28
|
+
): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
29
|
+
const command = new Deno.Command("deno", {
|
|
30
|
+
args: ["run", "-A", SCRIPT],
|
|
31
|
+
stdin: "piped",
|
|
32
|
+
stdout: "piped",
|
|
33
|
+
stderr: "piped",
|
|
34
|
+
});
|
|
35
|
+
const child = command.spawn();
|
|
36
|
+
const writer = child.stdin.getWriter();
|
|
37
|
+
await writer.write(new TextEncoder().encode(JSON.stringify(input)));
|
|
38
|
+
await writer.close();
|
|
39
|
+
const out = await child.output();
|
|
40
|
+
return {
|
|
41
|
+
code: out.code,
|
|
42
|
+
stdout: new TextDecoder().decode(out.stdout).trim(),
|
|
43
|
+
stderr: new TextDecoder().decode(out.stderr).trim(),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function readOpencodeJson(repo: string): Promise<Record<string, unknown>> {
|
|
48
|
+
return JSON.parse(await Deno.readTextFile(`${repo}/opencode.json`));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `Deno.makeTempDir()` devolve path nativo (com `\` no Windows), mas o script resolve o root via
|
|
53
|
+
* `git rev-parse --path-format=absolute`, que normaliza para `/` — mesma convenção já usada por
|
|
54
|
+
* `resolveWorktree()`/`resolve-model.ts`. Normaliza aqui só para montar a chave esperada no teste.
|
|
55
|
+
*/
|
|
56
|
+
function toForwardSlashes(path: string): string {
|
|
57
|
+
return path.replaceAll("\\", "/");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
Deno.test("cria opencode.json com a regra external_directory quando o arquivo não existe", async () => {
|
|
61
|
+
const repo = await makeRepo();
|
|
62
|
+
|
|
63
|
+
const result = await runCli({ cwd: repo });
|
|
64
|
+
|
|
65
|
+
assertEquals(result.code, 0);
|
|
66
|
+
const config = await readOpencodeJson(repo);
|
|
67
|
+
const rule = (config.permission as { external_directory?: Record<string, string> })
|
|
68
|
+
.external_directory;
|
|
69
|
+
assertEquals(rule?.[`${toForwardSlashes(repo)}/.claude/vetor/**`], "allow");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
Deno.test("mescla a regra num opencode.json existente sem apagar outras chaves", async () => {
|
|
73
|
+
const repo = await makeRepo();
|
|
74
|
+
await Deno.writeTextFile(
|
|
75
|
+
`${repo}/opencode.json`,
|
|
76
|
+
JSON.stringify({
|
|
77
|
+
"$schema": "https://opencode.ai/config.json",
|
|
78
|
+
mcp: { docker: { type: "local", command: ["docker-mcp"] } },
|
|
79
|
+
permission: { external_directory: { "/algum/outro/path/**": "ask" } },
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const result = await runCli({ cwd: repo });
|
|
84
|
+
|
|
85
|
+
assertEquals(result.code, 0);
|
|
86
|
+
const config = await readOpencodeJson(repo);
|
|
87
|
+
assertEquals((config.mcp as Record<string, unknown>).docker !== undefined, true);
|
|
88
|
+
const rule = (config.permission as { external_directory?: Record<string, string> })
|
|
89
|
+
.external_directory;
|
|
90
|
+
assertEquals(rule?.[`${toForwardSlashes(repo)}/.claude/vetor/**`], "allow");
|
|
91
|
+
assertEquals(rule?.["/algum/outro/path/**"], "ask");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
Deno.test("idempotente: segunda chamada não altera o conteúdo já correto", async () => {
|
|
95
|
+
const repo = await makeRepo();
|
|
96
|
+
|
|
97
|
+
const first = await runCli({ cwd: repo });
|
|
98
|
+
assertEquals(first.code, 0);
|
|
99
|
+
const afterFirst = await Deno.readTextFile(`${repo}/opencode.json`);
|
|
100
|
+
|
|
101
|
+
const second = await runCli({ cwd: repo });
|
|
102
|
+
assertEquals(second.code, 0);
|
|
103
|
+
const afterSecond = await Deno.readTextFile(`${repo}/opencode.json`);
|
|
104
|
+
|
|
105
|
+
assertEquals(afterSecond, afterFirst);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
Deno.test("não sobrescreve opencode.json existente e ilegível — falha com mensagem acionável", async () => {
|
|
109
|
+
const repo = await makeRepo();
|
|
110
|
+
await Deno.writeTextFile(`${repo}/opencode.json`, "{ isto não é json válido");
|
|
111
|
+
|
|
112
|
+
const result = await runCli({ cwd: repo });
|
|
113
|
+
|
|
114
|
+
assertEquals(result.code, 1);
|
|
115
|
+
const raw = await Deno.readTextFile(`${repo}/opencode.json`);
|
|
116
|
+
assertEquals(raw, "{ isto não é json válido");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Issue #307 (mesma classe já corrigida em resolve-model.ts): no Git Bash do Windows, `$(pwd)`
|
|
120
|
+
// produz um path POSIX-style (`/c/Users/...`), não nativo — precisa resolver o mesmo repo real.
|
|
121
|
+
function toPosixPath(nativePath: string): string {
|
|
122
|
+
return nativePath.replace(/^([A-Za-z]):\\/, (_, d) => `/${d.toLowerCase()}/`).replaceAll(
|
|
123
|
+
"\\",
|
|
124
|
+
"/",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
Deno.test({
|
|
129
|
+
name: "resolve o root real mesmo com cwd POSIX-style (Git Bash Windows)",
|
|
130
|
+
ignore: Deno.build.os !== "windows",
|
|
131
|
+
fn: async () => {
|
|
132
|
+
const repo = await makeRepo();
|
|
133
|
+
|
|
134
|
+
const result = await runCli({ cwd: toPosixPath(repo) });
|
|
135
|
+
|
|
136
|
+
assertEquals(result.code, 0);
|
|
137
|
+
const config = await readOpencodeJson(repo);
|
|
138
|
+
const rule = (config.permission as { external_directory?: Record<string, string> })
|
|
139
|
+
.external_directory;
|
|
140
|
+
assertEquals(rule?.[`${toForwardSlashes(repo)}/.claude/vetor/**`], "allow");
|
|
141
|
+
},
|
|
142
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Guarda de escrita dos workers: dentro de um worktree, só se escreve no próprio worktree.
|
|
2
|
+
//
|
|
3
|
+
// Com até 5 workers em paralelo, um Edit/Write apontando para fora do worktree contamina
|
|
4
|
+
// o trabalho dos outros. A única exceção é o status file, escrito na raiz de propósito
|
|
5
|
+
// (é por ele que o issue-coordinator agrega o andamento).
|
|
6
|
+
|
|
7
|
+
/** Resolve `.`/`..` e uniformiza separadores. Não toca no disco: o alvo pode não existir. */
|
|
8
|
+
export function normalizePath(path: string): string {
|
|
9
|
+
const segments: string[] = [];
|
|
10
|
+
const raw = path.replaceAll("\\", "/");
|
|
11
|
+
|
|
12
|
+
for (const segment of raw.split("/")) {
|
|
13
|
+
if (segment === "" || segment === ".") continue;
|
|
14
|
+
if (segment === ".." && segments.length > 0 && segments.at(-1) !== "..") {
|
|
15
|
+
segments.pop();
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
segments.push(segment);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const prefix = raw.startsWith("/") ? "/" : "";
|
|
22
|
+
const joined = prefix + segments.join("/");
|
|
23
|
+
// Windows: comparação de path é case-insensitive.
|
|
24
|
+
return Deno.build.os === "windows" ? joined.toLowerCase() : joined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isWithin(child: string, parent: string): boolean {
|
|
28
|
+
const c = normalizePath(child);
|
|
29
|
+
const p = normalizePath(parent);
|
|
30
|
+
return c === p || c.startsWith(p.endsWith("/") ? p : `${p}/`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `worktree` é o cwd do agente; `root`, a raiz do repositório principal.
|
|
35
|
+
* Fora de um worktree linkado o guard não se aplica — quem chama decide isso.
|
|
36
|
+
*/
|
|
37
|
+
export function isWriteAllowed(target: string, worktree: string, root: string): boolean {
|
|
38
|
+
if (isWithin(target, worktree)) return true;
|
|
39
|
+
|
|
40
|
+
const statusDir = normalizePath(`${root}/.claude/vetor/status`);
|
|
41
|
+
const normalizedTarget = normalizePath(target);
|
|
42
|
+
const fileName = normalizedTarget.slice(statusDir.length + 1);
|
|
43
|
+
return normalizedTarget.startsWith(`${statusDir}/`) && !fileName.includes("/") &&
|
|
44
|
+
fileName.endsWith(".md");
|
|
45
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Persistência de saúde de modelo/provedor entre processos separados do OpenCode (issue #83).
|
|
2
|
+
//
|
|
3
|
+
// Cada worker `opencode run --dir` é um processo do SO distinto — não há estado compartilhado
|
|
4
|
+
// em memória entre eles nem com o `issue-coordinator`. Por isso o sinal de rate-limit/quota
|
|
5
|
+
// detectado pelo hook `event` (opencode/plugin/vetor.ts, escutando `session.error`) precisa ser
|
|
6
|
+
// gravado em disco, em `.claude/vetor/status/model-health.json`, na raiz do repositório
|
|
7
|
+
// principal — fora de qualquer worktree, mesmo local onde já vivem os status files dos workers
|
|
8
|
+
// (ver scripts/lib/status.ts).
|
|
9
|
+
//
|
|
10
|
+
// Payload do evento confirmado contra @opencode-ai/sdk v1.18.4 instalado
|
|
11
|
+
// (dist/gen/types.gen.d.ts:86 `ApiError`, :518 `EventSessionError`) em 2026-07-21:
|
|
12
|
+
// ApiError.data = { message, statusCode?, isRetryable, responseHeaders?, responseBody? }
|
|
13
|
+
// `EventSessionError.properties` só carrega `sessionID`+`error` — sem provider/model. Por isso
|
|
14
|
+
// o plugin correlaciona sessionID -> "<providerID>/<modelID>" via `chat.params` (que recebe
|
|
15
|
+
// `model: Model` com `providerID`/`id`) antes do erro chegar.
|
|
16
|
+
|
|
17
|
+
/** Só 429 (rate limit) e 529 (overloaded, comum em provedores como Anthropic) contam como
|
|
18
|
+
* degradação reativa — outros 4xx/5xx (auth, bad request, erro de servidor genérico) não
|
|
19
|
+
* implicam necessariamente indisponibilidade do modelo em si. */
|
|
20
|
+
const RATE_LIMIT_OR_QUOTA_STATUS_CODES = new Set([429, 529]);
|
|
21
|
+
|
|
22
|
+
export function isRateLimitOrQuotaStatus(statusCode: number | undefined): boolean {
|
|
23
|
+
return statusCode !== undefined && RATE_LIMIT_OR_QUOTA_STATUS_CODES.has(statusCode);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Sem `retry-after` do provedor, backoff fixo curto — revisitado no próximo erro se persistir. */
|
|
27
|
+
export const DEFAULT_BACKOFF_MS = 60_000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `retry-after` HTTP aceita segundos (inteiro) ou uma data no formato RFC 7231. Nenhum valor
|
|
31
|
+
* utilizável (ausente, não numérico e não parseável como data) cai no backoff fixo.
|
|
32
|
+
*/
|
|
33
|
+
export function computeUntil(
|
|
34
|
+
now: number,
|
|
35
|
+
responseHeaders: Record<string, string> | undefined,
|
|
36
|
+
): number {
|
|
37
|
+
const raw = responseHeaders &&
|
|
38
|
+
Object.entries(responseHeaders).find(([k]) => k.toLowerCase() === "retry-after")?.[1];
|
|
39
|
+
|
|
40
|
+
if (raw !== undefined) {
|
|
41
|
+
const seconds = Number(raw);
|
|
42
|
+
if (Number.isFinite(seconds) && seconds >= 0) return now + seconds * 1000;
|
|
43
|
+
|
|
44
|
+
const asDate = Date.parse(raw);
|
|
45
|
+
if (!Number.isNaN(asDate)) return asDate;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return now + DEFAULT_BACKOFF_MS;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ModelHealthEntry {
|
|
52
|
+
status: "degraded";
|
|
53
|
+
until: number;
|
|
54
|
+
lastError: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type ModelHealthFile = Record<string, ModelHealthEntry>;
|
|
58
|
+
|
|
59
|
+
const LOCK_RETRY_MS = 10;
|
|
60
|
+
|
|
61
|
+
function exists(path: string): boolean {
|
|
62
|
+
try {
|
|
63
|
+
Deno.statSync(path);
|
|
64
|
+
return true;
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function readModelHealthFile(path: string): ModelHealthFile {
|
|
71
|
+
if (!exists(path)) return {};
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(Deno.readTextFileSync(path));
|
|
74
|
+
return parsed && typeof parsed === "object" ? parsed as ModelHealthFile : {};
|
|
75
|
+
} catch {
|
|
76
|
+
// JSON corrompido/parcial (ex.: escrita concorrente de outro worker): recomeça do zero em
|
|
77
|
+
// vez de travar o registro do novo sinal — perder histórico é preferível a nunca gravar.
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Atualiza (cria ou sobrescreve) a entrada de `key` ("<provider>/<model>") em
|
|
84
|
+
* `.claude/vetor/status/model-health.json` e persiste o arquivo inteiro.
|
|
85
|
+
*/
|
|
86
|
+
export function recordModelHealth(
|
|
87
|
+
path: string,
|
|
88
|
+
key: string,
|
|
89
|
+
entry: ModelHealthEntry,
|
|
90
|
+
): ModelHealthFile {
|
|
91
|
+
const dir = path.slice(0, path.lastIndexOf("/"));
|
|
92
|
+
if (dir) Deno.mkdirSync(dir, { recursive: true });
|
|
93
|
+
|
|
94
|
+
const lockPath = `${path}.lock`;
|
|
95
|
+
while (true) {
|
|
96
|
+
try {
|
|
97
|
+
Deno.openSync(lockPath, { createNew: true, write: true }).close();
|
|
98
|
+
break;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (!(error instanceof Deno.errors.AlreadyExists)) throw error;
|
|
101
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_RETRY_MS);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const file = readModelHealthFile(path);
|
|
107
|
+
file[key] = entry;
|
|
108
|
+
const tempPath = `${path}.${Deno.pid}.${crypto.randomUUID()}.tmp`;
|
|
109
|
+
Deno.writeTextFileSync(tempPath, JSON.stringify(file, null, 2) + "\n");
|
|
110
|
+
Deno.renameSync(tempPath, path);
|
|
111
|
+
return file;
|
|
112
|
+
} finally {
|
|
113
|
+
Deno.removeSync(lockPath);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Entrada ausente ou `until` no passado = saudável. Consumido pelo issue-coordinator (#84). */
|
|
118
|
+
export function isHealthy(entry: ModelHealthEntry | undefined, now: number): boolean {
|
|
119
|
+
return entry === undefined || entry.status !== "degraded" || entry.until <= now;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Percorre `fallback` (lista ordenada de "<provider>/<model>") e devolve o primeiro que estiver
|
|
124
|
+
* saudável em `health`. `null` se todos os modelos da lista estiverem `degraded` e não expirados
|
|
125
|
+
* — o issue-coordinator (issue #84) trata isso como "não despache, mantenha o grupo QUEUED".
|
|
126
|
+
*/
|
|
127
|
+
export function pickHealthyModel(
|
|
128
|
+
fallback: string[],
|
|
129
|
+
health: ModelHealthFile,
|
|
130
|
+
now: number,
|
|
131
|
+
): string | null {
|
|
132
|
+
return fallback.find((model) => isHealthy(health[model], now)) ?? null;
|
|
133
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { assert, assertEquals } from "@std/assert";
|
|
2
|
+
import {
|
|
3
|
+
computeUntil,
|
|
4
|
+
DEFAULT_BACKOFF_MS,
|
|
5
|
+
isHealthy,
|
|
6
|
+
isRateLimitOrQuotaStatus,
|
|
7
|
+
pickHealthyModel,
|
|
8
|
+
recordModelHealth,
|
|
9
|
+
} from "./model-health.ts";
|
|
10
|
+
|
|
11
|
+
Deno.test("isRateLimitOrQuotaStatus - 429 e 529 são rate limit/quota", () => {
|
|
12
|
+
assert(isRateLimitOrQuotaStatus(429));
|
|
13
|
+
assert(isRateLimitOrQuotaStatus(529));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
Deno.test("isRateLimitOrQuotaStatus - outros códigos não são", () => {
|
|
17
|
+
assert(!isRateLimitOrQuotaStatus(500));
|
|
18
|
+
assert(!isRateLimitOrQuotaStatus(401));
|
|
19
|
+
assert(!isRateLimitOrQuotaStatus(undefined));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
Deno.test("computeUntil - deriva de retry-after em segundos", () => {
|
|
23
|
+
const now = 1_000_000;
|
|
24
|
+
const until = computeUntil(now, { "retry-after": "30" });
|
|
25
|
+
assertEquals(until, now + 30_000);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
Deno.test("computeUntil - retry-after é case-insensitive no nome do header", () => {
|
|
29
|
+
const now = 1_000_000;
|
|
30
|
+
const until = computeUntil(now, { "Retry-After": "5" });
|
|
31
|
+
assertEquals(until, now + 5_000);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
Deno.test("computeUntil - deriva de retry-after como data HTTP", () => {
|
|
35
|
+
const now = Date.parse("2026-07-21T00:00:00Z") - 10_000;
|
|
36
|
+
const until = computeUntil(now, { "retry-after": "Tue, 21 Jul 2026 00:00:00 GMT" });
|
|
37
|
+
assertEquals(until, Date.parse("2026-07-21T00:00:00Z"));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
Deno.test("computeUntil - sem retry-after cai no backoff fixo", () => {
|
|
41
|
+
const now = 1_000_000;
|
|
42
|
+
assertEquals(computeUntil(now, undefined), now + DEFAULT_BACKOFF_MS);
|
|
43
|
+
assertEquals(computeUntil(now, {}), now + DEFAULT_BACKOFF_MS);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
Deno.test("computeUntil - retry-after inválido cai no backoff fixo", () => {
|
|
47
|
+
const now = 1_000_000;
|
|
48
|
+
assertEquals(
|
|
49
|
+
computeUntil(now, { "retry-after": "não-é-nem-número-nem-data" }),
|
|
50
|
+
now + DEFAULT_BACKOFF_MS,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
Deno.test("recordModelHealth - cria model-health.json com a entrada degraded", async () => {
|
|
55
|
+
const dir = await Deno.makeTempDir();
|
|
56
|
+
const path = `${dir}/.claude/vetor/status/model-health.json`;
|
|
57
|
+
|
|
58
|
+
const file = recordModelHealth(path, "anthropic/claude-sonnet-4-5", {
|
|
59
|
+
status: "degraded",
|
|
60
|
+
until: 2_000_000,
|
|
61
|
+
lastError: "HTTP 429: rate limited",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
assertEquals(file["anthropic/claude-sonnet-4-5"].status, "degraded");
|
|
65
|
+
assertEquals(file["anthropic/claude-sonnet-4-5"].until, 2_000_000);
|
|
66
|
+
|
|
67
|
+
const onDisk = JSON.parse(await Deno.readTextFile(path));
|
|
68
|
+
assertEquals(onDisk["anthropic/claude-sonnet-4-5"].until, 2_000_000);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
Deno.test("recordModelHealth - atualiza entrada existente sem apagar outras chaves", async () => {
|
|
72
|
+
const dir = await Deno.makeTempDir();
|
|
73
|
+
const path = `${dir}/.claude/vetor/status/model-health.json`;
|
|
74
|
+
|
|
75
|
+
recordModelHealth(path, "openai/gpt-5", { status: "degraded", until: 1, lastError: "a" });
|
|
76
|
+
const file = recordModelHealth(path, "anthropic/claude-haiku-4-5", {
|
|
77
|
+
status: "degraded",
|
|
78
|
+
until: 2,
|
|
79
|
+
lastError: "b",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
assertEquals(Object.keys(file).sort(), ["anthropic/claude-haiku-4-5", "openai/gpt-5"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
Deno.test("recordModelHealth - processos concorrentes preservam ambas as entradas", async () => {
|
|
86
|
+
const dir = await Deno.makeTempDir();
|
|
87
|
+
const path = `${dir}/.claude/vetor/status/model-health.json`;
|
|
88
|
+
const gate = `${dir}/start`;
|
|
89
|
+
const moduleUrl = new URL("./model-health.ts", import.meta.url).href;
|
|
90
|
+
const run = (key: string) =>
|
|
91
|
+
new Deno.Command(Deno.execPath(), {
|
|
92
|
+
args: [
|
|
93
|
+
"eval",
|
|
94
|
+
`import { recordModelHealth } from ${JSON.stringify(moduleUrl)};
|
|
95
|
+
while (!await Deno.stat(${JSON.stringify(gate)}).then(() => true).catch(() => false)) {
|
|
96
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
97
|
+
}
|
|
98
|
+
recordModelHealth(${JSON.stringify(path)}, ${JSON.stringify(key)}, {
|
|
99
|
+
status: "degraded", until: 2_000_000, lastError: "HTTP 429"
|
|
100
|
+
});`,
|
|
101
|
+
],
|
|
102
|
+
}).output();
|
|
103
|
+
|
|
104
|
+
const first = run("anthropic/claude-sonnet-4-5");
|
|
105
|
+
const second = run("openai/gpt-5");
|
|
106
|
+
await Deno.writeTextFile(gate, "go");
|
|
107
|
+
const results = await Promise.all([first, second]);
|
|
108
|
+
|
|
109
|
+
assert(
|
|
110
|
+
results.every((result) => result.success),
|
|
111
|
+
results.map((result) => new TextDecoder().decode(result.stderr)).join("\n"),
|
|
112
|
+
);
|
|
113
|
+
assertEquals(Object.keys(JSON.parse(await Deno.readTextFile(path))).sort(), [
|
|
114
|
+
"anthropic/claude-sonnet-4-5",
|
|
115
|
+
"openai/gpt-5",
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
Deno.test("isHealthy - entrada ausente é saudável", () => {
|
|
120
|
+
assert(isHealthy(undefined, Date.now()));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
Deno.test("isHealthy - entrada com until no futuro é degraded", () => {
|
|
124
|
+
const now = 1_000_000;
|
|
125
|
+
assert(!isHealthy({ status: "degraded", until: now + 5_000, lastError: "x" }, now));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
Deno.test("isHealthy - entrada com until no passado é saudável (expirada)", () => {
|
|
129
|
+
const now = 1_000_000;
|
|
130
|
+
assert(isHealthy({ status: "degraded", until: now - 5_000, lastError: "x" }, now));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
Deno.test("pickHealthyModel - escolhe o primeiro saudável da lista", () => {
|
|
134
|
+
const now = 1_000_000;
|
|
135
|
+
const fallback = ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-4-5"];
|
|
136
|
+
assertEquals(pickHealthyModel(fallback, {}, now), "anthropic/claude-haiku-4-5");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
Deno.test("pickHealthyModel - modelo preferencial degraded e não expirado cai para o próximo", () => {
|
|
140
|
+
const now = 1_000_000;
|
|
141
|
+
const fallback = ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-4-5"];
|
|
142
|
+
const health = {
|
|
143
|
+
"anthropic/claude-haiku-4-5": {
|
|
144
|
+
status: "degraded" as const,
|
|
145
|
+
until: now + 60_000,
|
|
146
|
+
lastError: "HTTP 429",
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
assertEquals(pickHealthyModel(fallback, health, now), "anthropic/claude-sonnet-4-5");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
Deno.test("pickHealthyModel - modelo preferencial degraded mas expirado é escolhido de novo", () => {
|
|
153
|
+
const now = 1_000_000;
|
|
154
|
+
const fallback = ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-4-5"];
|
|
155
|
+
const health = {
|
|
156
|
+
"anthropic/claude-haiku-4-5": {
|
|
157
|
+
status: "degraded" as const,
|
|
158
|
+
until: now - 1_000,
|
|
159
|
+
lastError: "HTTP 429",
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
assertEquals(pickHealthyModel(fallback, health, now), "anthropic/claude-haiku-4-5");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
Deno.test("pickHealthyModel - todos degraded devolve null", () => {
|
|
166
|
+
const now = 1_000_000;
|
|
167
|
+
const fallback = ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-4-5"];
|
|
168
|
+
const health = {
|
|
169
|
+
"anthropic/claude-haiku-4-5": {
|
|
170
|
+
status: "degraded" as const,
|
|
171
|
+
until: now + 60_000,
|
|
172
|
+
lastError: "HTTP 429",
|
|
173
|
+
},
|
|
174
|
+
"anthropic/claude-sonnet-4-5": {
|
|
175
|
+
status: "degraded" as const,
|
|
176
|
+
until: now + 60_000,
|
|
177
|
+
lastError: "HTTP 429",
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
assertEquals(pickHealthyModel(fallback, health, now), null);
|
|
181
|
+
});
|