arkaos 5.17.2 → 5.18.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/THE-ARKAOS-GUIDE.md +2 -2
- package/VERSION +1 -1
- package/arka/SKILL.md +2 -1
- package/arka/skills/costs/SKILL.md +16 -0
- package/config/claude-agents/marta-cqo.md +10 -0
- package/config/decisions/corpora/bash-effect.jsonl +38 -0
- package/config/decisions/corpora/creation-intent.jsonl +47 -0
- package/config/decisions/corpora/dispatch-role.jsonl +32 -0
- package/config/decisions/corpora/forge-complexity.jsonl +102 -0
- package/config/decisions/corpora/forge-departments.jsonl +34 -0
- package/config/decisions/corpora/heldout/learning-signal.jsonl +17 -0
- package/config/decisions/corpora/heldout/qg-prescreen.jsonl +20 -0
- package/config/decisions/corpora/heldout/slop-score.jsonl +20 -0
- package/config/decisions/corpora/learning-signal.jsonl +35 -0
- package/config/decisions/corpora/phantom-action.jsonl +34 -0
- package/config/decisions/corpora/qg-prescreen.jsonl +46 -0
- package/config/decisions/corpora/refine.jsonl +104 -0
- package/config/decisions/corpora/route.jsonl +33 -0
- package/config/decisions/corpora/skill-hints.jsonl +34 -0
- package/config/decisions/corpora/skill-proposer.jsonl +32 -0
- package/config/decisions/corpora/slop-score.jsonl +33 -0
- package/config/decisions/corpora/subagent-discipline.jsonl +32 -0
- package/config/decisions/corpora/sycophancy.jsonl +34 -0
- package/config/decisions/corpora/topic-drift.jsonl +34 -0
- package/config/decisions/corpora/ui-in-ts.jsonl +60 -0
- package/config/hooks/gate-manifest.json +1 -0
- package/config/models.yaml +10 -2
- package/core/decisions/__init__.py +8 -0
- package/core/decisions/backoff.py +137 -0
- package/core/decisions/cache.py +92 -0
- package/core/decisions/client.py +191 -0
- package/core/decisions/config.py +159 -0
- package/core/decisions/cost_effect.py +242 -0
- package/core/decisions/engine.py +400 -0
- package/core/decisions/models.py +118 -0
- package/core/decisions/paths.py +39 -0
- package/core/decisions/privacy.py +378 -0
- package/core/decisions/promotion.py +126 -0
- package/core/decisions/registry.py +23 -0
- package/core/decisions/replay.py +852 -0
- package/core/decisions/replay_report.py +413 -0
- package/core/decisions/shadow.py +147 -0
- package/core/decisions/site.py +343 -0
- package/core/decisions/sites/__init__.py +1 -0
- package/core/decisions/sites/command.py +58 -0
- package/core/decisions/sites/dispatch.py +161 -0
- package/core/decisions/sites/forge.py +230 -0
- package/core/decisions/sites/governance.py +361 -0
- package/core/decisions/sites/prompt.py +195 -0
- package/core/decisions/sites/quality.py +281 -0
- package/core/decisions/telemetry.py +335 -0
- package/core/decisions/telemetry_cli.py +149 -0
- package/core/decisions/transport.py +97 -0
- package/core/egress/audit.py +46 -6
- package/core/egress/credentials.py +1296 -0
- package/core/egress/policy.py +7 -4
- package/core/evals/verdict_labels.py +25 -5
- package/core/forge/budget.py +32 -0
- package/core/forge/complexity.py +11 -3
- package/core/forge/jev_sites.py +80 -0
- package/core/forge/orchestrator.py +76 -72
- package/core/governance/aggregate_guard.py +175 -77
- package/core/governance/carry_advisor.py +36 -19
- package/core/governance/evidence_checks.py +134 -55
- package/core/governance/fence_recovery.py +165 -0
- package/core/governance/harness_scanner.py +4 -0
- package/core/governance/jev_advisory.py +154 -0
- package/core/governance/leak_scanner.py +2 -2
- package/core/governance/literal_git.py +66 -0
- package/core/governance/qg_prescreen.py +518 -0
- package/core/governance/qg_tier.py +17 -0
- package/core/governance/reviewer_ledger.py +225 -46
- package/core/governance/skill_proposer.py +38 -13
- package/core/governance/slop_check.py +169 -0
- package/core/hooks/operator_message.py +128 -0
- package/core/hooks/stop.py +418 -38
- package/core/hooks/stop_budget.py +43 -0
- package/core/hooks/subagent_stop.py +39 -9
- package/core/hooks/ups_dispatch.py +243 -0
- package/core/hooks/user_prompt_submit.py +458 -106
- package/core/keys.py +4 -0
- package/core/runtime/model_router.py +38 -0
- package/core/runtime/pricing.py +8 -0
- package/core/synapse/command_menu.py +70 -0
- package/core/synapse/layers.py +108 -66
- package/core/workflow/flow_enforcer.py +71 -1
- package/core/workflow/frontend_gate.py +57 -1
- package/core/workflow/transcript_scope.py +34 -25
- package/departments/quality/SKILL.md +29 -2
- package/harness/codex/AGENTS.md +1 -1
- package/harness/copilot/copilot-instructions.md +1 -1
- package/harness/cursor/rules/arkaos.mdc +2 -2
- package/harness/gemini/GEMINI.md +1 -1
- package/harness/opencode/AGENTS.md +1 -1
- package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
- package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
- package/harness/opencode/agents/arka-cfo-helena.md +1 -1
- package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
- package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
- package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
- package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
- package/harness/opencode/agents/arka-coo-sofia.md +1 -1
- package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
- package/harness/opencode/agents/arka-cqo-marta.md +1 -1
- package/harness/opencode/agents/arka-cto-marco.md +1 -1
- package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
- package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
- package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
- package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
- package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
- package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
- package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
- package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
- package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
- package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
- package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
- package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
- package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
- package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
- package/harness/zed/.rules +1 -1
- package/installer/config-seed.js +71 -1
- package/installer/keys.js +1 -1
- package/installer/update.js +29 -0
- package/knowledge/commands-registry.json +17 -4
- package/knowledge/commands-registry.json.bak +6739 -0
- package/knowledge/skills-manifest.json +1 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/synapse-bridge.py +17 -1
- package/scripts/tools/corpus_merge.py +402 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{"id": "td-pt-01", "lang": "pt", "prompt": "agora prepara o email de lançamento", "prior": ["corrige o bug do login"], "expected": true}
|
|
2
|
+
{"id": "td-pt-02", "lang": "pt", "prompt": "escreve um post para o LinkedIn sobre o nosso novo produto", "prior": ["implementa a paginação na lista de encomendas", "adiciona testes à paginação"], "expected": true}
|
|
3
|
+
{"id": "td-pt-03", "lang": "pt", "prompt": "configura o pipeline de CI no GitHub Actions", "prior": ["revê a proposta comercial para o ClienteY"], "expected": true}
|
|
4
|
+
{"id": "td-pt-04", "lang": "pt", "prompt": "desenha um logótipo para a marca ProjetoX", "prior": ["analisa os custos de tokens desta semana"], "expected": true}
|
|
5
|
+
{"id": "td-pt-05", "lang": "pt", "prompt": "qual é a melhor receita de bacalhau?", "prior": ["migra a base de dados para PostgreSQL 17"], "expected": true}
|
|
6
|
+
{"id": "td-pt-06", "lang": "pt", "prompt": "corrige o erro de TypeScript no componente do checkout", "prior": ["prepara o orçamento do trimestre"], "expected": true}
|
|
7
|
+
{"id": "td-pt-07", "lang": "pt", "prompt": "refatora o serviço de autenticação", "prior": ["escreve o guião do vídeo curto sobre produtividade"], "expected": true}
|
|
8
|
+
{"id": "td-pt-08", "lang": "pt", "prompt": "faz a reconciliação das faturas de agosto", "prior": ["cria a landing page do webinar"], "expected": true}
|
|
9
|
+
{"id": "td-pt-09", "lang": "pt", "prompt": "faz o planeamento financeiro do próximo ano", "prior": ["cria o componente do calendário"], "expected": true}
|
|
10
|
+
{"id": "td-pt-10", "lang": "pt", "prompt": "e agora adiciona um teste para esse caso", "prior": ["corrige o bug do login"], "expected": false}
|
|
11
|
+
{"id": "td-pt-11", "lang": "pt", "prompt": "usa 20 itens por página em vez de 50", "prior": ["implementa a paginação na lista de encomendas"], "expected": false}
|
|
12
|
+
{"id": "td-pt-12", "lang": "pt", "prompt": "torna o assunto mais curto e direto", "prior": ["escreve o email de lançamento"], "expected": false}
|
|
13
|
+
{"id": "td-pt-13", "lang": "pt", "prompt": "acrescenta uma secção de preços à proposta", "prior": ["revê a proposta para o ClienteY"], "expected": false}
|
|
14
|
+
{"id": "td-pt-14", "lang": "pt", "prompt": "o job de testes falhou, vê o log", "prior": ["configura o pipeline de CI"], "expected": false}
|
|
15
|
+
{"id": "td-pt-15", "lang": "pt", "prompt": "divide a análise do churn por plano", "prior": ["analisa o churn do último trimestre"], "expected": false}
|
|
16
|
+
{"id": "td-pt-16", "lang": "pt", "prompt": "muda o título da landing para algo mais forte", "prior": ["cria a landing page do webinar"], "expected": false}
|
|
17
|
+
{"id": "td-pt-17", "lang": "pt", "prompt": "não te esqueças dos índices na migração", "prior": ["migra a base de dados para PostgreSQL 17"], "expected": false}
|
|
18
|
+
{"id": "td-pt-18", "lang": "pt", "prompt": "cria um script de backup", "prior": [], "expected": false}
|
|
19
|
+
{"id": "td-pt-19", "lang": "pt", "prompt": "continua", "prior": ["corrige o bug do login", "adiciona teste ao login"], "expected": false}
|
|
20
|
+
{"id": "td-pt-20", "lang": "pt", "prompt": "põe o calendário a começar à segunda-feira", "prior": ["cria o componente do calendário"], "expected": false}
|
|
21
|
+
{"id": "td-en-01", "lang": "en", "prompt": "now draft the launch announcement for Twitter", "prior": ["fix the login redirect bug"], "expected": true}
|
|
22
|
+
{"id": "td-en-02", "lang": "en", "prompt": "what's a good name for our podcast?", "prior": ["refactor the payment service into smaller classes"], "expected": true}
|
|
23
|
+
{"id": "td-en-03", "lang": "en", "prompt": "write a cold email sequence for SaaS founders", "prior": ["set up the staging database"], "expected": true}
|
|
24
|
+
{"id": "td-en-04", "lang": "en", "prompt": "debug the flaky websocket test in CI", "prior": ["review the pricing page copy"], "expected": true}
|
|
25
|
+
{"id": "td-en-05", "lang": "en", "prompt": "design a colour palette for the brand refresh", "prior": ["plan the sprint backlog for next week"], "expected": true}
|
|
26
|
+
{"id": "td-en-06", "lang": "en", "prompt": "negotiate the renewal terms with ClienteY", "prior": ["summarise the research notes on vector databases"], "expected": true}
|
|
27
|
+
{"id": "td-en-07", "lang": "en", "prompt": "also add a regression test for it", "prior": ["fix the login redirect bug"], "expected": false}
|
|
28
|
+
{"id": "td-en-08", "lang": "en", "prompt": "keep the public interface unchanged while you do it", "prior": ["refactor the payment service"], "expected": false}
|
|
29
|
+
{"id": "td-en-09", "lang": "en", "prompt": "use the same credentials vault as production", "prior": ["set up the staging database"], "expected": false}
|
|
30
|
+
{"id": "td-en-10", "lang": "en", "prompt": "make the headline shorter", "prior": ["review the pricing page copy"], "expected": false}
|
|
31
|
+
{"id": "td-en-11", "lang": "en", "prompt": "move the auth stories to the top", "prior": ["plan the sprint backlog"], "expected": false}
|
|
32
|
+
{"id": "td-en-12", "lang": "en", "prompt": "compare pgvector and Qdrant in a table", "prior": ["summarise the research notes on vector databases"], "expected": false}
|
|
33
|
+
{"id": "td-en-13", "lang": "en", "prompt": "mention the new decisions layer first", "prior": ["write the release notes for v5.18"], "expected": false}
|
|
34
|
+
{"id": "td-en-14", "lang": "en", "prompt": "now run the smoke tests against staging", "prior": ["deploy the api to staging"], "expected": false}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{"id": "ui-in-ts-01", "lang": "en", "prompt": "export {}", "context": {"path": "app/Button.styles.ts"}, "expected": true}
|
|
2
|
+
{"id": "ui-in-ts-02", "lang": "en", "prompt": "export default {}", "context": {"path": "tailwind.config.ts"}, "expected": true}
|
|
3
|
+
{"id": "ui-in-ts-03", "lang": "en", "prompt": "export const t = {}", "context": {"path": "src/theme.ts"}, "expected": true}
|
|
4
|
+
{"id": "ui-in-ts-04", "lang": "en", "prompt": "el.className = \"btn btn-primary\"", "context": {"path": "app/render.ts"}, "expected": true}
|
|
5
|
+
{"id": "ui-in-ts-05", "lang": "en", "prompt": "const Card = styled.div`padding: 4px`", "context": {"path": "app/card.ts"}, "expected": true}
|
|
6
|
+
{"id": "ui-in-ts-06", "lang": "en", "prompt": "export const button = cva('btn', { variants: { size: { sm: 'h-8', lg: 'h-12' } } })", "context": {"path": "src/ui/variants.ts"}, "expected": true}
|
|
7
|
+
{"id": "ui-in-ts-07", "lang": "en", "prompt": "// store do carrinho\nexport const useCart = defineStore('cart', { state: () => ({ items: [] }) })", "context": {"path": "app/stores/cart.ts"}, "expected": false}
|
|
8
|
+
{"id": "ui-in-ts-08", "lang": "en", "prompt": "// cliente HTTP para a API de encomendas\nexport async function getOrders(): Promise<Order[]> {\n const res = await fetch('/api/orders')\n return res.json()\n}", "context": {"path": "src/api/orders.ts"}, "expected": false}
|
|
9
|
+
{"id": "ui-in-ts-09", "lang": "en", "prompt": "// tipos partilhados\nexport interface Order { id: string; total: number }", "context": {"path": "src/types/order.ts"}, "expected": false}
|
|
10
|
+
{"id": "ui-in-ts-10", "lang": "en", "prompt": "// script de build\nimport { build } from 'esbuild'\nawait build({ entryPoints: ['src/index.ts'], bundle: true })", "context": {"path": "scripts/build.mjs"}, "expected": false}
|
|
11
|
+
{"id": "ui-in-ts-11", "lang": "en", "prompt": "// servidor Express\napp.get('/health', (_req, res) => res.json({ ok: true }))", "context": {"path": "server/index.js"}, "expected": false}
|
|
12
|
+
{"id": "ui-in-ts-12", "lang": "en", "prompt": "// teste da função de soma\nimport { sum } from './sum'\ntest('soma', () => expect(sum(1, 2)).toBe(3))", "context": {"path": "src/sum.test.ts"}, "expected": false}
|
|
13
|
+
{"id": "ui-in-ts-13", "lang": "pt", "prompt": "// composable que mostra um toast no ecrã\nexport function useToast() {\n const el = document.createElement('div')\n el.style.position = 'fixed'\n el.style.bottom = '16px'\n document.body.appendChild(el)\n return (msg: string) => { el.textContent = msg }\n}", "context": {"path": "src/composables/useToast.ts"}, "expected": true}
|
|
14
|
+
{"id": "ui-in-ts-14", "lang": "en", "prompt": "// animação do hero\nimport gsap from 'gsap'\ngsap.from('.hero h1', { y: 40, opacity: 0, duration: 0.8 })", "context": {"path": "src/animations/hero.ts"}, "expected": true}
|
|
15
|
+
{"id": "ui-in-ts-15", "lang": "en", "prompt": "// tokens de design\nexport const colors = { zinc: { 900: '#18181b' }, rose: { 500: '#f43f5e' } }\nexport const radius = { md: '8px' }", "context": {"path": "src/design/tokens.ts"}, "expected": true}
|
|
16
|
+
{"id": "ui-in-ts-16", "lang": "pt", "prompt": "// renderiza a tabela de preços\nexport function renderPricing(root: HTMLElement, plans: Plan[]) {\n root.innerHTML = plans.map(p => `<article class=\"plan\"><h3>${p.name}</h3><p>${p.price}</p></article>`).join('')\n}", "context": {"path": "src/pricing/render.ts"}, "expected": true}
|
|
17
|
+
{"id": "ui-in-ts-17", "lang": "en", "prompt": "// configuração do Vite\nimport { defineConfig } from 'vite'\nexport default defineConfig({ server: { port: 3399 } })", "context": {"path": "vite.config.ts"}, "expected": false}
|
|
18
|
+
{"id": "ui-in-ts-18", "lang": "en", "prompt": "// validação com Zod\nexport const schema = z.object({ email: z.string().email() })", "context": {"path": "src/validation/signup.ts"}, "expected": false}
|
|
19
|
+
{"id": "ui-in-ts-19", "lang": "en", "prompt": "// helper de datas\nexport const toIso = (d: Date) => d.toISOString().slice(0, 10)", "context": {"path": "src/utils/date.ts"}, "expected": false}
|
|
20
|
+
{"id": "ui-in-ts-20", "lang": "en", "prompt": "// router do Vue\nexport default createRouter({ history: createWebHistory(), routes })", "context": {"path": "src/router/index.ts"}, "expected": false}
|
|
21
|
+
{"id": "ui-in-ts-21", "lang": "en", "prompt": "// middleware de autenticação\nexport default defineNuxtRouteMiddleware((to) => { if (!useAuth().user) return navigateTo('/login') })", "context": {"path": "middleware/auth.ts"}, "expected": false}
|
|
22
|
+
{"id": "ui-in-ts-22", "lang": "pt", "prompt": "// componente em render function\nimport { h } from 'vue'\nexport default { render: () => h('button', { class: 'rounded-md px-3 py-2' }, 'Guardar') }", "context": {"path": "src/components/SaveButton.ts"}, "expected": true}
|
|
23
|
+
{"id": "ui-in-ts-23", "lang": "en", "prompt": "// preset do Tailwind da marca\nmodule.exports = { theme: { extend: { fontFamily: { sans: ['Space Grotesk'] } } } }", "context": {"path": "config/brand-preset.cjs"}, "expected": true}
|
|
24
|
+
{"id": "ui-in-ts-24", "lang": "en", "prompt": "// variantes de classes\nexport const badge = (tone: 'ok' | 'err') => tone === 'ok' ? 'bg-emerald-100 text-emerald-800' : 'bg-rose-100 text-rose-800'", "context": {"path": "src/ui/badge.ts"}, "expected": true}
|
|
25
|
+
{"id": "ui-in-ts-25", "lang": "en", "prompt": "// worker da fila de emails\nexport async function handle(job: Job) { await mailer.send(job.data) }", "context": {"path": "workers/email.ts"}, "expected": false}
|
|
26
|
+
{"id": "ui-in-ts-26", "lang": "en", "prompt": "// migração de dados\nexport async function up(db: Db) { await db.schema.alterTable('orders', t => t.integer('total_cents')) }", "context": {"path": "migrations/20260924_total_cents.ts"}, "expected": false}
|
|
27
|
+
{"id": "ui-in-ts-27", "lang": "en", "prompt": "import chalk from 'chalk'\nconsole.log(chalk.green('done'))", "context": {"path": "bin/cli.js"}, "expected": false}
|
|
28
|
+
{"id": "ui-in-ts-28", "lang": "en", "prompt": "export function formatPrice(cents: number): string { return (cents / 100).toFixed(2) }", "context": {"path": "src/format.ts"}, "expected": false}
|
|
29
|
+
{"id": "ui-in-ts-29", "lang": "en", "prompt": "const chart = new Chart(canvas, { type: 'bar', data, options: { plugins: { legend: { display: false } } } })", "context": {"path": "src/charts/revenue.ts"}, "expected": true}
|
|
30
|
+
{"id": "ui-in-ts-30", "lang": "en", "prompt": "export const logger = pino({ level: process.env.LOG_LEVEL ?? 'info' })", "context": {"path": "src/logger.ts"}, "expected": false}
|
|
31
|
+
{"id": "ui-in-ts-31", "lang": "en", "prompt": "document.querySelector('#menu')!.classList.toggle('open')", "context": {"path": "src/menu.js"}, "expected": true}
|
|
32
|
+
{"id": "ui-in-ts-32", "lang": "en", "prompt": "export default defineEventHandler(async () => ({ status: 'ok' }))", "context": {"path": "server/api/health.ts"}, "expected": false}
|
|
33
|
+
{"id": "ui-in-ts-33", "lang": "pt", "prompt": "// mostra uma mensagem quando a lista de encomendas está vazia\nimport { h, defineComponent } from 'vue'\nexport default defineComponent({ setup: () => () => h('p', { class: 'text-sm text-zinc-500' }, 'Ainda não tens encomendas.') })", "context": {"path": "src/components/EmptyState.ts"}, "expected": true}
|
|
34
|
+
{"id": "ui-in-ts-34", "lang": "pt", "prompt": "export function avisar(msg: string) {\n const el = document.createElement('div')\n el.className = 'toast'\n el.textContent = msg\n document.body.append(el)\n}\navisar('As alterações foram guardadas.')", "context": {"path": "src/ui/toast.ts"}, "expected": true}
|
|
35
|
+
{"id": "ui-in-ts-35", "lang": "pt", "prompt": "// cores da marca: o rosa só aparece em estados de alerta\nexport const cores = { fundo: '#09090b', texto: '#fafafa', alerta: '#f43f5e' }", "context": {"path": "src/theme/cores.ts"}, "expected": true}
|
|
36
|
+
{"id": "ui-in-ts-36", "lang": "pt", "prompt": "// faz aparecer os cartões um a um quando entram no ecrã\nimport gsap from 'gsap'\ngsap.from('.cartao', { opacity: 0, y: 24, stagger: 0.1, scrollTrigger: '.grelha' })", "context": {"path": "src/animations/entrada.ts"}, "expected": true}
|
|
37
|
+
{"id": "ui-in-ts-37", "lang": "pt", "prompt": "import { h } from 'vue'\nexport default { render: () => h('nav', { 'aria-label': 'Paginação' }, [h('button', { class: 'btn' }, 'Anterior'), h('button', { class: 'btn' }, 'Seguinte')]) }", "context": {"path": "src/components/Paginacao.ts"}, "expected": true}
|
|
38
|
+
{"id": "ui-in-ts-38", "lang": "pt", "prompt": "// abre o diálogo de confirmação e bloqueia o scroll da página\nexport function confirmar(dialog: HTMLDialogElement) {\n dialog.showModal()\n document.body.style.overflow = 'hidden'\n}", "context": {"path": "src/dom/modal.ts"}, "expected": true}
|
|
39
|
+
{"id": "ui-in-ts-39", "lang": "pt", "prompt": "// botão principal, com o contorno visível ao navegar pelo teclado\nexport const Botao = styled.button`\n padding: 8px 16px;\n &:focus-visible { outline: 2px solid #f43f5e; }\n`", "context": {"path": "src/styles/botao.ts"}, "expected": true}
|
|
40
|
+
{"id": "ui-in-ts-40", "lang": "pt", "prompt": "// pago fica a verde, pendente fica a âmbar\nexport const classeEstado = (pago: boolean) =>\n pago ? 'bg-emerald-100 text-emerald-800' : 'bg-amber-100 text-amber-800'", "context": {"path": "src/ui/estado.ts"}, "expected": true}
|
|
41
|
+
{"id": "ui-in-ts-41", "lang": "pt", "prompt": "new Chart(canvas, { type: 'line', data, options: { plugins: { title: { display: true, text: 'Vendas por mês' } } } })", "context": {"path": "src/charts/vendas.ts"}, "expected": true}
|
|
42
|
+
{"id": "ui-in-ts-42", "lang": "pt", "prompt": "export function montar(root: HTMLElement) {\n root.innerHTML = `<label for=\"email\">Endereço de email</label><input id=\"email\" type=\"email\"><button>Subscrever</button>`\n}", "context": {"path": "src/forms/subscricao.ts"}, "expected": true}
|
|
43
|
+
{"id": "ui-in-ts-43", "lang": "pt", "prompt": "// o título usa Space Grotesk; o texto corrido usa IBM Plex Sans\nexport default { theme: { extend: { fontFamily: { display: ['Space Grotesk'], sans: ['IBM Plex Sans'] } } } }", "context": {"path": "config/fonts.ts"}, "expected": true}
|
|
44
|
+
{"id": "ui-in-ts-44", "lang": "pt", "prompt": "const banner = document.getElementById('manutencao')!\nbanner.hidden = false\nbanner.querySelector('p')!.textContent = 'Estamos em manutenção entre as 2h e as 4h.'", "context": {"path": "src/dom/banner.ts"}, "expected": true}
|
|
45
|
+
{"id": "ui-in-ts-45", "lang": "pt", "prompt": "// devolve 404 quando a encomenda não existe\nexport default defineEventHandler(async (event) => {\n const encomenda = await db.find(getRouterParam(event, 'id'))\n if (!encomenda) throw createError({ statusCode: 404, statusMessage: 'Encomenda não encontrada' })\n return encomenda\n})", "context": {"path": "server/api/encomendas/[id].ts"}, "expected": false}
|
|
46
|
+
{"id": "ui-in-ts-46", "lang": "pt", "prompt": "// apaga os ficheiros temporários com mais de sete dias\nconst n = await removeOlderThan(tmpDir, 7)\nconsole.log(`Apaguei ${n} ficheiros temporários.`)", "context": {"path": "bin/limpar.js"}, "expected": false}
|
|
47
|
+
{"id": "ui-in-ts-47", "lang": "pt", "prompt": "// guarda o utilizador autenticado e o token; não desenha nada\nexport const useSessao = defineStore('sessao', { state: () => ({ utilizador: null, token: '' }) })", "context": {"path": "src/stores/sessao.ts"}, "expected": false}
|
|
48
|
+
{"id": "ui-in-ts-48", "lang": "pt", "prompt": "export const morada = z.object({\n codigoPostal: z.string().regex(/^\\d{4}-\\d{3}$/, 'O código postal tem de ter o formato 0000-000'),\n})", "context": {"path": "src/validation/morada.ts"}, "expected": false}
|
|
49
|
+
{"id": "ui-in-ts-49", "lang": "pt", "prompt": "import { comIva } from './iva'\ntest('aplica a taxa de IVA de 23 % ao preço base', () => expect(comIva(100)).toBe(123))", "context": {"path": "src/utils/iva.test.ts"}, "expected": false}
|
|
50
|
+
{"id": "ui-in-ts-50", "lang": "pt", "prompt": "// gera o sitemap a partir das rotas publicadas e escreve-o em public/\nimport { writeFile } from 'node:fs/promises'\nawait writeFile('public/sitemap.xml', toXml(await rotasPublicadas()))", "context": {"path": "scripts/gerar-sitemap.mjs"}, "expected": false}
|
|
51
|
+
{"id": "ui-in-ts-51", "lang": "pt", "prompt": "// envia a fatura por email quando o pagamento é confirmado\nexport async function handle(job: Job<{ faturaId: string }>) { await enviarFatura(job.data.faturaId) }", "context": {"path": "workers/faturas.ts"}, "expected": false}
|
|
52
|
+
{"id": "ui-in-ts-52", "lang": "pt", "prompt": "// procura clientes pelo NIF; devolve uma lista vazia quando não há resultados\nexport async function porNif(nif: string): Promise<Cliente[]> {\n const res = await fetch(`/api/clientes?nif=${nif}`)\n return res.ok ? res.json() : []\n}", "context": {"path": "src/api/clientes.ts"}, "expected": false}
|
|
53
|
+
{"id": "ui-in-ts-53", "lang": "pt", "prompt": "/** Estado da fatura: fica em rascunho até ser emitida e depois já não pode mudar. */\nexport type EstadoFatura = 'rascunho' | 'emitida'", "context": {"path": "src/types/fatura.ts"}, "expected": false}
|
|
54
|
+
{"id": "ui-in-ts-54", "lang": "pt", "prompt": "export default defineEventHandler((event) => {\n if (excedeuLimite(getRequestIP(event)))\n throw createError({ statusCode: 429, statusMessage: 'Demasiados pedidos, tenta daqui a um minuto.' })\n})", "context": {"path": "server/middleware/limite.ts"}, "expected": false}
|
|
55
|
+
{"id": "ui-in-ts-55", "lang": "pt", "prompt": "// regista a alteração para a auditoria, sem guardar a palavra-passe\nexport const registar = (id: string) => logger.info({ utilizador: id }, 'Palavra-passe alterada com sucesso')", "context": {"path": "src/auditoria.ts"}, "expected": false}
|
|
56
|
+
{"id": "ui-in-ts-56", "lang": "pt", "prompt": "// acrescenta o código postal às moradas; as linhas antigas ficam a nulo\nexport async function up(db: Db) { await db.schema.alterTable('moradas', t => t.string('codigo_postal').nullable()) }", "context": {"path": "migrations/20260924_moradas.ts"}, "expected": false}
|
|
57
|
+
{"id": "ui-in-ts-57", "lang": "pt", "prompt": "// em desenvolvimento, encaminha /api para o servidor local na porta 8000\nexport default defineConfig({ server: { proxy: { '/api': 'http://localhost:8000' } } })", "context": {"path": "vite.config.ts"}, "expected": false}
|
|
58
|
+
{"id": "ui-in-ts-58", "lang": "pt", "prompt": "// formata cêntimos em euros com vírgula decimal, como se escreve em Portugal\nexport const euros = (c: number) => new Intl.NumberFormat('pt-PT', { style: 'currency', currency: 'EUR' }).format(c / 100)", "context": {"path": "src/utils/moeda.ts"}, "expected": false}
|
|
59
|
+
{"id": "ui-in-ts-59", "lang": "pt", "prompt": "// todas as manhãs, lembra os clientes que têm faturas em atraso\nexport default defineTask({ meta: { name: 'lembretes' }, run: async () => ({ result: await lembrarAtrasos() }) })", "context": {"path": "server/cron/lembretes.ts"}, "expected": false}
|
|
60
|
+
{"id": "ui-in-ts-60", "lang": "pt", "prompt": "// sincroniza o stock com o ERP e ignora os artigos descontinuados\nexport async function sincronizar(erp: Erp) {\n for (const artigo of await erp.artigos())\n if (!artigo.descontinuado) await db.stock.upsert(artigo.sku, artigo.quantidade)\n}", "context": {"path": "src/sync/stock.ts"}, "expected": false}
|
package/config/models.yaml
CHANGED
|
@@ -27,8 +27,8 @@ providers:
|
|
|
27
27
|
type: anthropic-direct # SDK; needs ANTHROPIC_API_KEY
|
|
28
28
|
ollama:
|
|
29
29
|
type: ollama # local models, http://localhost:11434
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
openrouter: # one key -> hundreds of models; also the Jev decisions transport
|
|
31
|
+
type: openrouter # needs OPENROUTER_API_KEY
|
|
32
32
|
|
|
33
33
|
# ─── Aliases ────────────────────────────────────────────────────────────
|
|
34
34
|
# `best` / `default` / `fast` resolve per provider, so roles stay readable.
|
|
@@ -68,3 +68,11 @@ fusion:
|
|
|
68
68
|
# - {provider: ollama, model: kimi-k2.6}
|
|
69
69
|
# - {provider: openrouter, model: deepseek/deepseek-v4-pro}
|
|
70
70
|
# - {provider: anthropic, model: best}
|
|
71
|
+
|
|
72
|
+
# ─── Decisions (Jev typed classifier — not a role) ──────────────────────
|
|
73
|
+
# Typed yes/no, choice and score answers for hooks, gates, Forge and
|
|
74
|
+
# cognition (core/decisions/). No text generation, so never a role above.
|
|
75
|
+
# Per-site modes (act | shadow | off) live in ~/.arkaos/config.json.
|
|
76
|
+
decisions:
|
|
77
|
+
transport: openrouter # only transport supported (operator decision 2026-09-23)
|
|
78
|
+
model: jev-1.13 # alias -> typesafe/jev-1.13 on OpenRouter
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Typed decisions layer — System 1 under the ArkaOS agents.
|
|
2
|
+
|
|
3
|
+
A thin client for the OpenRouter Decisions endpoint (TypeSafe Jev):
|
|
4
|
+
typed questions in (``noul`` / ``choice`` / ``score``), typed answers
|
|
5
|
+
out, never generated text. Every call site keeps its heuristic as the
|
|
6
|
+
unavailability fallback; :func:`core.decisions.engine.decide` never
|
|
7
|
+
raises. See ADR ``docs/adr/2026-09-23-jev-decisions-layer.md``.
|
|
8
|
+
"""
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Circuit breaker shared by every process: one JSON file under the cache.
|
|
2
|
+
|
|
3
|
+
``trip`` records ``{"reason", "until"}``; ``blocked`` answers the reason
|
|
4
|
+
while ``now < until``. A longer existing block is never shortened — unless
|
|
5
|
+
another key set it: an ``http-401`` trip also records the key's
|
|
6
|
+
fingerprint (:func:`~core.decisions.transport.key_fingerprint`), and
|
|
7
|
+
rotating the key reopens the breaker at once (R-B1).
|
|
8
|
+
Neither function raises — a broken breaker means "not blocked".
|
|
9
|
+
|
|
10
|
+
Transport failures (``timeout`` / ``network``) trip it too, but only
|
|
11
|
+
after :data:`FAILURE_TRIP_COUNT` in a row (``failures.json``, shared by
|
|
12
|
+
every hook process): one slow turn is noise, three are an outage the
|
|
13
|
+
next turns must not each pay for (QG r1 B2). A success clears the count;
|
|
14
|
+
a count older than :data:`FAILURE_WINDOW_S` starts over.
|
|
15
|
+
|
|
16
|
+
Directories this module creates are 0700 and its files 0600 (QG r1 m3).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import contextlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import tempfile
|
|
25
|
+
import time
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from core.decisions.paths import cache_root
|
|
29
|
+
from core.decisions.transport import key_fingerprint
|
|
30
|
+
|
|
31
|
+
# No caller trips for longer (client.py caps Retry-After at an hour); a
|
|
32
|
+
# deadline further out is a corrupt or planted file, not a real trip,
|
|
33
|
+
# and must neither block forever nor stop a real trip from landing.
|
|
34
|
+
MAX_BLOCK_S = 3600.0
|
|
35
|
+
FAILURE_TRIP_COUNT = 3
|
|
36
|
+
FAILURE_BACKOFF_S = 60.0
|
|
37
|
+
FAILURE_WINDOW_S = 600.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _state_path() -> Path:
|
|
41
|
+
return cache_root() / "backoff.json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _failures_path() -> Path:
|
|
45
|
+
return cache_root() / "failures.json"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def ensure_private_dir(path: Path) -> None:
|
|
49
|
+
"""``mkdir -p`` where every directory CREATED here is 0700."""
|
|
50
|
+
missing: list[Path] = []
|
|
51
|
+
probe = path
|
|
52
|
+
while not probe.exists() and probe != probe.parent:
|
|
53
|
+
missing.append(probe)
|
|
54
|
+
probe = probe.parent
|
|
55
|
+
for directory in reversed(missing):
|
|
56
|
+
with contextlib.suppress(FileExistsError):
|
|
57
|
+
directory.mkdir(mode=0o700)
|
|
58
|
+
os.chmod(directory, 0o700) # mkdir's mode is masked by the umask
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def record_failure(reason: str, now: float | None = None) -> int:
|
|
62
|
+
"""Count one transport failure; trip at the threshold. Never raises."""
|
|
63
|
+
ref = time.time() if now is None else now
|
|
64
|
+
try:
|
|
65
|
+
state = json.loads(_failures_path().read_text(encoding="utf-8"))
|
|
66
|
+
count = int(state["count"]) if ref - float(state["last"]) <= FAILURE_WINDOW_S else 0
|
|
67
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
68
|
+
count = 0
|
|
69
|
+
count += 1
|
|
70
|
+
if count >= FAILURE_TRIP_COUNT:
|
|
71
|
+
trip(reason, FAILURE_BACKOFF_S, now=ref)
|
|
72
|
+
record_success()
|
|
73
|
+
return count
|
|
74
|
+
with contextlib.suppress(OSError, ValueError):
|
|
75
|
+
_atomic_write(_failures_path(), {"count": count, "last": ref})
|
|
76
|
+
return count
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def record_success() -> None:
|
|
80
|
+
"""A call went through: consecutive failures start from zero."""
|
|
81
|
+
with contextlib.suppress(OSError):
|
|
82
|
+
_failures_path().unlink(missing_ok=True)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _read() -> dict[str, object]:
|
|
86
|
+
try:
|
|
87
|
+
data = json.loads(_state_path().read_text(encoding="utf-8"))
|
|
88
|
+
except (OSError, ValueError):
|
|
89
|
+
return {}
|
|
90
|
+
return data if isinstance(data, dict) else {}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def blocked(now: float | None = None) -> str | None:
|
|
94
|
+
"""The trip reason while the breaker is open, else None."""
|
|
95
|
+
state = _read()
|
|
96
|
+
until = state.get("until")
|
|
97
|
+
if not isinstance(until, int | float) or _set_by_other_key(state):
|
|
98
|
+
return None
|
|
99
|
+
ref = time.time() if now is None else now
|
|
100
|
+
if ref >= until or until - ref > MAX_BLOCK_S:
|
|
101
|
+
return None
|
|
102
|
+
return str(state.get("reason") or "backoff")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def trip(reason: str, seconds: float, now: float | None = None) -> None:
|
|
106
|
+
"""Open the breaker for ``seconds``; keeps any later existing deadline."""
|
|
107
|
+
ref = time.time() if now is None else now
|
|
108
|
+
until = ref + min(max(0.0, float(seconds)), MAX_BLOCK_S)
|
|
109
|
+
state = _read()
|
|
110
|
+
current = state.get("until")
|
|
111
|
+
keep = not _set_by_other_key(state)
|
|
112
|
+
if keep and isinstance(current, int | float) and until < current <= ref + MAX_BLOCK_S:
|
|
113
|
+
return
|
|
114
|
+
payload: dict[str, object] = {"reason": reason, "until": until}
|
|
115
|
+
if reason == "http-401":
|
|
116
|
+
payload["key"] = key_fingerprint()
|
|
117
|
+
with contextlib.suppress(OSError, ValueError):
|
|
118
|
+
_atomic_write(_state_path(), payload)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _set_by_other_key(state: dict[str, object]) -> bool:
|
|
122
|
+
key = state.get("key")
|
|
123
|
+
return isinstance(key, str) and key != key_fingerprint()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _atomic_write(path: Path, payload: dict[str, object]) -> None:
|
|
127
|
+
ensure_private_dir(path.parent)
|
|
128
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".backoff-", suffix=".tmp")
|
|
129
|
+
try:
|
|
130
|
+
os.fchmod(fd, 0o600)
|
|
131
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
132
|
+
json.dump(payload, fh)
|
|
133
|
+
os.replace(tmp, path)
|
|
134
|
+
except BaseException:
|
|
135
|
+
with contextlib.suppress(OSError):
|
|
136
|
+
os.unlink(tmp)
|
|
137
|
+
raise
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Response cache keyed by sha256(model + prepared state + questions).
|
|
2
|
+
|
|
3
|
+
Stores RESPONSES only — never the state. One file per key under
|
|
4
|
+
``<cache_root>/<key[:2]>/<key>.json``, written tmp+rename with mode
|
|
5
|
+
0600. Corrupt, expired or foreign files are a miss; nothing raises.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import tempfile
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from pydantic import ValidationError
|
|
20
|
+
|
|
21
|
+
from core.decisions.backoff import ensure_private_dir
|
|
22
|
+
from core.decisions.models import DecisionResponse, Question, State
|
|
23
|
+
from core.decisions.paths import cache_root
|
|
24
|
+
|
|
25
|
+
_KEY_RE = re.compile(r"[0-9a-f]{64}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def cache_key(model: str, state: State, questions: dict[str, Question]) -> str:
|
|
29
|
+
"""Deterministic sha256 over the canonical JSON of the request."""
|
|
30
|
+
canonical = json.dumps(
|
|
31
|
+
{
|
|
32
|
+
"model": model,
|
|
33
|
+
"state": state,
|
|
34
|
+
"questions": {k: q.to_payload() for k, q in questions.items()},
|
|
35
|
+
},
|
|
36
|
+
sort_keys=True,
|
|
37
|
+
ensure_ascii=False,
|
|
38
|
+
separators=(",", ":"),
|
|
39
|
+
)
|
|
40
|
+
return hashlib.sha256(canonical.encode("utf-8", errors="surrogatepass")).hexdigest()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _entry_path(key: str) -> Path | None:
|
|
44
|
+
if not _KEY_RE.fullmatch(key):
|
|
45
|
+
return None
|
|
46
|
+
return cache_root() / key[:2] / f"{key}.json"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get(key: str, ttl_s: float, now: float | None = None) -> DecisionResponse | None:
|
|
50
|
+
"""The cached response while younger than ``ttl_s``, else None."""
|
|
51
|
+
path = _entry_path(key)
|
|
52
|
+
if path is None:
|
|
53
|
+
return None
|
|
54
|
+
try:
|
|
55
|
+
entry = json.loads(path.read_text(encoding="utf-8"))
|
|
56
|
+
stored_at = float(entry["stored_at"])
|
|
57
|
+
response = DecisionResponse.model_validate(entry["response"])
|
|
58
|
+
except (OSError, ValueError, KeyError, TypeError, ValidationError):
|
|
59
|
+
return None
|
|
60
|
+
ref = time.time() if now is None else now
|
|
61
|
+
if ttl_s <= 0 or ref - stored_at > ttl_s:
|
|
62
|
+
return None
|
|
63
|
+
return response
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def put(key: str, response: DecisionResponse, now: float | None = None) -> bool:
|
|
67
|
+
"""Store one response atomically; False when it could not."""
|
|
68
|
+
path = _entry_path(key)
|
|
69
|
+
if path is None:
|
|
70
|
+
return False
|
|
71
|
+
stored_at = time.time() if now is None else now
|
|
72
|
+
payload = {"stored_at": stored_at, "response": response.model_dump(mode="json")}
|
|
73
|
+
try:
|
|
74
|
+
_atomic_write(path, payload)
|
|
75
|
+
except (OSError, ValueError, TypeError):
|
|
76
|
+
return False
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _atomic_write(path: Path, payload: dict[str, object]) -> None:
|
|
81
|
+
# Every directory created on the way is 0700, not the umask default.
|
|
82
|
+
ensure_private_dir(path.parent)
|
|
83
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".entry-", suffix=".tmp")
|
|
84
|
+
try:
|
|
85
|
+
os.fchmod(fd, 0o600)
|
|
86
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
87
|
+
json.dump(payload, fh, ensure_ascii=False)
|
|
88
|
+
os.replace(tmp, path)
|
|
89
|
+
except BaseException:
|
|
90
|
+
with contextlib.suppress(OSError):
|
|
91
|
+
os.unlink(tmp)
|
|
92
|
+
raise
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Stdlib client for ``POST /api/alpha/decisions``.
|
|
2
|
+
|
|
3
|
+
:func:`post_decision` raises ONLY :class:`DecisionUnavailable`; its
|
|
4
|
+
``reason`` is the stable telemetry vocabulary:
|
|
5
|
+
|
|
6
|
+
``no-transport`` | ``bypassed`` | ``backoff`` | ``deadline`` |
|
|
7
|
+
``egress-denied:<kind>`` | ``timeout`` | ``network`` | ``http-401`` |
|
|
8
|
+
``http-422`` | ``http-429`` | ``http-529`` | ``http-<n>`` |
|
|
9
|
+
``invalid-json`` | ``invalid-shape`` | ``redirected``.
|
|
10
|
+
|
|
11
|
+
A failure decided on this machine is raised with ``local=True``; the
|
|
12
|
+
replay harness reads it through :meth:`DecisionUnavailable.run_reason` as
|
|
13
|
+
``<reason>:local`` (``invalid-shape:local`` for a state or body that
|
|
14
|
+
cannot be serialised, ``timeout:local`` for the wall-clock deadline) and
|
|
15
|
+
never counts it as a call that reached the endpoint. Live telemetry keeps
|
|
16
|
+
the bare ``reason`` above.
|
|
17
|
+
|
|
18
|
+
401/429/529 also trip the shared circuit breaker (``backoff.py``);
|
|
19
|
+
``timeout``/``network`` count towards it (3 in a row trip it for 60 s)
|
|
20
|
+
and a success clears the count.
|
|
21
|
+
|
|
22
|
+
Deadline: ``urlopen(timeout=)`` bounds each socket operation, not the
|
|
23
|
+
call — DNS is not covered and a server dripping one byte every 0.4 s
|
|
24
|
+
held a 1 s call for 11.4 s (QG r1 B1). The exchange runs in a daemon
|
|
25
|
+
thread joined against a monotonic deadline; past it the call is
|
|
26
|
+
``timeout`` and the thread is abandoned. The thread only fetches bytes:
|
|
27
|
+
parsing, cache, breaker and telemetry all happen on the caller's side,
|
|
28
|
+
so an abandoned thread can write nothing.
|
|
29
|
+
|
|
30
|
+
Redirects: urllib turns a 301/302/303 answer to a POST into a GET and
|
|
31
|
+
copies every non-content header to whatever host ``Location`` names —
|
|
32
|
+
the Bearer key included (reproduced against two local servers in the
|
|
33
|
+
2026-09-23 security review). ``Authorization`` therefore travels as an
|
|
34
|
+
UNREDIRECTED header (urllib never copies those), the body is dropped
|
|
35
|
+
by urllib itself, and a response whose final URL differs from the one
|
|
36
|
+
requested is refused (``redirected``) so another host cannot answer.
|
|
37
|
+
307/308 to a POST already surface as ``http-307``/``http-308``.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import json
|
|
43
|
+
import threading
|
|
44
|
+
import time
|
|
45
|
+
import urllib.error
|
|
46
|
+
import urllib.request
|
|
47
|
+
|
|
48
|
+
from pydantic import ValidationError
|
|
49
|
+
|
|
50
|
+
from core.decisions import backoff
|
|
51
|
+
from core.decisions.models import DecisionRequest, DecisionResponse
|
|
52
|
+
from core.decisions.transport import Transport
|
|
53
|
+
|
|
54
|
+
BACKOFF_401_S = 3600.0
|
|
55
|
+
BACKOFF_429_DEFAULT_S = 60.0
|
|
56
|
+
BACKOFF_529_S = 120.0
|
|
57
|
+
MAX_RETRY_AFTER_S = 3600.0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# The ``:<cause>`` suffix of a reason decided locally (see run_reason).
|
|
61
|
+
LOCAL_MARK = "local"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class DecisionUnavailable(Exception): # noqa: N818 — contract name from the ADR
|
|
65
|
+
"""No typed answer this time; the caller uses its heuristic."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, reason: str, detail: str = "", *, local: bool = False) -> None:
|
|
68
|
+
super().__init__(f"{reason}: {detail}" if detail else reason)
|
|
69
|
+
self.reason = reason
|
|
70
|
+
self.detail = detail
|
|
71
|
+
self.local = local
|
|
72
|
+
|
|
73
|
+
def run_reason(self) -> str:
|
|
74
|
+
"""``reason``, suffixed ``:local`` when this machine decided it."""
|
|
75
|
+
return f"{self.reason}:{LOCAL_MARK}" if self.local else self.reason
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def post_decision(
|
|
79
|
+
transport: Transport, request: DecisionRequest, timeout_s: float
|
|
80
|
+
) -> tuple[DecisionResponse, int]:
|
|
81
|
+
"""POST one request; ``(response, latency_ms)`` or DecisionUnavailable."""
|
|
82
|
+
try:
|
|
83
|
+
payload = {**request.to_payload(), **transport.body_extras}
|
|
84
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
85
|
+
except (TypeError, ValueError) as exc:
|
|
86
|
+
raise DecisionUnavailable("invalid-shape", type(exc).__name__, local=True) from exc
|
|
87
|
+
req = _request(transport, body)
|
|
88
|
+
start = time.monotonic()
|
|
89
|
+
try:
|
|
90
|
+
raw = _send_by_deadline(req, timeout_s)
|
|
91
|
+
except DecisionUnavailable as exc:
|
|
92
|
+
if exc.reason in ("timeout", "network"):
|
|
93
|
+
backoff.record_failure(exc.reason)
|
|
94
|
+
raise
|
|
95
|
+
latency_ms = int((time.monotonic() - start) * 1000)
|
|
96
|
+
response = _parse(raw)
|
|
97
|
+
backoff.record_success()
|
|
98
|
+
return response, latency_ms
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _request(transport: Transport, body: bytes) -> urllib.request.Request:
|
|
102
|
+
headers = {k: v for k, v in transport.headers.items() if k.lower() != "authorization"}
|
|
103
|
+
req = urllib.request.Request(transport.url, data=body, headers=headers, method="POST")
|
|
104
|
+
for name, value in transport.headers.items():
|
|
105
|
+
if name.lower() == "authorization":
|
|
106
|
+
req.add_unredirected_header(name, value)
|
|
107
|
+
return req
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _send_by_deadline(req: urllib.request.Request, timeout_s: float) -> bytes:
|
|
111
|
+
"""``_send`` bounded by wall clock, not per socket operation."""
|
|
112
|
+
box: dict[str, object] = {}
|
|
113
|
+
|
|
114
|
+
def worker() -> None:
|
|
115
|
+
try:
|
|
116
|
+
box["data"] = _send(req, timeout_s)
|
|
117
|
+
except BaseException as exc: # handed to the caller, never lost
|
|
118
|
+
box["error"] = exc
|
|
119
|
+
|
|
120
|
+
thread = threading.Thread(target=worker, name="arka-decision", daemon=True)
|
|
121
|
+
thread.start()
|
|
122
|
+
thread.join(max(0.0, timeout_s))
|
|
123
|
+
if thread.is_alive():
|
|
124
|
+
# Not closed from here: HTTPResponse.close() waits on the reader
|
|
125
|
+
# lock the dripping read holds, so it would block the caller for
|
|
126
|
+
# the whole drip (measured: 11.7 s). The daemon thread ends with
|
|
127
|
+
# the socket or the process; it holds bytes only.
|
|
128
|
+
raise DecisionUnavailable("timeout", "deadline", local=True)
|
|
129
|
+
error = box.get("error")
|
|
130
|
+
if isinstance(error, urllib.error.HTTPError):
|
|
131
|
+
raise _map_http_error(error) from error
|
|
132
|
+
if isinstance(error, BaseException):
|
|
133
|
+
raise error
|
|
134
|
+
data = box.get("data")
|
|
135
|
+
return data if isinstance(data, bytes) else b""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _send(req: urllib.request.Request, timeout_s: float) -> bytes:
|
|
139
|
+
try:
|
|
140
|
+
with urllib.request.urlopen(req, timeout=timeout_s) as resp:
|
|
141
|
+
final = getattr(resp, "url", None)
|
|
142
|
+
if isinstance(final, str) and final != req.full_url:
|
|
143
|
+
raise DecisionUnavailable("redirected")
|
|
144
|
+
data: bytes = resp.read()
|
|
145
|
+
return data
|
|
146
|
+
except DecisionUnavailable:
|
|
147
|
+
raise
|
|
148
|
+
except urllib.error.HTTPError:
|
|
149
|
+
raise # mapped on the caller's side: the breaker is never tripped late
|
|
150
|
+
except urllib.error.URLError as exc:
|
|
151
|
+
reason = "timeout" if isinstance(exc.reason, TimeoutError) else "network"
|
|
152
|
+
raise DecisionUnavailable(reason, str(exc.reason)) from exc
|
|
153
|
+
except TimeoutError as exc:
|
|
154
|
+
raise DecisionUnavailable("timeout") from exc
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
raise DecisionUnavailable("network", type(exc).__name__) from exc
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _map_http_error(exc: urllib.error.HTTPError) -> DecisionUnavailable:
|
|
160
|
+
code = int(exc.code)
|
|
161
|
+
if code == 401:
|
|
162
|
+
backoff.trip("http-401", BACKOFF_401_S)
|
|
163
|
+
elif code == 429:
|
|
164
|
+
backoff.trip("http-429", _retry_after(exc))
|
|
165
|
+
elif code == 529:
|
|
166
|
+
backoff.trip("http-529", BACKOFF_529_S)
|
|
167
|
+
return DecisionUnavailable(f"http-{code}", str(exc.reason or ""))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _retry_after(exc: urllib.error.HTTPError) -> float:
|
|
171
|
+
raw = exc.headers.get("Retry-After") if exc.headers is not None else None
|
|
172
|
+
try:
|
|
173
|
+
seconds = float(str(raw).strip())
|
|
174
|
+
except (TypeError, ValueError):
|
|
175
|
+
return BACKOFF_429_DEFAULT_S
|
|
176
|
+
if seconds <= 0:
|
|
177
|
+
return BACKOFF_429_DEFAULT_S
|
|
178
|
+
return min(seconds, MAX_RETRY_AFTER_S)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _parse(raw: bytes) -> DecisionResponse:
|
|
182
|
+
try:
|
|
183
|
+
data = json.loads(raw.decode("utf-8"))
|
|
184
|
+
except (UnicodeDecodeError, ValueError) as exc:
|
|
185
|
+
raise DecisionUnavailable("invalid-json", type(exc).__name__) from exc
|
|
186
|
+
if not isinstance(data, dict):
|
|
187
|
+
raise DecisionUnavailable("invalid-shape", "body is not an object")
|
|
188
|
+
try:
|
|
189
|
+
return DecisionResponse.model_validate(data)
|
|
190
|
+
except ValidationError as exc:
|
|
191
|
+
raise DecisionUnavailable("invalid-shape", f"{exc.error_count()} errors") from exc
|