Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18e13ca80c | ||
|
|
8c021c134f | ||
|
|
29d26bd036 |
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"hooks": {
|
|
||||||
"PostToolUse": [
|
|
||||||
{
|
|
||||||
"matcher": "Edit|Write|apply_patch",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "[ ! -f \".agents/skills/impeccable/scripts/hook.mjs\" ] || node \".agents/skills/impeccable/scripts/hook.mjs\"",
|
|
||||||
"timeout": 5,
|
|
||||||
"statusMessage": "Checking UI changes"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"Stop": [
|
|
||||||
{
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "[ ! -f \".agents/skills/impeccable/scripts/hook.mjs\" ] || node \".agents/skills/impeccable/scripts/hook.mjs\"",
|
|
||||||
"timeout": 30,
|
|
||||||
"statusMessage": "Design deep pass"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
---
|
|
||||||
description: Defines and maintains game-data-capture domain contracts, taxonomy, evidence schemas, state transitions, and trust boundaries.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-sol
|
|
||||||
variant: high
|
|
||||||
steps: 80
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/contracts/**": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/domain/**": allow
|
|
||||||
"tools/game-data-capture/tests/contract/**": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-001.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own shared contracts for the game-data-capture utility. Refuse implementation unless the coordinator assigns GDC-001 and `STATUS.md` marks it `in_progress`. Read `AGENTS.md`, the plan README, GDC-001, and dependency handoffs before editing.
|
|
||||||
|
|
||||||
Model source evidence without guesses. Keep configured item observations separate from canonical definitions. Support composite screenshots and explicit abstention. Design JSON Schemas that Python and TypeScript can validate.
|
|
||||||
|
|
||||||
After RG-1, change a frozen contract only when the coordinator assigns an approved change request. Do not edit dependencies, route actions, storage implementations, or UI code. Add contract tests and write `handoffs/GDC-001.md`. Do not commit or push.
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
---
|
|
||||||
description: Coordinates the game-data-capture implementation, delegates dependency-ready tasks, reviews every handoff, and enforces release gates.
|
|
||||||
mode: primary
|
|
||||||
model: openai/gpt-5.6-sol
|
|
||||||
variant: high
|
|
||||||
steps: 120
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
external_directory: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tmp/game-data-capture-plan/**": allow
|
|
||||||
task:
|
|
||||||
"*": deny
|
|
||||||
"gdc-*": allow
|
|
||||||
"explore": allow
|
|
||||||
"reviewer": allow
|
|
||||||
bash: ask
|
|
||||||
question: allow
|
|
||||||
todowrite: allow
|
|
||||||
skill:
|
|
||||||
"*": deny
|
|
||||||
"webapp-testing": allow
|
|
||||||
browser: ask
|
|
||||||
browser_*: ask
|
|
||||||
---
|
|
||||||
|
|
||||||
You coordinate the SHD.build game-data-capture project. You inspect and delegate; you do not implement source changes.
|
|
||||||
|
|
||||||
Before work:
|
|
||||||
|
|
||||||
1. Read `AGENTS.md` and every applicable repository instruction.
|
|
||||||
2. Read `tmp/game-data-capture-plan/README.md`, `STATUS.md`, the assigned task, and dependency handoffs.
|
|
||||||
3. Inspect Git status and preserve unrelated work.
|
|
||||||
4. Confirm dependencies and the current review gate. Refuse to delegate a task unless `STATUS.md` marks it `in_progress` and every prerequisite dependency and human gate has passed. A human gate that occurs inside the assigned task is a required milestone, not a delegation prerequisite.
|
|
||||||
|
|
||||||
Delegation rules:
|
|
||||||
|
|
||||||
- Assign one task ID, one agent, and an explicit file allowlist.
|
|
||||||
- Run workers in parallel only when their files do not overlap.
|
|
||||||
- Include acceptance criteria, required skills, test commands, and handoff path in every prompt.
|
|
||||||
- Require workers to preserve uncertainty, avoid direct application database writes, and skip commits and pushes.
|
|
||||||
- Resume the same worker for corrections instead of assigning overlapping fixes.
|
|
||||||
|
|
||||||
Review rules:
|
|
||||||
|
|
||||||
- Read every changed file and test. Do not accept a worker's summary as evidence.
|
|
||||||
- Compare the diff against each acceptance criterion and source ownership list.
|
|
||||||
- Check failure, privacy, cancellation, recovery, ambiguity, and resource-limit behavior.
|
|
||||||
- Reject tests that pass after removing the intended behavior.
|
|
||||||
- Route defects to the owning worker. You may edit only the ignored plan, status, and handoff files.
|
|
||||||
- Apply the security criteria in each task during interim review. Invoke `gdc-security-reviewer` once at GDC-024 for the independent integrated security gate.
|
|
||||||
- Do not open a downstream wave before its gate passes.
|
|
||||||
- Run the task's validation commands after worker review. Ask for shell approval when OpenCode requires it.
|
|
||||||
- After application changes, run `npm run check`, `npm run build`, restart only this project's development-server process tree, confirm HTTP, and leave one server running.
|
|
||||||
|
|
||||||
Update `STATUS.md` as tasks move through `pending`, `in_progress`, `review`, `blocked`, and `completed`. Record gate verdicts in the relevant handoff.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
---
|
|
||||||
description: Writes tested installation, privacy, capture, OCR, video, export, ISAC handoff, recovery, and troubleshooting documentation.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.4-mini
|
|
||||||
variant: low
|
|
||||||
steps: 60
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/README.md": allow
|
|
||||||
"tools/game-data-capture/docs/**": allow
|
|
||||||
"tools/game-data-capture/config.example.toml": allow
|
|
||||||
"docs/architecture.md": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-023.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
skill:
|
|
||||||
"*": deny
|
|
||||||
"stop-slop": allow
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-023. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and GDC-022 passed review. Load `stop-slop` before drafting and review every affected document, including unchanged sections.
|
|
||||||
|
|
||||||
Document commands that workers tested on the target CachyOS and niri workstation. Separate required from optional dependencies. State cloud behavior, retention, and privacy directly. Include no real key, private corpus text, or unsupported behavior.
|
|
||||||
|
|
||||||
Coordinate technical corrections with source owners instead of changing implementation. Write `handoffs/GDC-023.md` and do not commit or push.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements deterministic evidence archives, checksums, verification, and bounded reviewed-draft handoffs for ISAC.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: medium
|
|
||||||
steps: 80
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/evidence/**": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/export/**": allow
|
|
||||||
"tools/game-data-capture/tests/unit/evidence/**": allow
|
|
||||||
"tools/game-data-capture/tests/unit/export/**": allow
|
|
||||||
"tools/game-data-capture/tests/contract/test_export_golden.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-016.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-016. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and GDC-013 passed review. Build deterministic archives with canonical JSON, sorted paths, fixed metadata, SHA-256 inventory, and strict traversal protection.
|
|
||||||
|
|
||||||
Keep evidence archives separate from bounded ISAC handoffs. Exclude screenshots from handoffs by default. Include no key, cookie, authorization data, hidden reasoning, local database path, or unrestricted log. Preserve export lineage rather than rewriting an old archive.
|
|
||||||
|
|
||||||
Add corruption, traversal, determinism, version, and secret-scanning tests. Write `handoffs/GDC-016.md` and do not commit or push.
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements the authenticated ISAC capture-intake boundary, handoff validation, dependency planning, permission display, and mutation safeguards.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-sol
|
|
||||||
variant: high
|
|
||||||
steps: 110
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"src/lib/isac/capture-intake/types.ts": allow
|
|
||||||
"src/lib/isac/capture-intake/schema.ts": allow
|
|
||||||
"src/lib/isac/capture-intake/plan.ts": allow
|
|
||||||
"src/lib/isac/capture-intake/*.test.ts": allow
|
|
||||||
"src/routes/isac/capture/**": allow
|
|
||||||
"src/routes/isac/+layout.svelte": allow
|
|
||||||
"src/app.css": allow
|
|
||||||
"docs/DESIGN_SYSTEM.md": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-018.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
skill:
|
|
||||||
"*": deny
|
|
||||||
"frontend-design": allow
|
|
||||||
"impeccable": allow
|
|
||||||
"stop-slop": allow
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-018. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, GDC-016 and H4 passed, and dependency handoffs were read. Preserve the existing ISAC trust boundary. Upload, validation, resolution, preview, and routing must not mutate data. Require `data.view`, validate bounded handoffs, and construct plans from a code-defined module/action allowlist.
|
|
||||||
|
|
||||||
Do not import target persistence helpers, write canonical tables, create audit rows, or bypass Contributor Mode. Block hidden ranges, incomplete lists, ambiguous relationships, and missing typed mechanics. State whether confirmation would publish or propose.
|
|
||||||
|
|
||||||
Use project UI skills and load `stop-slop` before any design-system edit. Update the design system only for reusable decisions. Add permission, version, bounds, and no-mutation tests. Write `handoffs/GDC-018.md` and do not commit or push.
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
---
|
|
||||||
description: Adapts reviewed Skill Chip and OS Protocol capture drafts into existing ISAC editors without fabricating relationships or mechanic programs.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: high
|
|
||||||
steps: 100
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"src/lib/isac/capture-intake/adapters/skillchips.ts": allow
|
|
||||||
"src/lib/isac/capture-intake/adapters/protocols.ts": allow
|
|
||||||
"src/lib/isac/capture-intake/adapters/progression.test.ts": allow
|
|
||||||
"src/routes/isac/skillchips/+page.svelte": allow
|
|
||||||
"src/routes/isac/protocols/+page.svelte": allow
|
|
||||||
"src/routes/isac/skillchips/capture-intake.test.ts": allow
|
|
||||||
"src/routes/isac/protocols/capture-intake.test.ts": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-020.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
skill:
|
|
||||||
"*": deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-020. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and GDC-018 and GDC-010 passed review.
|
|
||||||
|
|
||||||
Prefill only reviewed evidence. Resolve abilities and Attributes without fuzzy auto-selection. Keep shared Protocol profiles separate. Do not fabricate `conditionalRule`; require a reviewed contract-valid program. Preserve capacity checks, icon reset, result tokens, Contributor Mode, readiness refresh, and audit behavior.
|
|
||||||
|
|
||||||
Tests must assert audit actor, category, action, target, and complete owned relationship evidence. Write `handoffs/GDC-020.md` and do not commit or push.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
---
|
|
||||||
description: Adapts reviewed Specialization, Ability, Focus, assignment, tree-node, and rank drafts while protecting complete graph replacement semantics.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-sol
|
|
||||||
variant: high
|
|
||||||
steps: 110
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"src/lib/isac/capture-intake/adapters/specializations.ts": allow
|
|
||||||
"src/lib/isac/capture-intake/adapters/specializations.test.ts": allow
|
|
||||||
"src/routes/isac/specializations/+page.svelte": allow
|
|
||||||
"src/routes/isac/specializations/capture-intake.test.ts": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-021.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
skill:
|
|
||||||
"*": deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-021. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and GDC-018 and GDC-011 passed review.
|
|
||||||
|
|
||||||
Require positive parent resolution. Do not guess tree positions, rank costs, Ability targets, intrinsic attacks, or mechanics. Require a complete current-state comparison and confirmation for node updates. Pause after preparing the first action plan until the owner passes H5. Preserve canonical actions, readiness refresh, editor lifecycle, Contributor Mode, and audit behavior.
|
|
||||||
|
|
||||||
Tests must assert audit actor, category, action, target, and complete graph evidence. Write `handoffs/GDC-021.md` and do not commit or push.
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
---
|
|
||||||
description: Adapts reviewed Weapon and Gear capture drafts into existing ISAC editors while preserving validation, moderation, audit, and editor lifecycle.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: high
|
|
||||||
steps: 90
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"src/lib/isac/capture-intake/adapters/weapons.ts": allow
|
|
||||||
"src/lib/isac/capture-intake/adapters/gear.ts": allow
|
|
||||||
"src/lib/isac/capture-intake/adapters/weapon-gear.test.ts": allow
|
|
||||||
"src/routes/isac/weapons/+page.svelte": allow
|
|
||||||
"src/routes/isac/gear/+page.svelte": allow
|
|
||||||
"src/routes/isac/weapons/capture-intake.test.ts": allow
|
|
||||||
"src/routes/isac/gear/capture-intake.test.ts": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-019.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
skill:
|
|
||||||
"*": deny
|
|
||||||
"import-weapon-screenshot": allow
|
|
||||||
"import-talent-screenshot": allow
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-019. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and GDC-018 and GDC-009 passed review. Read current editor and action contracts before relying on field names.
|
|
||||||
|
|
||||||
Prefill only reviewed evidence. Do not auto-select ambiguous Talents, Brands, or Attributes. Keep hidden ranges, incomplete pools, augments, and mod slots unresolved. Do not auto-submit. Preserve existing actions, result tokens, add/edit lifecycle, Contributor Mode, and audit behavior.
|
|
||||||
|
|
||||||
Tests must assert audit actor, category, action, target, and before/after relationship evidence. Write `handoffs/GDC-019.md` and do not commit or push.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements automatic OpenAI Responses API vision fallback, structured schemas, retries, duplicate suppression, and secret-safe handling.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: high
|
|
||||||
steps: 90
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/vision/openai_provider.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/vision/prompts.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/vision/schemas.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/vision/privacy.py": allow
|
|
||||||
"tools/game-data-capture/tests/unit/vision/test_openai_provider.py": allow
|
|
||||||
"tools/game-data-capture/tests/contract/test_openai_schema.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-012.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-012. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and GDC-007 and GDC-008 passed review. Use a fixed configurable OpenAI vision model through the Responses API with `store: false`, no tools, original image detail when supported, and strict Structured Outputs.
|
|
||||||
|
|
||||||
Automatic fallback has owner approval and no cost ceiling. Keep retries bounded and suppress duplicate requests. Send useful crops when possible. Treat local OCR as untrusted input and validate every response locally. Never log or persist keys, headers, base64 payloads, unrestricted responses, or hidden reasoning.
|
|
||||||
|
|
||||||
Use fake clients in tests. Do not run a live request without coordinator approval. Write `handoffs/GDC-012.md` and do not commit or push.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements evidence extraction for Skill Chips and OS Protocols, including instance values, shared profiles, bonuses, and Talent prose.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: high
|
|
||||||
steps: 90
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/extractors/skill_chip.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/extractors/os_protocol.py": allow
|
|
||||||
"tools/game-data-capture/tests/unit/extractors/test_skill_chip.py": allow
|
|
||||||
"tools/game-data-capture/tests/unit/extractors/test_os_protocol.py": allow
|
|
||||||
"tools/game-data-capture/tests/evaluation/test_progression_items.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-010.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-010. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and GDC-008 passed review. Read current Skill Chip, OS Protocol, Attribute, mechanic, and Specialization Ability contracts before editing.
|
|
||||||
|
|
||||||
Separate configured instance quality, level, rolls, and upgrades from reusable definitions. Keep the OS Protocol secondary profile separate from an individual Protocol. Capture Talent prose and values without inventing events, reducers, state, expressions, or scopes. Missing pools, endpoints, profiles, abilities, or Attributes remain unresolved.
|
|
||||||
|
|
||||||
Add ambiguity and interpolation tests. Write `handoffs/GDC-010.md` and do not commit or push.
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements the game-data-capture Python package, configuration, diagnostics, dependency lock, and approved web and OCR dependency integration.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: high
|
|
||||||
steps: 100
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/pyproject.toml": allow
|
|
||||||
"tools/game-data-capture/uv.lock": allow
|
|
||||||
"tools/game-data-capture/.python-version": allow
|
|
||||||
"tools/game-data-capture/config.example.toml": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/__init__.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/__main__.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/cli.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/config.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/paths.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/logging.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/doctor.py": allow
|
|
||||||
"tools/game-data-capture/tests/unit/test_config.py": allow
|
|
||||||
"tools/game-data-capture/tests/unit/test_cli.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-002.md": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-007.md": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-005.md": allow
|
|
||||||
bash: ask
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own Python packaging and dependency integration. The coordinator assigns GDC-002, the dependency-integration phase of GDC-005, or the dependency-integration phase of GDC-007. Refuse implementation unless `STATUS.md` marks that task `in_progress`. Read the task and dependency handoffs before editing.
|
|
||||||
|
|
||||||
Keep imports side-effect free. Use Python 3.12 through `uv`, XDG runtime paths, typed configuration, redacted logs, and explicit dependency pins. Leave runtime and subsystem wiring to `gdc-runtime-integration`.
|
|
||||||
|
|
||||||
Do not absorb storage, OCR, web, media, or provider implementations into core modules. Request contract changes through your handoff. Add focused tests, write the task-specific handoff, and do not commit or push.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements capture grouping, duplicate reuse, correction history, merge and split behavior, review states, undo, and ISAC-readiness checks.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: high
|
|
||||||
steps: 90
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/grouping/**": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/review/**": allow
|
|
||||||
"tools/game-data-capture/tests/unit/grouping/**": allow
|
|
||||||
"tools/game-data-capture/tests/unit/review/**": allow
|
|
||||||
"tools/game-data-capture/tests/integration/test_review_lifecycle.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-013.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-013. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and GDC-009 through GDC-012 passed review.
|
|
||||||
|
|
||||||
Preserve every source and model result. Exact duplicate reuse may be automatic; semantic merges require evidence or user confirmation. Keep evidence-reviewed separate from ISAC-ready. Append correction and undo history, reject stale mutations, and block readiness for missing ranges, pools, relationships, or mechanics.
|
|
||||||
|
|
||||||
Do not edit runtime, storage, provider, extractor, or web code. Write `handoffs/GDC-013.md` and do not commit or push.
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
---
|
|
||||||
description: Builds the local capture-labeling and evidence-review interface with accessible states, responsive layouts, and local-only assets.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: medium
|
|
||||||
steps: 90
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/web/templates/**": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/web/static/**": allow
|
|
||||||
"tools/game-data-capture/tests/integration/test_capture_labeling.py": allow
|
|
||||||
"tools/game-data-capture/tests/integration/test_review_ui.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-005.md": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-014.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
skill:
|
|
||||||
"*": deny
|
|
||||||
"frontend-design": allow
|
|
||||||
"impeccable": allow
|
|
||||||
---
|
|
||||||
|
|
||||||
You own the interface portion of GDC-005 or GDC-014. Refuse implementation unless the coordinator assigns one task, `STATUS.md` marks it `in_progress`, the backend handoff exists, and dependencies passed review. Load `frontend-design` and `impeccable` before design work. The coordinator performs browser validation with `webapp-testing`.
|
|
||||||
|
|
||||||
Build a restrained local operations interface for dense evidence review. Cover keyboard navigation, focus-visible, pending, disabled, error, success, empty, narrow, and reduced-motion states. Use text with color for state. Load no CDN asset. Never auto-arm capture, approve evidence, export, delete, or submit to ISAC.
|
|
||||||
|
|
||||||
Coordinate API needs through the handoff instead of editing backend files. Provide desktop and narrow-layout acceptance cases for coordinator browser validation, update the assigned handoff, and do not commit or push.
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
---
|
|
||||||
description: Wires completed capture, web, OCR, provider, video, review, and export services into CLI commands and one supervised runtime.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: high
|
|
||||||
steps: 100
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/cli.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/runtime.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/pipeline/coordinator.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/pipeline/supervisor.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/pipeline/events.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/evaluation/**": allow
|
|
||||||
"tools/game-data-capture/tests/unit/pipeline/**": allow
|
|
||||||
"tools/game-data-capture/tests/integration/test_runtime_cli.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-004.md": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-005.md": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-014.md": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-015.md": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-016.md": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-017.md": allow
|
|
||||||
bash: ask
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You integrate completed services; you do not reimplement them. The coordinator assigns one integration phase of GDC-004, GDC-005, GDC-014, GDC-015, GDC-016, or GDC-017. Refuse implementation unless `STATUS.md` marks that task `in_progress`, the subsystem owner completed its handoff section, and dependencies passed review.
|
|
||||||
|
|
||||||
Wire command and lifecycle boundaries through typed ports. Keep SQLite writes in the main process, start one GPU worker, bound provider and media work, reconcile durable jobs, and stop children in order. Aggregate subsystem evaluation interfaces into versioned reports without duplicating OCR, classifier, or extractor policy. Do not edit `pipeline/jobs.py`; `gdc-storage` owns job leases.
|
|
||||||
|
|
||||||
Append an integration section to the assigned handoff. Do not absorb subsystem policy, commit, or push.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements offline screen-family classification, view and Gear-slot labels, composite record types, region detection, abstention, and evaluation.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: high
|
|
||||||
steps: 90
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
external_directory: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/vision/classifier.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/vision/regions.py": allow
|
|
||||||
"tools/game-data-capture/tests/unit/vision/test_classifier.py": allow
|
|
||||||
"tools/game-data-capture/tests/unit/vision/test_regions.py": allow
|
|
||||||
"tools/game-data-capture/tests/evaluation/test_classifier.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-008.md": allow
|
|
||||||
bash: ask
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-008. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, H3 selected the OCR engine, and GDC-007 passed review.
|
|
||||||
|
|
||||||
Classify family, view, exact Gear slot, record types, confidence, and abstention. Support composite screens. Use normalized geometry or detected anchors rather than fixed 4K coordinates. Evaluate by capture session and report confusion matrices, class recall, false positives, and abstention. Never call a network service.
|
|
||||||
|
|
||||||
Do not edit OCR, provider, extractor, or domain contracts. Write `handoffs/GDC-008.md` and do not commit or push.
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
---
|
|
||||||
description: Performs independent read-only security, privacy, data-integrity, and ISAC-boundary review for the complete game-data-capture implementation.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-sol
|
|
||||||
variant: high
|
|
||||||
steps: 100
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-024.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-024 and remain read-only outside its ignored handoff. Refuse review unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and GDC-022 and GDC-023 passed. Report findings first, ordered by severity.
|
|
||||||
|
|
||||||
Inspect clipboard helper environments, media limits, subprocess commands, storage recovery, paths and symlinks, GPU queues, OpenAI secrets and payloads, localhost security, XSS, CSRF, archives, ISAC permissions, moderation, audit evidence, editor lifecycle, and data semantics. Read tests and identify assertions that cannot catch regressions.
|
|
||||||
|
|
||||||
Each finding needs affected files, reproduction, impact, required correction, and verification. High or critical findings block release. Recheck owner fixes and record dispositions. Do not patch implementation, commit, or push.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements extraction for Specialization identities, overview screens, abilities, focuses, trees, nodes, ranks, effects, and costs.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-sol
|
|
||||||
variant: high
|
|
||||||
steps: 110
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/extractors/specialization/**": allow
|
|
||||||
"tools/game-data-capture/tests/unit/extractors/specialization/**": allow
|
|
||||||
"tools/game-data-capture/tests/evaluation/test_specializations.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-011.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-011. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and GDC-008 passed review. Read the full Specialization route, readiness code, Skill Chip capacity rules, mechanic contracts, and architecture documentation before editing.
|
|
||||||
|
|
||||||
Model overview screens as composite evidence. Preserve parent, coordinate, rank, effect, cost, cadence, charge, and intrinsic-damage uncertainty. Do not emit derived readiness or infer executable mechanics from prose. Link fragments only with positive identity evidence.
|
|
||||||
|
|
||||||
Test incomplete trees, scrolling, free Ability nodes, multi-rank nodes, modified Ability values, and ambiguous parents. Write `handoffs/GDC-011.md` and do not commit or push.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements utility-local SQLite migrations, content-addressed blobs, durable jobs, provenance, recovery, safe purge behavior, and approved GDC-005 corpus persistence.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: medium
|
|
||||||
steps: 80
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/migrations/**": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/storage/**": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/pipeline/jobs.py": allow
|
|
||||||
"tools/game-data-capture/tests/unit/storage/**": allow
|
|
||||||
"tools/game-data-capture/tests/integration/storage/**": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-003.md": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-005.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-003 persistence or the approved persistence phase of GDC-005. Refuse implementation unless the coordinator assigns one task, `STATUS.md` marks it `in_progress`, and its dependencies passed review. Read the frozen contracts and dependency handoffs before editing.
|
|
||||||
|
|
||||||
Keep the utility database separate from SHD.build. Use checksummed additive migrations, foreign keys, WAL, atomic blob installation, compare-and-swap transitions, and recoverable leases. Model capture occurrences separately from blob identity. Test crashes at transaction and filesystem boundaries.
|
|
||||||
|
|
||||||
For GDC-005, implement only the reviewed durable corpus-port operations described in its task and handoff. Do not edit domain contracts, pipeline policy, routes, or application database code. Write the assigned task handoff and do not commit or push.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
---
|
|
||||||
description: Adds cross-subsystem integration, contract, recovery, synthetic-media, secret-scanning, and ISAC workflow tests without editing production code.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.3-codex-spark
|
|
||||||
variant: medium
|
|
||||||
steps: 90
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/tests/integration/test_end_to_end.py": allow
|
|
||||||
"tools/game-data-capture/tests/integration/test_crash_recovery.py": allow
|
|
||||||
"tools/game-data-capture/tests/integration/test_secret_exclusion.py": allow
|
|
||||||
"tools/game-data-capture/tests/fixtures/e2e/**": allow
|
|
||||||
"src/lib/isac/capture-intake/end-to-end.test.ts": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-022.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-022. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and every dependency passed review. Add tests only after subsystem contracts and implementations exist. Do not repair production code; report each failure to its owner.
|
|
||||||
|
|
||||||
Use synthetic or approved fixtures. Keep proprietary screenshots and private corpus data out of Git. Test end-to-end state, restart recovery, provider failures, media bounds, numeric disagreements, secret exclusion, manager publication, Contributor Mode proposals, and canonical readback. Assert audit actor, category, action, target, and before/after relationship evidence. Ensure tests fail when intended behavior is removed.
|
|
||||||
|
|
||||||
Write `handoffs/GDC-022.md` and do not commit or push.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements bounded FFmpeg and ffprobe video ingestion, tiled stability analysis, sharp-frame selection, lineage, cancellation, and recovery.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: medium
|
|
||||||
steps: 90
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/video/**": allow
|
|
||||||
"tools/game-data-capture/tests/unit/video/**": allow
|
|
||||||
"tools/game-data-capture/tests/integration/test_video_import.py": allow
|
|
||||||
"tools/game-data-capture/tests/fixtures/video/**": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-015.md": allow
|
|
||||||
bash: ask
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-015. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and GDC-008 and GDC-013 passed review.
|
|
||||||
|
|
||||||
Run FFmpeg and ffprobe with argument arrays, private paths, limits, timeouts, bounded stderr, cancellation, and process-group cleanup. Extract no audio. Use tiled motion so animated backgrounds do not hide stable UI. Preserve parent hashes, timestamps, metrics, commands, and policy versions.
|
|
||||||
|
|
||||||
Do not edit web routes, runtime integration, OCR, or storage. Use generated media fixtures. Write `handoffs/GDC-015.md` and do not commit or push.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
---
|
|
||||||
description: Benchmarks local GPU OCR and implements preprocessing, the persistent OCR worker, confidence calibration, and OCR evaluation.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: high
|
|
||||||
steps: 100
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
external_directory: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/ocr/**": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/vision/preprocess.py": allow
|
|
||||||
"tools/game-data-capture/tests/unit/ocr/**": allow
|
|
||||||
"tools/game-data-capture/tests/unit/vision/test_preprocess.py": allow
|
|
||||||
"tools/game-data-capture/tests/gpu/**": allow
|
|
||||||
"tools/game-data-capture/tests/evaluation/test_ocr.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-007.md": allow
|
|
||||||
bash: ask
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own the OCR phase of GDC-007. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and H2 passed. Do not select an OCR engine or threshold before the private corpus passes H2.
|
|
||||||
|
|
||||||
Measure exact numbers, labels, transcript accuracy, latency, VRAM, CUDA provider, model hashes, and confidence calibration. Keep raw OCR separate from normalized values. Never call a network service from local OCR.
|
|
||||||
|
|
||||||
Return dependency pin requests to `gdc-python-core`; do not edit lock files. Write the OCR section of `handoffs/GDC-007.md` and do not commit or push.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements niri and Wayland clipboard image intake, private spooling, and watcher process supervision.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: medium
|
|
||||||
steps: 90
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/ingest/**": allow
|
|
||||||
"tools/game-data-capture/tests/unit/ingest/**": allow
|
|
||||||
"tools/game-data-capture/tests/integration/test_clipboard_watch.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-004.md": allow
|
|
||||||
bash: ask
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-004. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and its dependencies passed review. Use the existing niri focused-window shortcut and `wl-paste`; do not automate game input.
|
|
||||||
|
|
||||||
Run child processes without a shell. Bound bytes, dimensions, stderr, queues, retries, and shutdown. Keep the clipboard helper small and remove OpenAI secrets from its environment. Request coordinator approval for live clipboard or compositor commands; never read clipboard text.
|
|
||||||
|
|
||||||
Do not edit storage, OCR, provider, video, or web code. Add failure tests, write `handoffs/GDC-004.md`, and do not commit or push.
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements evidence-preserving extraction for weapons and Gear across Mask, Gloves, Backpack, Body Armor, Holster, and Knee Pads.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: medium
|
|
||||||
steps: 90
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/extractors/weapon.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/extractors/gear.py": allow
|
|
||||||
"tools/game-data-capture/tests/unit/extractors/test_weapon.py": allow
|
|
||||||
"tools/game-data-capture/tests/unit/extractors/test_gear.py": allow
|
|
||||||
"tools/game-data-capture/tests/evaluation/test_weapon_gear.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-009.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
skill:
|
|
||||||
"*": deny
|
|
||||||
"import-weapon-screenshot": allow
|
|
||||||
---
|
|
||||||
|
|
||||||
You own GDC-009. Refuse implementation unless the coordinator assigns it, `STATUS.md` marks it `in_progress`, and GDC-008 passed review. Read the current Weapon and Gear actions, scaling documentation, and weapon screenshot import skill before editing.
|
|
||||||
|
|
||||||
Preserve displayed values as observations until evidence establishes canonical endpoints. Treat weapon Talent grids as complete unordered sets and mark cropped lists incomplete. Use exact Gear slot rules. Do not guess Talent, Brand, or Attribute IDs, hidden ranges, native tiers, mod slots, or compatibility pools.
|
|
||||||
|
|
||||||
Add per-slot and per-view regression tests. Write `handoffs/GDC-009.md` and do not commit or push.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
---
|
|
||||||
description: Implements the loopback FastAPI service, authenticated local sessions, capture and review APIs, request bounds, and browser security.
|
|
||||||
mode: subagent
|
|
||||||
hidden: true
|
|
||||||
model: openai/gpt-5.6-terra
|
|
||||||
variant: high
|
|
||||||
steps: 100
|
|
||||||
permission:
|
|
||||||
"*": deny
|
|
||||||
read:
|
|
||||||
"*": allow
|
|
||||||
"*.env": deny
|
|
||||||
"*.env.*": deny
|
|
||||||
"*.env.example": allow
|
|
||||||
"data/**": deny
|
|
||||||
"**/data/**": deny
|
|
||||||
"**/*.db": deny
|
|
||||||
"**/*.db-*": deny
|
|
||||||
glob: allow
|
|
||||||
grep: ask
|
|
||||||
edit:
|
|
||||||
"*": deny
|
|
||||||
"tools/game-data-capture/src/game_data_capture/web/*.py": allow
|
|
||||||
"tools/game-data-capture/src/game_data_capture/web/routes/**": allow
|
|
||||||
"tools/game-data-capture/tests/unit/web/**": allow
|
|
||||||
"tools/game-data-capture/tests/integration/test_capture_labeling.py": allow
|
|
||||||
"tools/game-data-capture/tests/integration/test_web_security.py": allow
|
|
||||||
"tools/game-data-capture/tests/integration/test_review_ui.py": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-005.md": allow
|
|
||||||
"tmp/game-data-capture-plan/handoffs/GDC-014.md": allow
|
|
||||||
bash: deny
|
|
||||||
task: deny
|
|
||||||
---
|
|
||||||
|
|
||||||
You own the backend portion of GDC-005 or GDC-014. Refuse implementation unless the coordinator assigns one task, `STATUS.md` marks it `in_progress`, and its dependencies passed review. Bind only to loopback and defend against DNS rebinding, cross-origin mutation, CSRF, XSS, path traversal, oversized uploads, and unauthorized artifacts.
|
|
||||||
|
|
||||||
Use opaque IDs and escaped text. Keep paths, keys, cookies, database internals, and unrestricted OCR/provider content out of responses. Do not embed SQL in routes or duplicate domain policy.
|
|
||||||
|
|
||||||
Coordinate route contracts with `gdc-review-ui`, but do not edit templates or static assets. Add failure-path tests, update the assigned handoff, and do not commit or push.
|
|
||||||
@@ -115,21 +115,39 @@ need database state use the embedded Turso engine.
|
|||||||
- **ISAC namespace**: `/isac` is the admin console; `/admin` redirects there
|
- **ISAC namespace**: `/isac` is the admin console; `/admin` redirects there
|
||||||
via 308.
|
via 308.
|
||||||
|
|
||||||
|
## Long-running development processes
|
||||||
|
|
||||||
|
Use the installed `pi-background-tasks` extension's `bg_run` tool for
|
||||||
|
development servers, watchers, and other persistent commands. Do not use
|
||||||
|
foreground shell sessions, `nohup`, `&`, or process-tree/PID discovery when a
|
||||||
|
tracked task exists.
|
||||||
|
|
||||||
|
- Use `isAgent: false` and a concise, descriptive task name.
|
||||||
|
- Retain the returned task ID and output path. Inspect a task with `bg_status`
|
||||||
|
and read its output with `bg_logs`.
|
||||||
|
- Stop a task with `bg_kill <task-id>`. To restart it, start a new `bg_run` task
|
||||||
|
and retain its new task ID and output path; do not search for and kill
|
||||||
|
processes.
|
||||||
|
- Do not leave duplicate tracked tasks for the same development service.
|
||||||
|
|
||||||
## Development server lifecycle
|
## Development server lifecycle
|
||||||
|
|
||||||
After changing source code, styles, configuration, dependencies, or routes,
|
After changing source code, styles, configuration, dependencies, or routes,
|
||||||
restart the development server before handing work back.
|
restart the development server before handing work back.
|
||||||
|
|
||||||
1. Inspect running processes and stop only this project's development-server
|
1. Start `npm run dev` from the repository root with `bg_run`, using
|
||||||
process tree.
|
`isAgent: false` and a descriptive task name. Retain its task ID and output
|
||||||
2. Start one hidden `npm run dev` process from the repository root. Do not leave
|
path.
|
||||||
duplicate project development servers running.
|
2. Before starting a replacement, stop the existing tracked development-server
|
||||||
3. Confirm the server accepts an HTTP request before completing the task.
|
task with `bg_kill <task-id>` so only one server runs.
|
||||||
4. Leave the verified development server running so the project owner can review
|
3. Confirm the task logs show the server started, then confirm it accepts an
|
||||||
the changes.
|
HTTP request before completing the task.
|
||||||
|
4. Leave the verified tracked task running so the project owner can review the
|
||||||
|
changes.
|
||||||
|
|
||||||
Database maintenance may require the server to remain stopped while the embedded
|
Database maintenance may require the server to remain stopped while the embedded
|
||||||
database is locked. Restart and verify the server after maintenance completes.
|
database is locked. Stop its tracked task first, then restart and verify it
|
||||||
|
after maintenance completes.
|
||||||
|
|
||||||
## SHD design-system maintenance
|
## SHD design-system maintenance
|
||||||
|
|
||||||
@@ -245,7 +263,7 @@ database. The command refuses to merge into a populated database.
|
|||||||
|
|
||||||
## Development database maintenance
|
## Development database maintenance
|
||||||
|
|
||||||
1. Stop only this project's development-server process tree.
|
1. Stop this project's tracked development-server task with `bg_kill <task-id>`.
|
||||||
2. Run and verify `npm run db:backup`. Retain and report the backup path.
|
2. Run and verify `npm run db:backup`. Retain and report the backup path.
|
||||||
3. Apply the migration or maintenance operation. Verify schema state, record
|
3. Apply the migration or maintenance operation. Verify schema state, record
|
||||||
counts, and critical entities.
|
counts, and critical entities.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ name: shd-build-production
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: ${SHD_IMAGE:-shd-build-production:local}
|
image: ${SHD_IMAGE:-shd-build-production:local}
|
||||||
restart: unless-stopped
|
restart: always
|
||||||
init: true
|
init: true
|
||||||
environment:
|
environment:
|
||||||
INFISICAL_PROJECT_ID: 2ee94b27-e599-4194-9797-3372d42e53f6
|
INFISICAL_PROJECT_ID: 2ee94b27-e599-4194-9797-3372d42e53f6
|
||||||
@@ -42,7 +42,7 @@ services:
|
|||||||
|
|
||||||
scheduler:
|
scheduler:
|
||||||
image: ${SHD_IMAGE:-shd-build-production:local}
|
image: ${SHD_IMAGE:-shd-build-production:local}
|
||||||
restart: unless-stopped
|
restart: always
|
||||||
init: true
|
init: true
|
||||||
command:
|
command:
|
||||||
- scheduler
|
- scheduler
|
||||||
|
|||||||
@@ -142,6 +142,10 @@ describe('container runtime startup', () => {
|
|||||||
{ key: 'APP_ORIGIN', value: 'https://exported.invalid' },
|
{ key: 'APP_ORIGIN', value: 'https://exported.invalid' },
|
||||||
{ key: 'DISCORD_CLIENT_ID', value: 'exported-discord-client' },
|
{ key: 'DISCORD_CLIENT_ID', value: 'exported-discord-client' },
|
||||||
{ key: 'DISCORD_CLIENT_SECRET', value: 'exported-discord-secret' },
|
{ key: 'DISCORD_CLIENT_SECRET', value: 'exported-discord-secret' },
|
||||||
|
{
|
||||||
|
key: 'MFA_TOTP_ENCRYPTION_KEYS',
|
||||||
|
value: 'totp-encryption-keyring',
|
||||||
|
},
|
||||||
]),
|
]),
|
||||||
writeExport(sharedExport, [
|
writeExport(sharedExport, [
|
||||||
{ key: 'SCHEDULER_TOKEN', value: schedulerToken },
|
{ key: 'SCHEDULER_TOKEN', value: schedulerToken },
|
||||||
@@ -220,6 +224,7 @@ describe('container runtime startup', () => {
|
|||||||
APP_ORIGIN: 'https://stage.shd.build',
|
APP_ORIGIN: 'https://stage.shd.build',
|
||||||
DISCORD_CLIENT_ID: 'exported-discord-client',
|
DISCORD_CLIENT_ID: 'exported-discord-client',
|
||||||
DISCORD_CLIENT_SECRET: 'exported-discord-secret',
|
DISCORD_CLIENT_SECRET: 'exported-discord-secret',
|
||||||
|
MFA_TOTP_ENCRYPTION_KEYS: 'totp-encryption-keyring',
|
||||||
ORIGIN: 'https://stage.shd.build',
|
ORIGIN: 'https://stage.shd.build',
|
||||||
PASSKEY_RP_ID: 'shd.build',
|
PASSKEY_RP_ID: 'shd.build',
|
||||||
SCHEDULER_TOKEN: schedulerToken,
|
SCHEDULER_TOKEN: schedulerToken,
|
||||||
|
|||||||
+34
-11
@@ -2390,9 +2390,11 @@ display `ADVANCED`, and stay visible in the generated script. State rows expose
|
|||||||
label, agent or target scope, initial value, bounds, best-case value, and
|
label, agent or target scope, initial value, bounds, best-case value, and
|
||||||
optional expiry. A state change that writes a target pool uses a `STATE OWNER`
|
optional expiry. A state change that writes a target pool uses a `STATE OWNER`
|
||||||
segmented control with `EVENT TARGET` and `EVENT ACTOR`; the selected owner
|
segmented control with `EVENT TARGET` and `EVENT ACTOR`; the selected owner
|
||||||
remains visible in the generated script. Outcome rows expose kind, target scope,
|
remains visible in the generated script. The `DERIVED VALUE` operation writes
|
||||||
Attribute or resource when applicable, value basis, state scaling, phase, and
|
the branch reducer's numeric result into the selected pool and displays that
|
||||||
every source-specific value column. The Value Model control supports static
|
link as a text readout instead of a numeric input. Outcome rows expose kind,
|
||||||
|
target scope, Attribute or resource when applicable, value basis, state scaling,
|
||||||
|
phase, and every source-specific value column. The Value Model control supports
|
||||||
values, live Attribute totals, percentage-normalized Attribute values, event
|
values, live Attribute totals, percentage-normalized Attribute values, event
|
||||||
fields, reducer output, source-defined scenario controls, and OS Protocol
|
fields, reducer output, source-defined scenario controls, and OS Protocol
|
||||||
upgrade scaling where the source permits it. Selecting either Attribute model
|
upgrade scaling where the source permits it. Selecting either Attribute model
|
||||||
@@ -2404,6 +2406,15 @@ applied to the stored value. Restoration outcomes also expose an `INSTANT` or
|
|||||||
field and describes the configured value as the total delivered across that
|
field and describes the configured value as the total delivered across that
|
||||||
duration; switching back to instant clears the duration.
|
duration; switching back to instant clears the duration.
|
||||||
|
|
||||||
|
Selecting `DERIVE A SCALAR VALUE` exposes a `DERIVED VALUE SOURCE`
|
||||||
|
`SegmentedChoice`. `STATIC` shows the numeric value input. `EVENT FIELD` shows
|
||||||
|
the numeric fields registered for the branch event, including the proximity
|
||||||
|
snapshot's allied-Agent count; it never asks the author to type a field path.
|
||||||
|
Keep an existing expression that falls outside these manual patterns visible as
|
||||||
|
disabled `ADVANCED` data. The source and field controls retain `SegmentedChoice`
|
||||||
|
radio semantics, visible legends, arrow-key operation, focus-visible treatment,
|
||||||
|
and text labels.
|
||||||
|
|
||||||
Target-scoped direct-damage outcomes offer `HIT-TARGET EXPLOSION` in the Area
|
Target-scoped direct-damage outcomes offer `HIT-TARGET EXPLOSION` in the Area
|
||||||
Targeting control. Selecting it creates an event-target-centered enemy area with
|
Targeting control. Selecting it creates an event-target-centered enemy area with
|
||||||
a five-meter starting radius, reveals the required radius field, and shows a
|
a five-meter starting radius, reveals the required radius field, and shows a
|
||||||
@@ -2421,13 +2432,15 @@ source program, where authors may edit its exposed branch, state, and outcome
|
|||||||
controls. The recipe label records provenance only. `REPLACE PROGRAM` requires
|
controls. The recipe label records provenance only. `REPLACE PROGRAM` requires
|
||||||
an inline confirmation that states all existing branches will be discarded.
|
an inline confirmation that states all existing branches will be discarded.
|
||||||
|
|
||||||
Builder Controls define source-specific scenario assumptions for the planned
|
Builder Controls define source-specific scenario assumptions for player
|
||||||
player simulation. ISAC authors choose toggle, slider, or numeric input and
|
simulation. ISAC authors choose toggle, slider, or numeric input and provide a
|
||||||
provide a label, Agent guidance, default value, best-case value, and numeric
|
label, Agent guidance, default value, best-case value, and numeric bounds when
|
||||||
bounds when applicable. The current player UI does not render these controls or
|
applicable. The calculation timeline builder can consume values supplied for
|
||||||
read their saved values. It uses an explicit `COMING SOON` panel for the future
|
these controls, including `allies-within-30m-with-agent-granted-buff`. Current
|
||||||
advanced scenario surface. The authored contract still requires the runtime to
|
Builder and shared Build pages do not render the controls or let players change
|
||||||
report `REQUIRES ASSUMPTIONS` when a required cadence or environmental value is
|
those values. They use an explicit `COMING SOON` panel for the future advanced
|
||||||
|
scenario surface. The authored contract still requires the runtime to report
|
||||||
|
`REQUIRES ASSUMPTIONS` when a required cadence or environmental value is
|
||||||
missing; the editor and runtime never invent a cadence.
|
missing; the editor and runtime never invent a cadence.
|
||||||
|
|
||||||
Use `--shd-model-surface` and `--shd-model-border` for mechanic programs,
|
Use `--shd-model-surface` and `--shd-model-border` for mechanic programs,
|
||||||
@@ -2478,7 +2491,10 @@ The panel presents five explicit states:
|
|||||||
polite live region, and disables repeat actions.
|
polite live region, and disables repeat actions.
|
||||||
- `CLARIFICATION REQUIRED` lists each focused question with its reason and
|
- `CLARIFICATION REQUIRED` lists each focused question with its reason and
|
||||||
bounded answer control. The author may answer, revise the source text, or
|
bounded answer control. The author may answer, revise the source text, or
|
||||||
return to manual authoring.
|
return to manual authoring. For an unresolved numbered numeric placeholder,
|
||||||
|
show a required clarification for every source value column whose number is
|
||||||
|
missing. Do not prefill, infer, or suggest a percentage. Keep
|
||||||
|
`APPLY TO EDITOR` unavailable until the author answers each required variant.
|
||||||
- `CAPABILITY GAP` names the unsupported behavior and displays the developer
|
- `CAPABILITY GAP` names the unsupported behavior and displays the developer
|
||||||
handoff, supported approximation, and optional workaround in separate labeled
|
handoff, supported approximation, and optional workaround in separate labeled
|
||||||
blocks. Do not present a capability gap as validation failure.
|
blocks. Do not present a capability gap as validation failure.
|
||||||
@@ -2502,6 +2518,13 @@ applying the draft would replace configured branches, pools, controls, or
|
|||||||
outcomes. The standard ISAC save lifecycle begins only after the author reviews
|
outcomes. The standard ISAC save lifecycle begins only after the author reviews
|
||||||
and saves the resulting form.
|
and saves the resulting form.
|
||||||
|
|
||||||
|
An applied AI draft stores each clarified number in the corresponding
|
||||||
|
human-facing outcome-variant value. A scaling expression may retain the
|
||||||
|
draft-only `variant_value` reference so later saves preserve the link to that
|
||||||
|
displayed value. The runtime adapter resolves the reference separately for each
|
||||||
|
variant. Show the value and generated formula in the ordinary review controls;
|
||||||
|
do not require an author to inspect or edit raw JSON.
|
||||||
|
|
||||||
Use text labels with status colors. Orange marks Generate, Answer, and Apply
|
Use text labels with status colors. Orange marks Generate, Answer, and Apply
|
||||||
actions. Blue identifies modeled information, yellow marks assumptions or
|
actions. Blue identifies modeled information, yellow marks assumptions or
|
||||||
unsupported coverage, and red marks request or validation failures. Never use
|
unsupported coverage, and red marks request or validation failures. Never use
|
||||||
|
|||||||
@@ -79,6 +79,24 @@ meters:
|
|||||||
duration.
|
duration.
|
||||||
- The wearer does not count as an ally.
|
- The wearer does not count as an ally.
|
||||||
|
|
||||||
|
## Close and Personal
|
||||||
|
|
||||||
|
Close and Personal uses a proximity-snapshot qualifying set:
|
||||||
|
|
||||||
|
- Include each distinct allied Agent whose distance from the source Agent is
|
||||||
|
less than or equal to 30 meters and who has at least one buff granted by that
|
||||||
|
source Agent.
|
||||||
|
- Count an ally once when several of the source Agent's buffs qualify.
|
||||||
|
- Do not include an ally whose qualifying buffs come only from other sources.
|
||||||
|
|
||||||
|
Record the resolved non-negative integer count in
|
||||||
|
`proximity_snapshot.values.alliesWithin30MetersWithAgentGrantedBuff`. The
|
||||||
|
calculation builder supplies it from the
|
||||||
|
`allies-within-30m-with-agent-granted-buff` scenario input. The confirmed rule
|
||||||
|
defines this qualifying set but does not provide Close and Personal's base
|
||||||
|
percentage. Keep that percentage unresolved until source evidence or an author's
|
||||||
|
clarification supplies it.
|
||||||
|
|
||||||
## Emergency Retreat
|
## Emergency Retreat
|
||||||
|
|
||||||
Emergency Retreat activates whenever at least one enemy is within 20 meters and
|
Emergency Retreat activates whenever at least one enemy is within 20 meters and
|
||||||
|
|||||||
+43
-13
@@ -638,12 +638,24 @@ normalized events are `shot_fired`, `weapon_hit`, `skill_deployed`,
|
|||||||
`weapon_swapped`, and `tick`. Event details such as target, distance, critical
|
`weapon_swapped`, and `tick`. Event details such as target, distance, critical
|
||||||
state, source, movement, and nearby counts travel as typed event values. The
|
state, source, movement, and nearby counts travel as typed event values. The
|
||||||
event registry publishes authoritative field metadata where branches need a
|
event registry publishes authoritative field metadata where branches need a
|
||||||
bounded contract. For those events, the mechanic editor derives its event-field
|
bounded contract. For those events, the mechanic editor derives event-field
|
||||||
controls from that metadata. For example, `weapon_hit.values.headshot` is a
|
conditions and manual derived-scalar controls from that metadata.
|
||||||
boolean that distinguishes headshots from body shots. `enemy_killed` and
|
`proximity_snapshot.values.alliesWithin30MetersWithAgentGrantedBuff` is an
|
||||||
`assist` may carry `values.assignedAbilityActive`; a Skill Chip Talent uses that
|
optional non-negative integer. It contains the resolved count of distinct allied
|
||||||
boolean to restrict a kill or assist trigger to its assigned ability's active
|
Agents at a distance less than or equal to 30 meters who have at least one buff
|
||||||
period. `skill_deployed` and `signature_ability_deployed` may carry
|
granted by the source Agent. Several qualifying buffs on one ally count once.
|
||||||
|
`proximity_snapshot` remains non-strict, so saved mechanic data and producers
|
||||||
|
that omit the field remain compatible.
|
||||||
|
|
||||||
|
`src/lib/builder/loadout-combat.ts` bridges the calculation scenario input
|
||||||
|
`allies-within-30m-with-agent-granted-buff` into that field on each generated
|
||||||
|
`proximity_snapshot`. For example, a derived-scalar reducer can select the
|
||||||
|
registered numeric field without requiring a hand-entered path. Other registered
|
||||||
|
metadata includes `weapon_hit.values.headshot`, a boolean that distinguishes
|
||||||
|
headshots from body shots. `enemy_killed` and `assist` may carry
|
||||||
|
`values.assignedAbilityActive`; a Skill Chip Talent uses that boolean to
|
||||||
|
restrict a kill or assist trigger to its assigned ability's active period.
|
||||||
|
`skill_deployed` and `signature_ability_deployed` may carry
|
||||||
`values.assignedAbilityDurationSeconds` to start assigned-ability state. The
|
`values.assignedAbilityDurationSeconds` to start assigned-ability state. The
|
||||||
runtime expires that state before it evaluates another event at the same
|
runtime expires that state before it evaluates another event at the same
|
||||||
timestamp.
|
timestamp.
|
||||||
@@ -707,11 +719,20 @@ The program graph uses three child tables:
|
|||||||
rolls separately. Restoration outcomes may deliver their total immediately or
|
rolls separately. Restoration outcomes may deliver their total immediately or
|
||||||
continuously over an explicit duration.
|
continuously over an explicit duration.
|
||||||
|
|
||||||
Target-scoped direct damage may use `event_target_area` targeting for an
|
Draft outcome expressions may use `{"ref":{"source":"variant_value"}}`. This
|
||||||
explosion around its event target. This targeting selects valid enemies and
|
reference belongs to the conditional draft grammar and does not enter the
|
||||||
stores a positive radius in meters. It is valid only for `on_event` or
|
runtime expression grammar. Each persisted outcome-variant row keeps its
|
||||||
`after_event` outcomes. The legacy `within_radius` shape remains descriptive
|
human-facing numeric `value`, while `value_expression_json` preserves the
|
||||||
targeting metadata and does not select runtime entities.
|
reference and its semantic link. The adapters in
|
||||||
|
`src/lib/calculations/mechanic-editor.ts` and
|
||||||
|
`src/lib/calculations/mechanic-persistence.ts` resolve the reference from the
|
||||||
|
current row's `value` when they construct a runtime program.
|
||||||
|
|
||||||
|
Target-scoped direct damage may use `event_target_area` targeting for an
|
||||||
|
explosion around its event target. This targeting selects valid enemies and
|
||||||
|
stores a positive radius in meters. It is valid only for `on_event` or
|
||||||
|
`after_event` outcomes. The legacy `within_radius` shape remains descriptive
|
||||||
|
targeting metadata and does not select runtime entities.
|
||||||
|
|
||||||
The generic runtime in `src/lib/calculations/mechanic.ts` processes combat
|
The generic runtime in `src/lib/calculations/mechanic.ts` processes combat
|
||||||
events in timestamp order. Predicates decide whether an event enters a clause.
|
events in timestamp order. Predicates decide whether an event enters a clause.
|
||||||
@@ -800,6 +821,13 @@ descriptions, and calculation metadata. The client cannot replace the system
|
|||||||
instructions, choose arbitrary models, attach files, add tools, or add external
|
instructions, choose arbitrary models, attach files, add tools, or add external
|
||||||
URLs.
|
URLs.
|
||||||
|
|
||||||
|
When source wording contains an unresolved numbered numeric placeholder, the
|
||||||
|
assistant must return `clarification_required` and ask for the value of every
|
||||||
|
required source variant. It must not infer a percentage or other numeric value.
|
||||||
|
After clarification, the draft places each answer in the corresponding
|
||||||
|
outcome-variant `value`; any scaling expression uses the draft-only
|
||||||
|
`variant_value` reference instead of repeating that number.
|
||||||
|
|
||||||
The deterministic draft parser owns the canonical outcome-capability manifest.
|
The deterministic draft parser owns the canonical outcome-capability manifest.
|
||||||
The AI context serializes that manifest instead of maintaining a second outcome
|
The AI context serializes that manifest instead of maintaining a second outcome
|
||||||
grammar. The parser rejects outcome fields absent from the manifest. Contract
|
grammar. The parser rejects outcome fields absent from the manifest. Contract
|
||||||
@@ -896,8 +924,10 @@ events remain the evidence for revision saves and activation.
|
|||||||
Source authors define planned player scenario controls as typed toggle, slider,
|
Source authors define planned player scenario controls as typed toggle, slider,
|
||||||
or numeric inputs. Each input records its label, guidance, bounds when
|
or numeric inputs. Each input records its label, guidance, bounds when
|
||||||
applicable, default value, and best-case value. ISAC retains these definitions
|
applicable, default value, and best-case value. ISAC retains these definitions
|
||||||
for continued mechanic authoring. Current Builder and shared Build pages do not
|
for continued mechanic authoring, and the calculation timeline builder can
|
||||||
render or evaluate them. Their calculation panels display `COMING SOON` instead.
|
consume scenario values supplied by calculation orchestration. Current Builder
|
||||||
|
and shared Build pages do not expose those controls or invoke the simulation.
|
||||||
|
Their calculation panels display `COMING SOON` instead.
|
||||||
|
|
||||||
Loadout schema version 6 stores one focus, selected tree ranks, nine Skill
|
Loadout schema version 6 stores one focus, selected tree ranks, nine Skill
|
||||||
Chips, independent T1/T2 choices for each equipped gear and weapon Talent, a
|
Chips, independent T1/T2 choices for each equipped gear and weapon Talent, a
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const APP_SECRET_NAMES = new Set([
|
|||||||
'OPENAI_MECHANIC_MODEL',
|
'OPENAI_MECHANIC_MODEL',
|
||||||
'OPENAI_MECHANIC_REASONING_EFFORT',
|
'OPENAI_MECHANIC_REASONING_EFFORT',
|
||||||
'OPENAI_MODERATION_API_KEY',
|
'OPENAI_MODERATION_API_KEY',
|
||||||
|
'MFA_TOTP_ENCRYPTION_KEYS',
|
||||||
'ORIGIN',
|
'ORIGIN',
|
||||||
'PASSKEY_RP_ID',
|
'PASSKEY_RP_ID',
|
||||||
'REMOVE_BG_API_KEY',
|
'REMOVE_BG_API_KEY',
|
||||||
|
|||||||
@@ -176,6 +176,38 @@ describe('loadout mechanic timeline', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emits the Agent-granted-buff ally count from its exact scenario input and defaults it to zero', () => {
|
||||||
|
const profile = createDraft().combatProfile;
|
||||||
|
profile.encounterSeconds = 1;
|
||||||
|
const configured = buildWeaponMechanicTimeline(
|
||||||
|
{ rpm: 60, magazine: 100, reload_speed: 0 },
|
||||||
|
profile,
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
scenarioInputs: {
|
||||||
|
'allies-within-30m-with-agent-granted-buff': 4,
|
||||||
|
'allies-within-radius': 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
).filter((event) => event.kind === 'proximity_snapshot');
|
||||||
|
const defaults = buildWeaponMechanicTimeline(
|
||||||
|
{ rpm: 60, magazine: 100, reload_speed: 0 },
|
||||||
|
profile,
|
||||||
|
).filter((event) => event.kind === 'proximity_snapshot');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
configured.map(
|
||||||
|
(event) => event.values?.alliesWithin30MetersWithAgentGrantedBuff,
|
||||||
|
),
|
||||||
|
).toEqual([4, 4]);
|
||||||
|
expect(configured.map((event) => event.values?.allyCount)).toEqual([2, 2]);
|
||||||
|
expect(
|
||||||
|
defaults.map(
|
||||||
|
(event) => event.values?.alliesWithin30MetersWithAgentGrantedBuff,
|
||||||
|
),
|
||||||
|
).toEqual([0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
it('seeds Extra Health state before periodic stack evaluation', () => {
|
it('seeds Extra Health state before periodic stack evaluation', () => {
|
||||||
const profile = createDraft().combatProfile;
|
const profile = createDraft().combatProfile;
|
||||||
profile.encounterSeconds = 2;
|
profile.encounterSeconds = 2;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
} from '$lib/calculations/combat';
|
} from '$lib/calculations/combat';
|
||||||
import {
|
import {
|
||||||
evaluateMechanicProgram,
|
evaluateMechanicProgram,
|
||||||
|
PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_VALUE_KEY,
|
||||||
|
PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_INPUT,
|
||||||
type MechanicCombatContext,
|
type MechanicCombatContext,
|
||||||
type MechanicEvaluation,
|
type MechanicEvaluation,
|
||||||
type MechanicEvent,
|
type MechanicEvent,
|
||||||
@@ -803,6 +805,11 @@ export function buildWeaponMechanicTimeline(
|
|||||||
allyCount: Number(
|
allyCount: Number(
|
||||||
options.scenarioInputs?.['allies-within-radius'] ?? 0,
|
options.scenarioInputs?.['allies-within-radius'] ?? 0,
|
||||||
),
|
),
|
||||||
|
[PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_VALUE_KEY]: Number(
|
||||||
|
options.scenarioInputs?.[
|
||||||
|
PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_INPUT
|
||||||
|
] ?? 0,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
events.push({
|
events.push({
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
currentHealthPercentBandPredicate,
|
currentHealthPercentBandPredicate,
|
||||||
eventFieldComparisonPredicate,
|
eventFieldComparisonPredicate,
|
||||||
|
eventFieldNumericExpression,
|
||||||
mechanicTargetingForMode,
|
mechanicTargetingForMode,
|
||||||
mechanicTargetingMode,
|
mechanicTargetingMode,
|
||||||
parseConditionalMechanicDraft,
|
parseConditionalMechanicDraft,
|
||||||
readCurrentHealthPercentBand,
|
readCurrentHealthPercentBand,
|
||||||
readEventFieldComparison,
|
readEventFieldComparison,
|
||||||
|
readEventFieldNumericExpression,
|
||||||
registeredEventFields,
|
registeredEventFields,
|
||||||
readStatePoolThreshold,
|
readStatePoolThreshold,
|
||||||
statePoolThresholdPredicate,
|
statePoolThresholdPredicate,
|
||||||
@@ -202,6 +204,108 @@ describe('conditional mechanic authoring payload', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('round-trips the registered proximity count as a derived scalar and resolves each variant display value', () => {
|
||||||
|
const path = 'values.alliesWithin30MetersWithAgentGrantedBuff';
|
||||||
|
expect(registeredEventFields('proximity_snapshot')).toContainEqual({
|
||||||
|
path,
|
||||||
|
label: 'Allies within 30 meters with an Agent-granted buff',
|
||||||
|
type: 'number',
|
||||||
|
minimum: 0,
|
||||||
|
integer: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const expression = eventFieldNumericExpression(path);
|
||||||
|
expect(readEventFieldNumericExpression(expression)).toBe(path);
|
||||||
|
expect(
|
||||||
|
readEventFieldNumericExpression({
|
||||||
|
op: 'multiply',
|
||||||
|
left: expression,
|
||||||
|
right: 2,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
const value = draft();
|
||||||
|
value.clauses[0].event = 'proximity_snapshot';
|
||||||
|
value.clauses[0].recipeKey = undefined;
|
||||||
|
value.clauses[0].reducer = {
|
||||||
|
kind: 'derived_scalar',
|
||||||
|
value: expression,
|
||||||
|
};
|
||||||
|
value.clauses[0].mutations = [
|
||||||
|
{
|
||||||
|
kind: 'derive',
|
||||||
|
pool: 'effect',
|
||||||
|
value: { ref: { source: 'reducer' } },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const upgradeScale = {
|
||||||
|
op: 'add' as const,
|
||||||
|
left: 1,
|
||||||
|
right: {
|
||||||
|
op: 'divide' as const,
|
||||||
|
left: {
|
||||||
|
ref: {
|
||||||
|
source: 'equipment' as const,
|
||||||
|
path: 'protocolTalentUpgrades',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
right: 5,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
value.clauses[0].outcomes[0].variants = {
|
||||||
|
I: {
|
||||||
|
value: 7.25,
|
||||||
|
expression: {
|
||||||
|
op: 'multiply',
|
||||||
|
left: { ref: { source: 'variant_value' } },
|
||||||
|
right: upgradeScale,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
II: {
|
||||||
|
value: 9.5,
|
||||||
|
expression: {
|
||||||
|
op: 'multiply',
|
||||||
|
left: { ref: { source: 'variant_value' } },
|
||||||
|
right: upgradeScale,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseConditionalMechanicDraft(value, {
|
||||||
|
variantKeys: ['I', 'II'],
|
||||||
|
allowedAttributeIds: new Set(['weapon-damage']),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
expect(result.value.draft.clauses[0]).toMatchObject({
|
||||||
|
event: 'proximity_snapshot',
|
||||||
|
reducer: { kind: 'derived_scalar', value: expression },
|
||||||
|
outcomes: [
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
I: {
|
||||||
|
value: 7.25,
|
||||||
|
expression: {
|
||||||
|
left: { ref: { source: 'variant_value' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result.value.programs.I.clauses[0].outcomes?.[0].value).toEqual({
|
||||||
|
op: 'multiply',
|
||||||
|
left: 7.25,
|
||||||
|
right: upgradeScale,
|
||||||
|
});
|
||||||
|
expect(result.value.programs.II.clauses[0].outcomes?.[0].value).toEqual({
|
||||||
|
op: 'multiply',
|
||||||
|
left: 9.5,
|
||||||
|
right: upgradeScale,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('exposes assigned-ability activity for kill and assist event conditions', () => {
|
it('exposes assigned-ability activity for kill and assist event conditions', () => {
|
||||||
expect(registeredEventFields('enemy_killed')).toEqual(
|
expect(registeredEventFields('enemy_killed')).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
|
|||||||
@@ -39,9 +39,28 @@ export type ConditionalMechanicPoolDraft = PoolDefinition & {
|
|||||||
expiresAfterSecondsVariants?: Record<string, number>;
|
expiresAfterSecondsVariants?: Record<string, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ConditionalMechanicValueReference =
|
||||||
|
ValueReference | { source: 'variant_value' };
|
||||||
|
|
||||||
|
export type ConditionalNumericExpression =
|
||||||
|
| number
|
||||||
|
| { ref: ConditionalMechanicValueReference }
|
||||||
|
| { op: 'floor'; value: ConditionalNumericExpression }
|
||||||
|
| {
|
||||||
|
op: 'add' | 'subtract' | 'multiply' | 'divide' | 'min' | 'max';
|
||||||
|
left: ConditionalNumericExpression;
|
||||||
|
right: ConditionalNumericExpression;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
op: 'clamp';
|
||||||
|
value: ConditionalNumericExpression;
|
||||||
|
min: ConditionalNumericExpression;
|
||||||
|
max: ConditionalNumericExpression;
|
||||||
|
};
|
||||||
|
|
||||||
export type ConditionalMechanicVariantValue = {
|
export type ConditionalMechanicVariantValue = {
|
||||||
value: number;
|
value: number;
|
||||||
expression?: NumericExpression;
|
expression?: ConditionalNumericExpression;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConditionalMechanicOutcomeDraft = Omit<
|
export type ConditionalMechanicOutcomeDraft = Omit<
|
||||||
@@ -200,6 +219,21 @@ export function registeredEventFields(event: MechanicEventKind) {
|
|||||||
return MECHANIC_EVENT_REGISTRY[event].fields ?? [];
|
return MECHANIC_EVENT_REGISTRY[event].fields ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function eventFieldNumericExpression(path: string): NumericExpression {
|
||||||
|
return { ref: { source: 'event', path } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readEventFieldNumericExpression(
|
||||||
|
expression: NumericExpression,
|
||||||
|
): string | null {
|
||||||
|
return typeof expression === 'object' &&
|
||||||
|
'ref' in expression &&
|
||||||
|
expression.ref.source === 'event' &&
|
||||||
|
expression.ref.path.trim()
|
||||||
|
? expression.ref.path
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
export type MechanicTargetingMode =
|
export type MechanicTargetingMode =
|
||||||
| 'none'
|
| 'none'
|
||||||
| 'allies_within_radius'
|
| 'allies_within_radius'
|
||||||
@@ -452,6 +486,14 @@ function isReference(value: unknown): value is ValueReference {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isConditionalReference(
|
||||||
|
value: unknown,
|
||||||
|
): value is ConditionalMechanicValueReference {
|
||||||
|
return (
|
||||||
|
(object(value) && value.source === 'variant_value') || isReference(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function validateAttributeReferences(
|
function validateAttributeReferences(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
allowedAttributeIds: ReadonlySet<string> | undefined,
|
allowedAttributeIds: ReadonlySet<string> | undefined,
|
||||||
@@ -511,6 +553,29 @@ function isExpression(value: unknown): value is NumericExpression {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isConditionalExpression(
|
||||||
|
value: unknown,
|
||||||
|
): value is ConditionalNumericExpression {
|
||||||
|
if (finite(value)) return true;
|
||||||
|
if (!object(value)) return false;
|
||||||
|
if ('ref' in value) return isConditionalReference(value.ref);
|
||||||
|
if (value.op === 'floor') return isConditionalExpression(value.value);
|
||||||
|
if (value.op === 'clamp') {
|
||||||
|
return (
|
||||||
|
isConditionalExpression(value.value) &&
|
||||||
|
isConditionalExpression(value.min) &&
|
||||||
|
isConditionalExpression(value.max)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
['add', 'subtract', 'multiply', 'divide', 'min', 'max'].includes(
|
||||||
|
String(value.op),
|
||||||
|
) &&
|
||||||
|
isConditionalExpression(value.left) &&
|
||||||
|
isConditionalExpression(value.right)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isOperand(value: unknown) {
|
function isOperand(value: unknown) {
|
||||||
return scalar(value) || isReference(value);
|
return scalar(value) || isReference(value);
|
||||||
}
|
}
|
||||||
@@ -838,7 +903,7 @@ function parseOutcome(
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
configured.expression !== undefined &&
|
configured.expression !== undefined &&
|
||||||
!isExpression(configured.expression)
|
!isConditionalExpression(configured.expression)
|
||||||
) {
|
) {
|
||||||
errors.push(
|
errors.push(
|
||||||
`${path} variant "${variant}" has an invalid value expression.`,
|
`${path} variant "${variant}" has an invalid value expression.`,
|
||||||
@@ -954,6 +1019,37 @@ function parseClauses(
|
|||||||
return clauses;
|
return clauses;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveVariantExpression(
|
||||||
|
expression: ConditionalNumericExpression,
|
||||||
|
variantValue: number,
|
||||||
|
): NumericExpression {
|
||||||
|
if (typeof expression === 'number') return expression;
|
||||||
|
if ('ref' in expression) {
|
||||||
|
return expression.ref.source === 'variant_value'
|
||||||
|
? variantValue
|
||||||
|
: { ref: expression.ref };
|
||||||
|
}
|
||||||
|
if (expression.op === 'floor') {
|
||||||
|
return {
|
||||||
|
op: 'floor',
|
||||||
|
value: resolveVariantExpression(expression.value, variantValue),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (expression.op === 'clamp') {
|
||||||
|
return {
|
||||||
|
op: 'clamp',
|
||||||
|
value: resolveVariantExpression(expression.value, variantValue),
|
||||||
|
min: resolveVariantExpression(expression.min, variantValue),
|
||||||
|
max: resolveVariantExpression(expression.max, variantValue),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
op: expression.op,
|
||||||
|
left: resolveVariantExpression(expression.left, variantValue),
|
||||||
|
right: resolveVariantExpression(expression.right, variantValue),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function programsForDraft(
|
function programsForDraft(
|
||||||
draft: ConditionalMechanicDraftV1,
|
draft: ConditionalMechanicDraftV1,
|
||||||
options: ParseConditionalMechanicDraftOptions,
|
options: ParseConditionalMechanicDraftOptions,
|
||||||
@@ -1002,7 +1098,13 @@ function programsForDraft(
|
|||||||
}) => ({
|
}) => ({
|
||||||
...outcome,
|
...outcome,
|
||||||
id: outcomeKey,
|
id: outcomeKey,
|
||||||
value: variants[variant].expression ?? variants[variant].value,
|
value:
|
||||||
|
variants[variant].expression === undefined
|
||||||
|
? variants[variant].value
|
||||||
|
: resolveVariantExpression(
|
||||||
|
variants[variant].expression,
|
||||||
|
variants[variant].value,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { evaluateMechanicProgram } from './mechanic.js';
|
||||||
import {
|
import {
|
||||||
adaptConditionalEffectProgram,
|
adaptConditionalEffectProgram,
|
||||||
type ConditionalEffectClauseRow,
|
type ConditionalEffectClauseRow,
|
||||||
@@ -912,4 +913,77 @@ describe('conditional-effect persistence adapter', () => {
|
|||||||
if: condition,
|
if: condition,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
it('preserves a variant-value expression in persisted JSON while adapting it to the selected row value', () => {
|
||||||
|
const sourceExpression = {
|
||||||
|
op: 'multiply',
|
||||||
|
left: { ref: { source: 'variant_value' } },
|
||||||
|
right: {
|
||||||
|
op: 'add',
|
||||||
|
left: 1,
|
||||||
|
right: {
|
||||||
|
op: 'divide',
|
||||||
|
left: {
|
||||||
|
ref: {
|
||||||
|
source: 'equipment',
|
||||||
|
path: 'protocolTalentUpgrades',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
right: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const serializedExpression = JSON.stringify(sourceExpression);
|
||||||
|
const persistedOutcome = outcome({
|
||||||
|
state_pool_id: null,
|
||||||
|
value: 7.25,
|
||||||
|
value_expression_json: serializedExpression,
|
||||||
|
value_scaling: 'flat',
|
||||||
|
application_phase: 'on_event',
|
||||||
|
});
|
||||||
|
const result = adaptConditionalEffectProgram(
|
||||||
|
{
|
||||||
|
rule: rule(),
|
||||||
|
statePools: [],
|
||||||
|
clauses: [
|
||||||
|
clause({
|
||||||
|
trigger_id: null,
|
||||||
|
event_kind: 'tick',
|
||||||
|
parameters_json: '{}',
|
||||||
|
state_pool_id: null,
|
||||||
|
mutations_json: '[]',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
outcomes: [persistedOutcome],
|
||||||
|
triggers: [],
|
||||||
|
},
|
||||||
|
{ variantKey: 'I' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 'READY', errors: [] });
|
||||||
|
expect(persistedOutcome.value).toBe(7.25);
|
||||||
|
expect(persistedOutcome.value_expression_json).toBe(serializedExpression);
|
||||||
|
expect(JSON.parse(String(persistedOutcome.value_expression_json))).toEqual(
|
||||||
|
sourceExpression,
|
||||||
|
);
|
||||||
|
expect(result.program?.clauses[0].outcomes?.[0].value).toEqual({
|
||||||
|
op: 'multiply',
|
||||||
|
left: 7.25,
|
||||||
|
right: sourceExpression.right,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.program) return;
|
||||||
|
const runtime = evaluateMechanicProgram(
|
||||||
|
result.program,
|
||||||
|
[{ kind: 'tick', at: 0 }],
|
||||||
|
{ mode: 'custom', encounterSeconds: 1 },
|
||||||
|
undefined,
|
||||||
|
{ protocolTalentUpgrades: 5 },
|
||||||
|
);
|
||||||
|
expect(runtime.status).toBe('OK');
|
||||||
|
expect(runtime.outcomes[0]).toMatchObject({
|
||||||
|
outcomeId: 'outcome-1',
|
||||||
|
value: 14.5,
|
||||||
|
resolvedValue: 14.5,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type Predicate,
|
type Predicate,
|
||||||
type Reducer,
|
type Reducer,
|
||||||
type ScenarioInputDefinition,
|
type ScenarioInputDefinition,
|
||||||
|
type ValueReference,
|
||||||
} from './mechanic.js';
|
} from './mechanic.js';
|
||||||
|
|
||||||
type DatabaseValue = string | number | bigint | boolean | null | undefined;
|
type DatabaseValue = string | number | bigint | boolean | null | undefined;
|
||||||
@@ -257,6 +258,55 @@ function isNumericExpression(value: unknown): value is NumericExpression {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveVariantExpression(
|
||||||
|
value: unknown,
|
||||||
|
variantValue: number,
|
||||||
|
): NumericExpression | null {
|
||||||
|
if (finite(value)) return value;
|
||||||
|
if (!object(value)) return null;
|
||||||
|
if ('ref' in value) {
|
||||||
|
if (
|
||||||
|
object(value.ref) &&
|
||||||
|
value.ref.source === 'variant_value' &&
|
||||||
|
Object.keys(value.ref).length === 1
|
||||||
|
) {
|
||||||
|
return variantValue;
|
||||||
|
}
|
||||||
|
return isValueReference(value.ref)
|
||||||
|
? { ref: value.ref as ValueReference }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (value.op === 'floor') {
|
||||||
|
const nested = resolveVariantExpression(value.value, variantValue);
|
||||||
|
return nested === null ? null : { op: 'floor', value: nested };
|
||||||
|
}
|
||||||
|
if (value.op === 'clamp') {
|
||||||
|
const nested = resolveVariantExpression(value.value, variantValue);
|
||||||
|
const min = resolveVariantExpression(value.min, variantValue);
|
||||||
|
const max = resolveVariantExpression(value.max, variantValue);
|
||||||
|
return nested === null || min === null || max === null
|
||||||
|
? null
|
||||||
|
: { op: 'clamp', value: nested, min, max };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!['add', 'subtract', 'multiply', 'divide', 'min', 'max'].includes(
|
||||||
|
String(value.op),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const left = resolveVariantExpression(value.left, variantValue);
|
||||||
|
const right = resolveVariantExpression(value.right, variantValue);
|
||||||
|
return left === null || right === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
op: value.op as
|
||||||
|
'add' | 'subtract' | 'multiply' | 'divide' | 'min' | 'max',
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function isPredicate(value: unknown): value is Predicate {
|
function isPredicate(value: unknown): value is Predicate {
|
||||||
if (!object(value) || typeof value.kind !== 'string') return false;
|
if (!object(value) || typeof value.kind !== 'string') return false;
|
||||||
if (value.kind === 'true') return true;
|
if (value.kind === 'true') return true;
|
||||||
@@ -673,11 +723,8 @@ function parseOutcome(
|
|||||||
) {
|
) {
|
||||||
errors.push(`${path} has an unsupported application phase.`);
|
errors.push(`${path} has an unsupported application phase.`);
|
||||||
}
|
}
|
||||||
let value: NumericExpression = requiredNumber(
|
const variantValue = requiredNumber(row.value, `${path} value`, errors);
|
||||||
row.value,
|
let value: NumericExpression = variantValue;
|
||||||
`${path} value`,
|
|
||||||
errors,
|
|
||||||
);
|
|
||||||
if (
|
if (
|
||||||
row.value_expression_json !== null &&
|
row.value_expression_json !== null &&
|
||||||
row.value_expression_json !== undefined &&
|
row.value_expression_json !== undefined &&
|
||||||
@@ -689,7 +736,8 @@ function parseOutcome(
|
|||||||
errors,
|
errors,
|
||||||
);
|
);
|
||||||
if (parsed.ok) {
|
if (parsed.ok) {
|
||||||
if (isNumericExpression(parsed.value)) value = parsed.value;
|
const resolved = resolveVariantExpression(parsed.value, variantValue);
|
||||||
|
if (resolved !== null) value = resolved;
|
||||||
else
|
else
|
||||||
errors.push(
|
errors.push(
|
||||||
`${path} value expression is not a valid numeric expression.`,
|
`${path} value expression is not a valid numeric expression.`,
|
||||||
|
|||||||
@@ -19,6 +19,84 @@ const custom = (
|
|||||||
inputs,
|
inputs,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const closeAndPersonalProgram = (
|
||||||
|
resolvedVariantValue: number,
|
||||||
|
): MechanicProgram => ({
|
||||||
|
id: 'close-and-personal-engineering',
|
||||||
|
name: 'Close and Personal Engineering',
|
||||||
|
pools: [
|
||||||
|
{
|
||||||
|
key: 'eligible-allies',
|
||||||
|
label: 'Eligible Allies',
|
||||||
|
scope: 'agent',
|
||||||
|
initial: 0,
|
||||||
|
min: 0,
|
||||||
|
max: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
clauses: [
|
||||||
|
{
|
||||||
|
id: 'sample-eligible-allies',
|
||||||
|
when: 'proximity_snapshot',
|
||||||
|
reducer: {
|
||||||
|
kind: 'derived_scalar',
|
||||||
|
value: {
|
||||||
|
ref: {
|
||||||
|
source: 'event',
|
||||||
|
path: 'values.alliesWithin30MetersWithAgentGrantedBuff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mutations: [
|
||||||
|
{
|
||||||
|
kind: 'derive',
|
||||||
|
pool: 'eligible-allies',
|
||||||
|
value: { ref: { source: 'reducer' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'resolve-engineering',
|
||||||
|
when: 'tick',
|
||||||
|
reducer: { kind: 'none' },
|
||||||
|
mutations: [{ kind: 'none' }],
|
||||||
|
outcomes: [
|
||||||
|
{
|
||||||
|
id: 'engineering',
|
||||||
|
label: 'Engineering',
|
||||||
|
phase: 'on_event',
|
||||||
|
kind: 'attribute_modifier',
|
||||||
|
scope: 'self',
|
||||||
|
basis: 'flat',
|
||||||
|
attributeId: 'engineering',
|
||||||
|
value: {
|
||||||
|
op: 'multiply',
|
||||||
|
left: {
|
||||||
|
op: 'multiply',
|
||||||
|
left: { ref: { source: 'pool', pool: 'eligible-allies' } },
|
||||||
|
right: resolvedVariantValue,
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
op: 'add',
|
||||||
|
left: 1,
|
||||||
|
right: {
|
||||||
|
op: 'divide',
|
||||||
|
left: {
|
||||||
|
ref: {
|
||||||
|
source: 'equipment',
|
||||||
|
path: 'protocolTalentUpgrades',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
right: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
describe('deterministic mechanic probabilities and assigned abilities', () => {
|
describe('deterministic mechanic probabilities and assigned abilities', () => {
|
||||||
it('rolls outcome probability independently with a reproducible seed and preserves a refreshed window', () => {
|
it('rolls outcome probability independently with a reproducible seed and preserves a refreshed window', () => {
|
||||||
const program: MechanicProgram = {
|
const program: MechanicProgram = {
|
||||||
@@ -321,6 +399,23 @@ describe('structured mechanic runtime', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('advertises the optional non-negative integer proximity count without making snapshots strict', () => {
|
||||||
|
const definition = MECHANIC_EVENT_REGISTRY.proximity_snapshot;
|
||||||
|
const countField = definition.fields?.find(
|
||||||
|
({ path }) => path === 'values.alliesWithin30MetersWithAgentGrantedBuff',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(definition.strictFields).not.toBe(true);
|
||||||
|
expect(countField).toEqual({
|
||||||
|
path: 'values.alliesWithin30MetersWithAgentGrantedBuff',
|
||||||
|
label: 'Allies within 30 meters with an Agent-granted buff',
|
||||||
|
type: 'number',
|
||||||
|
minimum: 0,
|
||||||
|
integer: true,
|
||||||
|
});
|
||||||
|
expect(countField?.required).not.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('advertises imported support and equipment-use trigger events', () => {
|
it('advertises imported support and equipment-use trigger events', () => {
|
||||||
expect(MECHANIC_EVENT_REGISTRY.ally_downed).toMatchObject({
|
expect(MECHANIC_EVENT_REGISTRY.ally_downed).toMatchObject({
|
||||||
key: 'ally_downed',
|
key: 'ally_downed',
|
||||||
@@ -1306,6 +1401,7 @@ describe('structured mechanic runtime', () => {
|
|||||||
],
|
],
|
||||||
custom(),
|
custom(),
|
||||||
);
|
);
|
||||||
|
expect(result.status).toBe('OK');
|
||||||
expect(result.finalState[0].value).toBe(2);
|
expect(result.finalState[0].value).toBe(2);
|
||||||
const maximum = evaluateMechanicProgram(program, [], {
|
const maximum = evaluateMechanicProgram(program, [], {
|
||||||
mode: 'maximum',
|
mode: 'maximum',
|
||||||
@@ -1314,6 +1410,81 @@ describe('structured mechanic runtime', () => {
|
|||||||
expect(maximum.finalState[0].value).toBe(3);
|
expect(maximum.finalState[0].value).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ count: 0, upgrades: 0, multiplier: 0 },
|
||||||
|
{ count: 3, upgrades: 0, multiplier: 3 },
|
||||||
|
{ count: 3, upgrades: 5, multiplier: 6 },
|
||||||
|
])(
|
||||||
|
'resolves Close-and-Personal-style Engineering for count $count and $upgrades OS upgrades',
|
||||||
|
({ count, upgrades, multiplier }) => {
|
||||||
|
const resolvedVariantValue = 7.25;
|
||||||
|
const result = evaluateMechanicProgram(
|
||||||
|
closeAndPersonalProgram(resolvedVariantValue),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kind: 'proximity_snapshot',
|
||||||
|
at: 0,
|
||||||
|
values: {
|
||||||
|
alliesWithin30MetersWithAgentGrantedBuff: count,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ kind: 'tick', at: 1 },
|
||||||
|
],
|
||||||
|
custom(),
|
||||||
|
undefined,
|
||||||
|
{ protocolTalentUpgrades: upgrades },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe('OK');
|
||||||
|
expect(result.finalState).toContainEqual(
|
||||||
|
expect.objectContaining({ pool: 'eligible-allies', value: count }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
result.outcomes.find(({ outcomeId }) => outcomeId === 'engineering'),
|
||||||
|
).toMatchObject({
|
||||||
|
value: resolvedVariantValue * multiplier,
|
||||||
|
resolvedValue: resolvedVariantValue * multiplier,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
count: '2',
|
||||||
|
error:
|
||||||
|
'Allies within 30 meters with an Agent-granted buff must be a number.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
count: -1,
|
||||||
|
error:
|
||||||
|
'Allies within 30 meters with an Agent-granted buff must be 0 or greater.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
count: 1.5,
|
||||||
|
error:
|
||||||
|
'Allies within 30 meters with an Agent-granted buff must be an integer.',
|
||||||
|
},
|
||||||
|
])('rejects invalid proximity ally count $count', ({ count, error }) => {
|
||||||
|
const result = evaluateMechanicProgram(
|
||||||
|
closeAndPersonalProgram(7.25),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kind: 'proximity_snapshot',
|
||||||
|
at: 0,
|
||||||
|
values: {
|
||||||
|
alliesWithin30MetersWithAgentGrantedBuff: count,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
custom(),
|
||||||
|
undefined,
|
||||||
|
{ protocolTalentUpgrades: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe('INVALID');
|
||||||
|
expect(result.errors).toContain(`Event 1 (Proximity snapshot) ${error}`);
|
||||||
|
});
|
||||||
|
|
||||||
it('shares one movement charge pool across generation and consumption clauses', () => {
|
it('shares one movement charge pool across generation and consumption clauses', () => {
|
||||||
const program: MechanicProgram = {
|
const program: MechanicProgram = {
|
||||||
id: 'mobile-charge',
|
id: 'mobile-charge',
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ export const MECHANIC_EVENT_KINDS = [
|
|||||||
'tick',
|
'tick',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_VALUE_KEY =
|
||||||
|
'alliesWithin30MetersWithAgentGrantedBuff' as const;
|
||||||
|
export const PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_FIELD =
|
||||||
|
`values.${PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_VALUE_KEY}` as const;
|
||||||
|
export const PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_INPUT =
|
||||||
|
'allies-within-30m-with-agent-granted-buff' as const;
|
||||||
|
|
||||||
export type MechanicEventKind = (typeof MECHANIC_EVENT_KINDS)[number];
|
export type MechanicEventKind = (typeof MECHANIC_EVENT_KINDS)[number];
|
||||||
export type MechanicEventDefinition = {
|
export type MechanicEventDefinition = {
|
||||||
key: MechanicEventKind;
|
key: MechanicEventKind;
|
||||||
@@ -39,6 +46,7 @@ export type MechanicEventDefinition = {
|
|||||||
type: 'boolean' | 'number' | 'string';
|
type: 'boolean' | 'number' | 'string';
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
minimum?: number;
|
minimum?: number;
|
||||||
|
integer?: boolean;
|
||||||
}[];
|
}[];
|
||||||
strictFields?: boolean;
|
strictFields?: boolean;
|
||||||
};
|
};
|
||||||
@@ -308,6 +316,15 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<
|
|||||||
label: 'Proximity snapshot',
|
label: 'Proximity snapshot',
|
||||||
description:
|
description:
|
||||||
'A sampled count or state for nearby allies, enemies, and Agent-owned buffs.',
|
'A sampled count or state for nearby allies, enemies, and Agent-owned buffs.',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_FIELD,
|
||||||
|
label: 'Allies within 30 meters with an Agent-granted buff',
|
||||||
|
type: 'number',
|
||||||
|
minimum: 0,
|
||||||
|
integer: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
firing_started: {
|
firing_started: {
|
||||||
key: 'firing_started',
|
key: 'firing_started',
|
||||||
@@ -2049,6 +2066,13 @@ function validateMechanicEvents(events: MechanicEvent[]) {
|
|||||||
) {
|
) {
|
||||||
errors.push(`${fieldPath} must be ${field.minimum} or greater.`);
|
errors.push(`${fieldPath} must be ${field.minimum} or greater.`);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
field.type === 'number' &&
|
||||||
|
field.integer &&
|
||||||
|
!Number.isInteger(value)
|
||||||
|
) {
|
||||||
|
errors.push(`${fieldPath} must be an integer.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const hasExtraHealth = getPath(event, 'values.hasExtraHealth');
|
const hasExtraHealth = getPath(event, 'values.hasExtraHealth');
|
||||||
const currentExtraHealth = getPath(event, 'values.currentExtraHealth');
|
const currentExtraHealth = getPath(event, 'values.currentExtraHealth');
|
||||||
|
|||||||
@@ -9,15 +9,18 @@
|
|||||||
blankConditionalMechanicDraft,
|
blankConditionalMechanicDraft,
|
||||||
currentHealthPercentBandPredicate,
|
currentHealthPercentBandPredicate,
|
||||||
eventFieldComparisonPredicate,
|
eventFieldComparisonPredicate,
|
||||||
|
eventFieldNumericExpression,
|
||||||
mechanicTargetingForMode,
|
mechanicTargetingForMode,
|
||||||
mechanicTargetingMode,
|
mechanicTargetingMode,
|
||||||
parseConditionalMechanicDraft,
|
parseConditionalMechanicDraft,
|
||||||
readCurrentHealthPercentBand,
|
readCurrentHealthPercentBand,
|
||||||
readEventFieldComparison,
|
readEventFieldComparison,
|
||||||
|
readEventFieldNumericExpression,
|
||||||
registeredEventFields,
|
registeredEventFields,
|
||||||
readStatePoolThreshold,
|
readStatePoolThreshold,
|
||||||
statePoolThresholdPredicate,
|
statePoolThresholdPredicate,
|
||||||
type ConditionalMechanicClauseDraft,
|
type ConditionalMechanicClauseDraft,
|
||||||
|
type ConditionalNumericExpression,
|
||||||
type ConditionalMechanicDraftV1,
|
type ConditionalMechanicDraftV1,
|
||||||
type ConditionalMechanicOutcomeDraft,
|
type ConditionalMechanicOutcomeDraft,
|
||||||
type ConditionalMechanicPoolDraft,
|
type ConditionalMechanicPoolDraft,
|
||||||
@@ -31,6 +34,7 @@
|
|||||||
formatMechanicProgram,
|
formatMechanicProgram,
|
||||||
MECHANIC_EVENT_KINDS,
|
MECHANIC_EVENT_KINDS,
|
||||||
MECHANIC_EVENT_REGISTRY,
|
MECHANIC_EVENT_REGISTRY,
|
||||||
|
PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_FIELD,
|
||||||
type ClauseMutation,
|
type ClauseMutation,
|
||||||
type MechanicOutcomeScope,
|
type MechanicOutcomeScope,
|
||||||
type NumericExpression,
|
type NumericExpression,
|
||||||
@@ -408,6 +412,40 @@
|
|||||||
updateClause(index, { reducer });
|
updateClause(index, { reducer });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function derivedScalarModel(clause: ConditionalMechanicClauseDraft) {
|
||||||
|
if (clause.reducer.kind !== 'derived_scalar') return 'static';
|
||||||
|
if (typeof clause.reducer.value === 'number') return 'static';
|
||||||
|
return readEventFieldNumericExpression(clause.reducer.value) ? 'event' : 'advanced';
|
||||||
|
}
|
||||||
|
|
||||||
|
function numericEventFields(clause: ConditionalMechanicClauseDraft) {
|
||||||
|
return registeredEventFields(clause.event).filter((field) => field.type === 'number');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDerivedScalarModel(clauseIndex: number, model: string) {
|
||||||
|
const clause = rule.clauses[clauseIndex];
|
||||||
|
if (clause.reducer.kind !== 'derived_scalar') return;
|
||||||
|
if (model === 'static') {
|
||||||
|
updateClause(clauseIndex, { reducer: { kind: 'derived_scalar', value: 1 } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const field = numericEventFields(clause)[0];
|
||||||
|
if (model === 'event' && field) {
|
||||||
|
updateClause(clauseIndex, {
|
||||||
|
reducer: {
|
||||||
|
kind: 'derived_scalar',
|
||||||
|
value: eventFieldNumericExpression(field.path)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDerivedScalarEventField(clauseIndex: number, path: string) {
|
||||||
|
updateClause(clauseIndex, {
|
||||||
|
reducer: { kind: 'derived_scalar', value: eventFieldNumericExpression(path) }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function updateOutcome(clauseIndex: number, outcomeIndex: number, patch: Partial<ConditionalMechanicOutcomeDraft>) {
|
function updateOutcome(clauseIndex: number, outcomeIndex: number, patch: Partial<ConditionalMechanicOutcomeDraft>) {
|
||||||
const outcomes = [...rule.clauses[clauseIndex].outcomes];
|
const outcomes = [...rule.clauses[clauseIndex].outcomes];
|
||||||
outcomes[outcomeIndex] = { ...outcomes[outcomeIndex], ...patch };
|
outcomes[outcomeIndex] = { ...outcomes[outcomeIndex], ...patch };
|
||||||
@@ -517,6 +555,10 @@
|
|||||||
const eventValueOptions = [
|
const eventValueOptions = [
|
||||||
{ value: 'values.enemyCount', label: 'ENEMY COUNT' },
|
{ value: 'values.enemyCount', label: 'ENEMY COUNT' },
|
||||||
{ value: 'values.allyCount', label: 'ALLY COUNT' },
|
{ value: 'values.allyCount', label: 'ALLY COUNT' },
|
||||||
|
{
|
||||||
|
value: PROXIMITY_AGENT_GRANTED_BUFF_ALLY_COUNT_FIELD,
|
||||||
|
label: 'AGENT-BUFFED ALLIES ≤30M'
|
||||||
|
},
|
||||||
{ value: 'values.magazineRemaining', label: 'ROUNDS LEFT' },
|
{ value: 'values.magazineRemaining', label: 'ROUNDS LEFT' },
|
||||||
{ value: 'values.magazinePercent', label: 'MAGAZINE %' },
|
{ value: 'values.magazinePercent', label: 'MAGAZINE %' },
|
||||||
{ value: 'values.distanceMeters', label: 'DISTANCE' }
|
{ value: 'values.distanceMeters', label: 'DISTANCE' }
|
||||||
@@ -534,10 +576,10 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function protocolUpgradeExpression(value: number): NumericExpression {
|
function protocolUpgradeExpression(): ConditionalNumericExpression {
|
||||||
return {
|
return {
|
||||||
op: 'multiply',
|
op: 'multiply',
|
||||||
left: value,
|
left: { ref: { source: 'variant_value' } },
|
||||||
right: {
|
right: {
|
||||||
op: 'add',
|
op: 'add',
|
||||||
left: 1,
|
left: 1,
|
||||||
@@ -578,17 +620,17 @@
|
|||||||
typeof expression === 'object'
|
typeof expression === 'object'
|
||||||
&& 'op' in expression
|
&& 'op' in expression
|
||||||
&& expression.op === 'multiply'
|
&& expression.op === 'multiply'
|
||||||
&& typeof expression.left === 'number'
|
|
||||||
&& typeof expression.right === 'object'
|
&& typeof expression.right === 'object'
|
||||||
) {
|
) {
|
||||||
if ('ref' in expression.right) {
|
if (typeof expression.left === 'number' && 'ref' in expression.right) {
|
||||||
if (expression.right.ref.source === 'event') return 'event';
|
if (expression.right.ref.source === 'event') return 'event';
|
||||||
if (expression.right.ref.source === 'scenario') return 'scenario';
|
if (expression.right.ref.source === 'scenario') return 'scenario';
|
||||||
if (expression.right.ref.source === 'reducer') return 'reducer';
|
if (expression.right.ref.source === 'reducer') return 'reducer';
|
||||||
if (expression.right.ref.source === 'attribute') return 'attribute_value';
|
if (expression.right.ref.source === 'attribute') return 'attribute_value';
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
'op' in expression.right
|
typeof expression.left === 'number'
|
||||||
|
&& 'op' in expression.right
|
||||||
&& expression.right.op === 'divide'
|
&& expression.right.op === 'divide'
|
||||||
&& typeof expression.right.left === 'object'
|
&& typeof expression.right.left === 'object'
|
||||||
&& 'ref' in expression.right.left
|
&& 'ref' in expression.right.left
|
||||||
@@ -596,7 +638,13 @@
|
|||||||
&& expression.right.right === 100
|
&& expression.right.right === 100
|
||||||
) return 'attribute';
|
) return 'attribute';
|
||||||
if (
|
if (
|
||||||
'op' in expression.right
|
(typeof expression.left === 'number'
|
||||||
|
|| (
|
||||||
|
typeof expression.left === 'object'
|
||||||
|
&& 'ref' in expression.left
|
||||||
|
&& expression.left.ref.source === 'variant_value'
|
||||||
|
))
|
||||||
|
&& 'op' in expression.right
|
||||||
&& expression.right.op === 'add'
|
&& expression.right.op === 'add'
|
||||||
&& typeof expression.right.right === 'object'
|
&& typeof expression.right.right === 'object'
|
||||||
&& 'op' in expression.right.right
|
&& 'op' in expression.right.right
|
||||||
@@ -657,7 +705,7 @@
|
|||||||
model: OutcomeValueModel,
|
model: OutcomeValueModel,
|
||||||
value: number,
|
value: number,
|
||||||
reference = ''
|
reference = ''
|
||||||
): NumericExpression | undefined {
|
): ConditionalNumericExpression | undefined {
|
||||||
if (model === 'event') return referenceExpression('event', reference || 'values.enemyCount', value);
|
if (model === 'event') return referenceExpression('event', reference || 'values.enemyCount', value);
|
||||||
if (model === 'attribute') return reference
|
if (model === 'attribute') return reference
|
||||||
? attributePercentExpression(value, reference)
|
? attributePercentExpression(value, reference)
|
||||||
@@ -671,7 +719,7 @@
|
|||||||
if (model === 'reducer') {
|
if (model === 'reducer') {
|
||||||
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
|
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
|
||||||
}
|
}
|
||||||
if (model === 'protocol_upgrades') return protocolUpgradeExpression(value);
|
if (model === 'protocol_upgrades') return protocolUpgradeExpression();
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,7 +746,7 @@
|
|||||||
updateOutcome(clauseIndex, outcomeIndex, { variants });
|
updateOutcome(clauseIndex, outcomeIndex, { variants });
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceExpressionBase(expression: NumericExpression, value: number): NumericExpression {
|
function replaceExpressionBase(expression: ConditionalNumericExpression, value: number): ConditionalNumericExpression {
|
||||||
const model = outcomeValueModel({
|
const model = outcomeValueModel({
|
||||||
key: 'temporary',
|
key: 'temporary',
|
||||||
label: 'Temporary',
|
label: 'Temporary',
|
||||||
@@ -743,7 +791,7 @@
|
|||||||
if (model === 'reducer') {
|
if (model === 'reducer') {
|
||||||
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
|
return { op: 'multiply', left: value, right: { ref: { source: 'reducer' } } };
|
||||||
}
|
}
|
||||||
if (model === 'protocol_upgrades') return protocolUpgradeExpression(value);
|
if (model === 'protocol_upgrades') return protocolUpgradeExpression();
|
||||||
return expression;
|
return expression;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,7 +850,7 @@
|
|||||||
updateClause(clauseIndex, { mutations });
|
updateClause(clauseIndex, { mutations });
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'clear', pool?: string, value?: number) {
|
function updateMutation(clauseIndex: number, mutationIndex: number, kind: 'add' | 'set' | 'consume' | 'derive' | 'clear', pool?: string, value?: number) {
|
||||||
const mutations = [...rule.clauses[clauseIndex].mutations];
|
const mutations = [...rule.clauses[clauseIndex].mutations];
|
||||||
const current = mutations[mutationIndex];
|
const current = mutations[mutationIndex];
|
||||||
const nextPool = pool ?? ('pool' in current ? current.pool : rule.pools[0]?.key ?? '');
|
const nextPool = pool ?? ('pool' in current ? current.pool : rule.pools[0]?.key ?? '');
|
||||||
@@ -812,6 +860,8 @@
|
|||||||
: undefined;
|
: undefined;
|
||||||
mutations[mutationIndex] = kind === 'clear'
|
mutations[mutationIndex] = kind === 'clear'
|
||||||
? { kind, pool: nextPool, entity }
|
? { kind, pool: nextPool, entity }
|
||||||
|
: kind === 'derive'
|
||||||
|
? { kind, pool: nextPool, value: { ref: { source: 'reducer' } }, entity }
|
||||||
: { kind, pool: nextPool, value: value ?? ('value' in current && typeof current.value === 'number' ? current.value : 1), entity };
|
: { kind, pool: nextPool, value: value ?? ('value' in current && typeof current.value === 'number' ? current.value : 1), entity };
|
||||||
updateClause(clauseIndex, { mutations });
|
updateClause(clauseIndex, { mutations });
|
||||||
}
|
}
|
||||||
@@ -1134,8 +1184,30 @@
|
|||||||
{:else if clause.reducer.kind === 'periodic'}
|
{:else if clause.reducer.kind === 'periodic'}
|
||||||
<label class="field">INTERVAL · SECONDS<input type="number" min="0.01" step="any" value={typeof clause.reducer.intervalSeconds === 'number' ? clause.reducer.intervalSeconds : 1} oninput={(event) => setReducerNumber(clauseIndex,'intervalSeconds',event.currentTarget.value)}/></label>
|
<label class="field">INTERVAL · SECONDS<input type="number" min="0.01" step="any" value={typeof clause.reducer.intervalSeconds === 'number' ? clause.reducer.intervalSeconds : 1} oninput={(event) => setReducerNumber(clauseIndex,'intervalSeconds',event.currentTarget.value)}/></label>
|
||||||
{:else if clause.reducer.kind === 'derived_scalar'}
|
{:else if clause.reducer.kind === 'derived_scalar'}
|
||||||
|
<SegmentedChoice
|
||||||
|
legend="DERIVED VALUE SOURCE"
|
||||||
|
name={`${idPrefix}-derived-source-${clauseIndex}`}
|
||||||
|
options={[
|
||||||
|
{value:'static',label:'STATIC'},
|
||||||
|
{value:'event',label:'EVENT FIELD',disabled:numericEventFields(clause).length === 0},
|
||||||
|
...(derivedScalarModel(clause) === 'advanced' ? [{value:'advanced',label:'ADVANCED',disabled:true}] : [])
|
||||||
|
]}
|
||||||
|
value={derivedScalarModel(clause)}
|
||||||
|
onchange={(values) => setDerivedScalarModel(clauseIndex,values[0])}
|
||||||
|
/>
|
||||||
|
{#if derivedScalarModel(clause) === 'event'}
|
||||||
|
<SegmentedChoice
|
||||||
|
legend="NUMERIC EVENT FIELD"
|
||||||
|
name={`${idPrefix}-derived-event-${clauseIndex}`}
|
||||||
|
options={numericEventFields(clause).map((field) => ({value:field.path,label:field.label.toUpperCase()}))}
|
||||||
|
value={readEventFieldNumericExpression(clause.reducer.value) ?? ''}
|
||||||
|
columns={Math.min(3,numericEventFields(clause).length)}
|
||||||
|
onchange={(values) => setDerivedScalarEventField(clauseIndex,values[0])}
|
||||||
|
/>
|
||||||
|
{:else if derivedScalarModel(clause) === 'static'}
|
||||||
<label class="field">DERIVED VALUE<input type="number" step="any" value={typeof clause.reducer.value === 'number' ? clause.reducer.value : 1} oninput={(event) => setReducerNumber(clauseIndex,'value',event.currentTarget.value)}/></label>
|
<label class="field">DERIVED VALUE<input type="number" step="any" value={typeof clause.reducer.value === 'number' ? clause.reducer.value : 1} oninput={(event) => setReducerNumber(clauseIndex,'value',event.currentTarget.value)}/></label>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mutation-panel">
|
<div class="mutation-panel">
|
||||||
@@ -1159,9 +1231,9 @@
|
|||||||
<SegmentedChoice
|
<SegmentedChoice
|
||||||
legend="OPERATION"
|
legend="OPERATION"
|
||||||
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
|
name={`${idPrefix}-mutation-kind-${clauseIndex}-${mutationIndex}`}
|
||||||
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'clear',label:'CLEAR'}]}
|
options={[{value:'add',label:'ADD'},{value:'set',label:'SET'},{value:'consume',label:'CONSUME'},{value:'derive',label:'DERIVED VALUE'},{value:'clear',label:'CLEAR'}]}
|
||||||
value={mutation.kind === 'none' || mutation.kind === 'derive' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
|
value={mutation.kind === 'none' || mutation.kind === 'transfer' ? 'add' : mutation.kind}
|
||||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'clear')}
|
onchange={(values) => updateMutation(clauseIndex,mutationIndex,values[0] as 'add'|'set'|'consume'|'derive'|'clear')}
|
||||||
/>
|
/>
|
||||||
<SegmentedChoice
|
<SegmentedChoice
|
||||||
legend="STATE POOL"
|
legend="STATE POOL"
|
||||||
@@ -1169,10 +1241,12 @@
|
|||||||
options={rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))}
|
options={rule.pools.map((pool) => ({value:pool.key,label:pool.label.toUpperCase()}))}
|
||||||
value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''}
|
value={'pool' in mutation ? mutation.pool : rule.pools[0]?.key ?? ''}
|
||||||
columns={Math.min(4,Math.max(1,rule.pools.length))}
|
columns={Math.min(4,Math.max(1,rule.pools.length))}
|
||||||
onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
|
onchange={(values) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'clear' ? 'clear' : mutation.kind === 'derive' ? 'derive' : mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',values[0])}
|
||||||
/>
|
/>
|
||||||
{#if mutation.kind !== 'clear'}
|
{#if mutation.kind !== 'clear' && mutation.kind !== 'derive'}
|
||||||
<label class="field">VALUE<input type="number" step="any" value={'value' in mutation && typeof mutation.value === 'number' ? mutation.value : 1} oninput={(event) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',undefined,Number(event.currentTarget.value))}/></label>
|
<label class="field">VALUE<input type="number" step="any" value={'value' in mutation && typeof mutation.value === 'number' ? mutation.value : 1} oninput={(event) => updateMutation(clauseIndex,mutationIndex,mutation.kind === 'set' || mutation.kind === 'consume' ? mutation.kind : 'add',undefined,Number(event.currentTarget.value))}/></label>
|
||||||
|
{:else if mutation.kind === 'derive'}
|
||||||
|
<div class="branch-readout"><span>VALUE</span><strong>DERIVED SCALAR</strong></div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if 'pool' in mutation && rule.pools.find((pool) => pool.key === mutation.pool)?.scope === 'target'}
|
{#if 'pool' in mutation && rule.pools.find((pool) => pool.key === mutation.pool)?.scope === 'target'}
|
||||||
<SegmentedChoice
|
<SegmentedChoice
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
|
expect(AI_MECHANIC_PROMPT_VERSION).toBe(11);
|
||||||
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
|
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
|
||||||
expect.arrayContaining(['delivery', 'durationSeconds']),
|
expect.arrayContaining(['delivery', 'durationSeconds']),
|
||||||
);
|
);
|
||||||
@@ -136,7 +136,7 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
||||||
'Prompt contract version: 10',
|
'Prompt contract version: 11',
|
||||||
);
|
);
|
||||||
|
|
||||||
const advertised = structuredClone(
|
const advertised = structuredClone(
|
||||||
@@ -185,7 +185,7 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
expect(parsed.ok).toBe(true);
|
expect(parsed.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('advertises the canonical Extra Health change event contract in version 10', () => {
|
it('advertises the canonical Extra Health change event contract in version 11', () => {
|
||||||
const input = JSON.parse(
|
const input = JSON.parse(
|
||||||
buildAiMechanicAuthoringInput({
|
buildAiMechanicAuthoringInput({
|
||||||
request,
|
request,
|
||||||
@@ -196,9 +196,9 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
|
expect(AI_MECHANIC_PROMPT_VERSION).toBe(11);
|
||||||
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
||||||
'Prompt contract version: 10',
|
'Prompt contract version: 11',
|
||||||
);
|
);
|
||||||
expect(input.canonicalCatalog.events).toContainEqual({
|
expect(input.canonicalCatalog.events).toContainEqual({
|
||||||
key: 'extra_health_changed',
|
key: 'extra_health_changed',
|
||||||
@@ -325,7 +325,7 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
expect(parsed.ok).toBe(true);
|
expect(parsed.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('advertises reproducible outcome and clause proc probabilities in version 10', () => {
|
it('advertises reproducible outcome and clause proc probabilities in version 11', () => {
|
||||||
const input = JSON.parse(
|
const input = JSON.parse(
|
||||||
buildAiMechanicAuthoringInput({
|
buildAiMechanicAuthoringInput({
|
||||||
request,
|
request,
|
||||||
@@ -336,7 +336,7 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(AI_MECHANIC_PROMPT_VERSION).toBe(10);
|
expect(AI_MECHANIC_PROMPT_VERSION).toBe(11);
|
||||||
expect(input.canonicalCatalog.grammar.clause.optional).toEqual(
|
expect(input.canonicalCatalog.grammar.clause.optional).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
'procChancePercent',
|
'procChancePercent',
|
||||||
@@ -489,4 +489,62 @@ describe('AI mechanic authoring prompt', () => {
|
|||||||
});
|
});
|
||||||
expect(parsed.ok).toBe(true);
|
expect(parsed.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
it('advertises draft variant-value scaling and the typed proximity input in version 11', () => {
|
||||||
|
const input = JSON.parse(
|
||||||
|
buildAiMechanicAuthoringInput({
|
||||||
|
request: {
|
||||||
|
...request,
|
||||||
|
context: {
|
||||||
|
...request.context,
|
||||||
|
sourceName: 'Close and Personal',
|
||||||
|
sourceDescription:
|
||||||
|
'Increase Weapon Damage by (1)% plus (2)% for each nearby ally with a buff granted by you.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
attributes: [],
|
||||||
|
events: [MECHANIC_EVENT_REGISTRY.proximity_snapshot],
|
||||||
|
actions: [],
|
||||||
|
recipeKeys: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const grammar = input.canonicalCatalog.grammar;
|
||||||
|
const proximityEvent = input.canonicalCatalog.events[0];
|
||||||
|
|
||||||
|
expect(AI_MECHANIC_PROMPT_VERSION).toBe(11);
|
||||||
|
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
||||||
|
'Prompt contract version: 11',
|
||||||
|
);
|
||||||
|
expect(grammar.valueReferences).toContain('variant_value');
|
||||||
|
expect(grammar.draftOutcomeExpressionReferences).toEqual(['variant_value']);
|
||||||
|
expect(grammar.referenceShapes).toContainEqual({
|
||||||
|
ref: { source: 'variant_value' },
|
||||||
|
});
|
||||||
|
expect(proximityEvent.fields).toContainEqual({
|
||||||
|
path: 'values.alliesWithin30MetersWithAgentGrantedBuff',
|
||||||
|
label: 'Allies within 30 meters with an Agent-granted buff',
|
||||||
|
type: 'number',
|
||||||
|
minimum: 0,
|
||||||
|
integer: true,
|
||||||
|
});
|
||||||
|
expect(proximityEvent.strictFields).toBeUndefined();
|
||||||
|
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toEqual(
|
||||||
|
expect.stringContaining('unresolved numbered source placeholder'),
|
||||||
|
);
|
||||||
|
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toEqual(
|
||||||
|
expect.stringContaining('{ref:{source:"variant_value"}}'),
|
||||||
|
);
|
||||||
|
expect(input.examplesAndRules).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.stringContaining('only inside an outcome variant expression'),
|
||||||
|
expect.stringContaining('outcome variants[variantKey].value'),
|
||||||
|
expect.stringContaining(
|
||||||
|
'{ref:{source:"event",path:"values.alliesWithin30MetersWithAgentGrantedBuff"}}',
|
||||||
|
),
|
||||||
|
expect.stringContaining(
|
||||||
|
'{key:"allies-within-30m-with-agent-granted-buff"',
|
||||||
|
),
|
||||||
|
expect.stringContaining('readiness:"ready",min:0'),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mec
|
|||||||
import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic';
|
import { MECHANIC_COMBAT_REFERENCE_REGISTRY } from '$lib/calculations/mechanic';
|
||||||
import type { AiMechanicAttribute } from './mechanic-authoring-context';
|
import type { AiMechanicAttribute } from './mechanic-authoring-context';
|
||||||
|
|
||||||
export const AI_MECHANIC_PROMPT_VERSION = 10;
|
export const AI_MECHANIC_PROMPT_VERSION = 11;
|
||||||
|
|
||||||
const mechanicsGrammar = {
|
const mechanicsGrammar = {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -83,6 +83,7 @@ const mechanicsGrammar = {
|
|||||||
'attribute',
|
'attribute',
|
||||||
'reducer',
|
'reducer',
|
||||||
'pool',
|
'pool',
|
||||||
|
'variant_value',
|
||||||
],
|
],
|
||||||
combatReferences: MECHANIC_COMBAT_REFERENCE_REGISTRY,
|
combatReferences: MECHANIC_COMBAT_REFERENCE_REGISTRY,
|
||||||
numericExpressionOperators: [
|
numericExpressionOperators: [
|
||||||
@@ -95,7 +96,9 @@ const mechanicsGrammar = {
|
|||||||
'clamp',
|
'clamp',
|
||||||
'floor',
|
'floor',
|
||||||
],
|
],
|
||||||
|
draftOutcomeExpressionReferences: ['variant_value'],
|
||||||
referenceShapes: [
|
referenceShapes: [
|
||||||
|
{ ref: { source: 'variant_value' } },
|
||||||
{ ref: { source: 'event', path: 'values.distanceMeters' } },
|
{ ref: { source: 'event', path: 'values.distanceMeters' } },
|
||||||
{ ref: { source: 'scenario', key: 'input-key' } },
|
{ ref: { source: 'scenario', key: 'input-key' } },
|
||||||
{ ref: { source: 'combat', path: 'currentHealth', timing: 'current' } },
|
{ ref: { source: 'combat', path: 'currentHealth', timing: 'current' } },
|
||||||
@@ -498,6 +501,7 @@ Security and scope:
|
|||||||
- Use only the supplied event, action, recipe, attribute, scope, reference, expression, reducer, mutation, and outcome catalogs.
|
- Use only the supplied event, action, recipe, attribute, scope, reference, expression, reducer, mutation, and outcome catalogs.
|
||||||
- Never invent an Attribute ID, event, field path, source variant, or runtime behavior.
|
- Never invent an Attribute ID, event, field path, source variant, or runtime behavior.
|
||||||
- Ask focused questions when different answers would change the calculation. Do not guess material cadence, duration, target, stacking, reset, scope, damage basis, or value behavior.
|
- Ask focused questions when different answers would change the calculation. Do not guess material cadence, duration, target, stacking, reset, scope, damage basis, or value behavior.
|
||||||
|
- An unresolved numbered source placeholder is a material missing value. Ask the human for the exact numeric value for every affected source variant; never infer or guess it.
|
||||||
- If the behavior cannot be represented by the supplied grammar, return capability_gap with implementation-ready developer changes.
|
- If the behavior cannot be represented by the supplied grammar, return capability_gap with implementation-ready developer changes.
|
||||||
|
|
||||||
Response rules:
|
Response rules:
|
||||||
@@ -507,6 +511,7 @@ Response rules:
|
|||||||
- All keys must be stable lower-case identifiers using letters, digits, dashes, or underscores and beginning with a letter.
|
- All keys must be stable lower-case identifiers using letters, digits, dashes, or underscores and beginning with a letter.
|
||||||
- A draft must define exactly the supplied value variants for every outcome.
|
- A draft must define exactly the supplied value variants for every outcome.
|
||||||
- The numeric variant value is the human-facing base value. Use expression only when runtime scaling or a live event/scenario/equipment value changes it.
|
- The numeric variant value is the human-facing base value. Use expression only when runtime scaling or a live event/scenario/equipment value changes it.
|
||||||
|
- Put each clarified placeholder answer in the corresponding outcome variant value as its human-facing value. When that value participates in a runtime expression, reference it exactly as {ref:{source:"variant_value"}}; never duplicate the numeric literal in the expression. variant_value is draft-only and valid only inside an outcome variant expression.
|
||||||
- Use attribute_modifier only with an exact supplied Attribute ID.
|
- Use attribute_modifier only with an exact supplied Attribute ID.
|
||||||
- Use direct_damage, restoration, or resource_cost rather than a fabricated Attribute when that is the actual mechanic.
|
- Use direct_damage, restoration, or resource_cost rather than a fabricated Attribute when that is the actual mechanic.
|
||||||
- Keep assumptions explicit. Use warnings for representable limitations that do not require code changes.
|
- Keep assumptions explicit. Use warnings for representable limitations that do not require code changes.
|
||||||
@@ -551,9 +556,14 @@ export function buildAiMechanicAuthoringInput(input: {
|
|||||||
'A target-scoped pool owns an independent value and expiry timer for each entity. Bind hit-created target state with entity:{kind:"event_target"}; do not collapse different enemies into one Agent-scoped pool.',
|
'A target-scoped pool owns an independent value and expiry timer for each entity. Bind hit-created target state with entity:{kind:"event_target"}; do not collapse different enemies into one Agent-scoped pool.',
|
||||||
'Writing a target-scoped pool refreshes only that target entity timer. An on_exit outcome runs when that entity expires and uses the entity previous state for stack bands and perStack multiplication, even though the stored pool value has already reset.',
|
'Writing a target-scoped pool refreshes only that target entity timer. An on_exit outcome runs when that entity expires and uses the entity previous state for stack bands and perStack multiplication, even though the stored pool value has already reset.',
|
||||||
'When an outcome value is exactly a live Attribute amount, use the Attribute reference directly as the variant expression. Divide by 100 only when a displayed percentage is being used as a multiplier.',
|
'When an outcome value is exactly a live Attribute amount, use the Attribute reference directly as the variant expression. Divide by 100 only when a displayed percentage is being used as a multiplier.',
|
||||||
|
'variant_value is draft-only and may appear only inside an outcome variant expression, with the exact shape {ref:{source:"variant_value"}}. It resolves to that same variant object’s human-facing numeric value at runtime.',
|
||||||
|
'If source wording contains an unresolved numbered placeholder such as {1} or {2}, return clarification_required and ask for the exact numeric answer for every affected source variant. Never guess a placeholder value.',
|
||||||
|
'After clarification, put each answer in the corresponding outcome variants[variantKey].value. If a runtime-scaled expression uses that answer, reference {ref:{source:"variant_value"}} rather than duplicating the numeric literal.',
|
||||||
'A weapon_hit may compare values.aiming with true only when the wording requires aiming down sights at hit resolution. Missing aiming evidence does not match; do not replace it with a scenario assumption.',
|
'A weapon_hit may compare values.aiming with true only when the wording requires aiming down sights at hit resolution. Missing aiming evidence does not match; do not replace it with a scenario assumption.',
|
||||||
'An explosion centered on the hit target uses target-scoped direct_damage with targeting kind event_target_area, center event_target, validTargets enemies, and the stated positive radiusMeters. The runtime applies one equal damage emission to each authoritative valid enemy at or inside the radius. Do not use legacy within_radius metadata for explosion damage.',
|
'An explosion centered on the hit target uses target-scoped direct_damage with targeting kind event_target_area, center event_target, validTargets enemies, and the stated positive radiusMeters. The runtime applies one equal damage emission to each authoritative valid enemy at or inside the radius. Do not use legacy within_radius metadata for explosion damage.',
|
||||||
'Precise Trauma is representable as one target-scoped stack pool with a one-second expiry: body shots add one stack, headshots add two, and one on_exit direct-damage outcome uses the expiring target previous stack count. This models the stated one-second debuff without inventing generic repeated target ticks.',
|
'Precise Trauma is representable as one target-scoped stack pool with a one-second expiry: body shots add one stack, headshots add two, and one on_exit direct-damage outcome uses the expiring target previous stack count. This models the stated one-second debuff without inventing generic repeated target ticks.',
|
||||||
|
'For proximity_snapshot, the optional values.alliesWithin30MetersWithAgentGrantedBuff field is a non-negative integer count of distinct allied Agents at or within 30 meters who possess at least one buff granted by the source Agent. Multiple qualifying buffs on one ally still count that ally once. Read it with {ref:{source:"event",path:"values.alliesWithin30MetersWithAgentGrantedBuff"}}.',
|
||||||
|
'When loadout timelines must supply values.alliesWithin30MetersWithAgentGrantedBuff, define the ready numeric scenario input exactly as {key:"allies-within-30m-with-agent-granted-buff",label:"Allies within 30 meters with an Agent-granted buff",kind:"number",readiness:"ready",min:0}. The loadout timeline copies that resolved count into each proximity_snapshot event, and runtime event validation rejects non-integers.',
|
||||||
'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.',
|
'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.',
|
||||||
'Assigned-ability scope may be used only when allowAssignedAbility is true.',
|
'Assigned-ability scope may be used only when allowAssignedAbility is true.',
|
||||||
'When allowAssignedAbility is true, a kill or assist that must occur while the source item\'s assigned ability is active uses enemy_killed or assist with {source:"event",path:"values.assignedAbilityActive"} compared to true. This field is optional on the event; a missing value does not match.',
|
'When allowAssignedAbility is true, a kill or assist that must occur while the source item\'s assigned ability is active uses enemy_killed or assist with {source:"event",path:"values.assignedAbilityActive"} compared to true. This field is optional on the event; a missing value does not match.',
|
||||||
|
|||||||
@@ -502,16 +502,12 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
case 'skill-chips':
|
case 'skill-chips':
|
||||||
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`;
|
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`;
|
||||||
rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit,
|
rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit,
|
||||||
talents.name AS talent_name,abilities.name AS ability_name,specializations.name AS specialization_name,
|
talents.name AS talent_name,talents.description_template AS talent_description,
|
||||||
(SELECT COUNT(*) FROM skill_chip_primary_options) AS primary_count,
|
|
||||||
(SELECT COUNT(*) FROM skill_chip_secondary_options) AS secondary_count,
|
|
||||||
icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at
|
icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at
|
||||||
FROM skill_chip_sets item
|
FROM skill_chip_sets item
|
||||||
LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id
|
LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id
|
||||||
LEFT JOIN stat_attributes attributes ON attributes.id=bonus.stat_attribute_id
|
LEFT JOIN stat_attributes attributes ON attributes.id=bonus.stat_attribute_id
|
||||||
LEFT JOIN skill_chip_talents talents ON talents.skill_chip_set_id=item.id
|
LEFT JOIN skill_chip_talents talents ON talents.skill_chip_set_id=item.id
|
||||||
LEFT JOIN specialization_abilities abilities ON abilities.id=item.specialization_ability_id
|
|
||||||
LEFT JOIN specializations ON specializations.id=abilities.specialization_id
|
|
||||||
LEFT JOIN game_data_icons icons ON icons.entity_type='skill_chip' AND icons.entity_id=item.id
|
LEFT JOIN game_data_icons icons ON icons.entity_type='skill_chip' AND icons.entity_id=item.id
|
||||||
LEFT JOIN managed_images render ON render.source_entity_type='skill_chip_render' AND render.source_entity_id=item.id
|
LEFT JOIN managed_images render ON render.source_entity_type='skill_chip_render' AND render.source_entity_id=item.id
|
||||||
WHERE item.active=1${searchClause}
|
WHERE item.active=1${searchClause}
|
||||||
@@ -1035,23 +1031,12 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
iconUrl: iconUrl('skill_chip', row.id, row.icon_updated_at),
|
iconUrl: iconUrl('skill_chip', row.id, row.icon_updated_at),
|
||||||
badges: [{ label: 'SUPERIOR' }, { label: 'HIGH-END' }],
|
badges: [{ label: 'SUPERIOR' }, { label: 'HIGH-END' }],
|
||||||
facts: [
|
facts: [
|
||||||
{
|
|
||||||
label: 'SPECIALIZATION ABILITY',
|
|
||||||
value: row.ability_name
|
|
||||||
? `${row.ability_name} · ${row.specialization_name}`
|
|
||||||
: 'UNASSIGNED',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: '2-PIECE BONUS',
|
label: '2-PIECE BONUS',
|
||||||
value: row.bonus_stat
|
value: row.bonus_stat
|
||||||
? `${row.bonus_stat} · ${range(row.min_value, row.max_value, row.bonus_unit)}`
|
? `${row.bonus_stat} · ${range(row.min_value, row.max_value, row.bonus_unit)}`
|
||||||
: 'NOT CONFIGURED',
|
: 'NOT CONFIGURED',
|
||||||
},
|
},
|
||||||
{ label: 'PRIMARY POOL', value: `${row.primary_count} OPTIONS` },
|
|
||||||
{
|
|
||||||
label: 'SECONDARY POOL',
|
|
||||||
value: `${row.secondary_count} OPTIONS`,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
lines: [
|
lines: [
|
||||||
{
|
{
|
||||||
@@ -1059,6 +1044,9 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
value: list(row.talent_name, 'NOT CONFIGURED'),
|
value: list(row.talent_name, 'NOT CONFIGURED'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
description: row.talent_description
|
||||||
|
? String(row.talent_description)
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
case 'specializations':
|
case 'specializations':
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
3.12
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Copy this file to $XDG_CONFIG_HOME/shd-capture/config.toml and adjust local limits.
|
|
||||||
# The utility reads the OpenAI key from the named environment variable only.
|
|
||||||
|
|
||||||
[server]
|
|
||||||
# Only loopback IP literals are valid: 127.0.0.1 or ::1.
|
|
||||||
host = "127.0.0.1"
|
|
||||||
port = 8765
|
|
||||||
|
|
||||||
[lifecycle]
|
|
||||||
# One local process owns this instance name and releases its runtime lock on shutdown.
|
|
||||||
instance_name = "shd-capture"
|
|
||||||
lock_timeout_seconds = 5
|
|
||||||
shutdown_timeout_seconds = 30
|
|
||||||
|
|
||||||
[limits]
|
|
||||||
max_image_bytes = 33554432
|
|
||||||
max_video_bytes = 4294967296
|
|
||||||
max_queue_items = 64
|
|
||||||
max_ocr_text_characters = 20000
|
|
||||||
|
|
||||||
[openai]
|
|
||||||
# Set this name in the process environment. Do not put the key in this file.
|
|
||||||
api_key_env = "OPENAI_API_KEY"
|
|
||||||
model = "gpt-4.1-mini"
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
# Game Data Capture Contracts
|
|
||||||
|
|
||||||
RG-1 freezes contract version 1 for the taxonomy, workflow states, evidence
|
|
||||||
model, archive manifest, and ISAC handoff boundary. The approved GDC-001
|
|
||||||
mod-family change extends version 1 with `weapon_mod` and `gear_mod`.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
- `domain-contract-v1.json` lists every family/view pair, record type, Gear
|
|
||||||
slot, abstention reason, state, and allowed transition.
|
|
||||||
- `screenshot-classification.schema.json` validates one hierarchical screen
|
|
||||||
classification.
|
|
||||||
- `evidence-archive-manifest-v1.schema.json` validates a private evidence
|
|
||||||
archive manifest.
|
|
||||||
- `isac-handoff-v1.schema.json` validates a bounded draft handoff for
|
|
||||||
authenticated ISAC intake.
|
|
||||||
- `examples/classification/` contains one valid and one invalid classification
|
|
||||||
for each family, including Weapon Mod, Gear Mod, and explicit `unknown`
|
|
||||||
abstention.
|
|
||||||
- `examples/archive/` and `examples/handoff/` exercise each versioned document
|
|
||||||
schema.
|
|
||||||
|
|
||||||
The schemas use JSON Schema Draft 2020-12, internal `$ref` values, standard
|
|
||||||
formats, and no implementation-specific keywords. Python `jsonschema` and
|
|
||||||
TypeScript validators such as Ajv can compile the files without model
|
|
||||||
conversion. Consumers must reject a document unless `schema_version` equals the
|
|
||||||
schema's `const` value. Consumers must also run semantic reference validation
|
|
||||||
because JSON Schema cannot prove that an ID points to an item in another array.
|
|
||||||
|
|
||||||
## Mod Screen Semantics
|
|
||||||
|
|
||||||
Repository source data and editors establish `weapon_mod` and `gear_mod` as
|
|
||||||
separate canonical aggregates. Each family currently supports only the `detail`
|
|
||||||
view and its same-named record type. A Mod classification never sets the
|
|
||||||
six-slot `gear_slot` field. A Weapon Mod detail screenshot may provide evidence
|
|
||||||
for item-owned slot, primary quality ranges, compatibility, Mod Effects, exotic
|
|
||||||
Attributes, and weapon-lock data. A Gear Mod detail screenshot may provide
|
|
||||||
evidence for its item-owned primary quality ranges. These fields and
|
|
||||||
relationships are not separate screen views or record types.
|
|
||||||
|
|
||||||
Each mod family has one global canonical secondary Attribute pool configured
|
|
||||||
outside individual item details. The pool stores Level 1 and Level 20 minimum
|
|
||||||
and maximum ranges for Specialized, Superior, and High-end quality. Capacity is
|
|
||||||
zero for Standard, one for Specialized, and two each for Superior and High-end.
|
|
||||||
A secondary roll visible on an item-detail screenshot is evidence for that
|
|
||||||
observed item instance; it does not establish a pool option, canonical range
|
|
||||||
endpoint, or family-wide capacity.
|
|
||||||
|
|
||||||
The `record_types` array still supports composite screens. No repository
|
|
||||||
evidence establishes another independent canonical record type on a Weapon Mod
|
|
||||||
or Gear Mod detail screen, so these two mappings each contain one record type. A
|
|
||||||
classifier must use `unknown` with an abstention reason for another Mod view, an
|
|
||||||
ambiguous family, or unsupported content.
|
|
||||||
|
|
||||||
## Skill Chip Screen Semantics
|
|
||||||
|
|
||||||
The implemented Skill Chip model assigns each definition to one specialization
|
|
||||||
ability. Each set owns one 2-piece bonus and may own one 3-piece Talent. One
|
|
||||||
shared fixed primary profile serves every definition: Skill Duration, Skill
|
|
||||||
Radius, Skill Cooldown Recovery, and Skill Intensity each store direct minimum
|
|
||||||
and maximum values for Standard, Specialized, Superior, and High-end. These
|
|
||||||
primary values apply to the assigned ability. Current Agent and combat totals
|
|
||||||
exclude them and present them as `NOT YET DEFINED`.
|
|
||||||
|
|
||||||
One shared secondary Attribute pool also serves every definition. Its entries
|
|
||||||
store direct minimum and maximum values for Specialized, Superior, and High-end.
|
|
||||||
Secondary capacity is zero for Standard, one for Specialized, and two each for
|
|
||||||
Superior and High-end. Skill Chips have no item levels, so workers must not
|
|
||||||
infer level curves or level-based endpoints from Skill Chip evidence.
|
|
||||||
|
|
||||||
Every equipped quality counts toward the two-piece threshold. Only equipped
|
|
||||||
High-end chips increase `h` in `minimum + (maximum - minimum) * h / 3`, where
|
|
||||||
`h` ranges from zero through three. The unchanged version 1 contract vocabulary
|
|
||||||
uses `skill_chip_roll_pool` for captured roll-profile evidence. Handoff
|
|
||||||
consumers must map that evidence to the shared primary profile or shared
|
|
||||||
secondary pool and must not attach either profile to an individual definition.
|
|
||||||
The JSON schemas remain unchanged.
|
|
||||||
|
|
||||||
## Version Decision
|
|
||||||
|
|
||||||
The approved additive taxonomy change remains schema version 1. The document
|
|
||||||
shapes, trust boundary, bounds, and meanings of every existing value are
|
|
||||||
unchanged, and all existing version 1 labels and documents remain valid. This
|
|
||||||
utility and its payloads are prototype formats; no production-stable archive or
|
|
||||||
handoff version has been declared. A version increment would force a document
|
|
||||||
migration without representing a structural or semantic break.
|
|
||||||
|
|
||||||
Consumers must update their closed version 1 vocabularies before they accept Mod
|
|
||||||
labels. Older consumers reject the new values, which keeps the boundary fail
|
|
||||||
closed. Existing stored labels need validation against the revised vocabulary
|
|
||||||
but no value rewrite. The capture-label UI, storage regression coverage,
|
|
||||||
classifier, extraction dispatch, evidence export, and ISAC adapter mappings must
|
|
||||||
consume both families and their exact `detail`/record-type pairs before their
|
|
||||||
respective gates can pass. This contract change does not alter those downstream
|
|
||||||
implementations.
|
|
||||||
|
|
||||||
## Evidence Rules
|
|
||||||
|
|
||||||
Capture workers store source text, a normalized value, confidence, engine
|
|
||||||
identity, an image digest, a bounded region, and append-only correction history
|
|
||||||
for each evidence value. A null normalized value requires a `missing_reason`. A
|
|
||||||
present normalized value requires a null `missing_reason`. Each correction's
|
|
||||||
`before` state must match the preceding `after` state. Timestamps must increase,
|
|
||||||
and the final `after` state must match the current evidence value.
|
|
||||||
|
|
||||||
Workers record the configured item shown on screen under `observed_instances`,
|
|
||||||
including visible values and any applicable quality, tier, upgrade state, or
|
|
||||||
level. Skill Chip observations omit item level because Skill Chips have none.
|
|
||||||
Workers place proposed reusable definitions under `canonical_candidates` and
|
|
||||||
handoff `records`. For Weapon Mods, Gear Mods, and Skill Chips, item-detail
|
|
||||||
evidence remains separate from each family’s shared canonical profiles. A worker
|
|
||||||
must not copy an observed roll, relationship, mechanic, range endpoint, option
|
|
||||||
list, or secondary capacity into a canonical candidate unless evidence
|
|
||||||
establishes that canonical claim.
|
|
||||||
|
|
||||||
## Trust Boundary
|
|
||||||
|
|
||||||
Archive and handoff documents contain content hashes and logical artifact IDs.
|
|
||||||
They exclude host paths and credentials. The handoff declares
|
|
||||||
`mutation_authority: "none"`. Importing or inspecting it grants no permission
|
|
||||||
and performs no mutation. An authenticated ISAC action must revalidate each
|
|
||||||
draft and preserve contributor moderation and audit behavior.
|
|
||||||
|
|
||||||
Archive images and handoff images allow at most 64 MiB. Private archive videos
|
|
||||||
allow at most 16 GiB, matching the configured capture ceiling. Schema validation
|
|
||||||
enforces media-specific byte limits. Semantic validation enforces unique entity
|
|
||||||
and correction IDs, local references, evidence artifact hashes, correction
|
|
||||||
chains, and normalized image bounds.
|
|
||||||
|
|
||||||
The contracts assign explicit states or reasons to cancellation, resource
|
|
||||||
exhaustion, ambiguous classification, unsupported content, and extraction
|
|
||||||
failure. Recovery requires a listed transition. Unknown transitions and future
|
|
||||||
schema versions fail closed.
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
{
|
|
||||||
"contract_version": 1,
|
|
||||||
"families": {
|
|
||||||
"weapon": {
|
|
||||||
"gear_slot": false,
|
|
||||||
"views": {
|
|
||||||
"detail": ["weapon", "weapon_talent"],
|
|
||||||
"blueprint": ["weapon", "weapon_talent"],
|
|
||||||
"crafting": ["weapon"],
|
|
||||||
"stat_range": ["weapon"],
|
|
||||||
"talent_grid": ["weapon_talent"],
|
|
||||||
"augmentation": ["weapon_augmentation_profile"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"gear": {
|
|
||||||
"gear_slot": true,
|
|
||||||
"views": {
|
|
||||||
"detail": ["gear", "brand_set"],
|
|
||||||
"blueprint": ["gear", "brand_set"],
|
|
||||||
"talent": ["gear_talent"],
|
|
||||||
"augmentation": ["gear_augmentation_profile"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"skill_chip": {
|
|
||||||
"gear_slot": false,
|
|
||||||
"views": {
|
|
||||||
"detail": ["skill_chip_set", "skill_chip_roll_pool"],
|
|
||||||
"set_bonus": ["skill_chip_set_bonus"],
|
|
||||||
"talent": ["skill_chip_talent"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"os_protocol": {
|
|
||||||
"gear_slot": false,
|
|
||||||
"views": {
|
|
||||||
"detail": ["os_protocol"],
|
|
||||||
"secondary_profile": ["os_protocol_secondary_profile"],
|
|
||||||
"talent": ["os_protocol_talent"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"specialization": {
|
|
||||||
"gear_slot": false,
|
|
||||||
"views": {
|
|
||||||
"overview": ["specialization"],
|
|
||||||
"details": ["specialization"],
|
|
||||||
"ability": ["specialization_ability"],
|
|
||||||
"focus_details": ["specialization_focus"],
|
|
||||||
"focus_tree": ["specialization_focus", "specialization_tree_node"],
|
|
||||||
"rank_details": ["specialization_tree_rank"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"weapon_mod": {
|
|
||||||
"gear_slot": false,
|
|
||||||
"views": {
|
|
||||||
"detail": ["weapon_mod"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"gear_mod": {
|
|
||||||
"gear_slot": false,
|
|
||||||
"views": {
|
|
||||||
"detail": ["gear_mod"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"unknown": {
|
|
||||||
"gear_slot": false,
|
|
||||||
"views": { "unknown": [] }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"gear_slots": [
|
|
||||||
"Mask",
|
|
||||||
"Gloves",
|
|
||||||
"Backpack",
|
|
||||||
"Body Armor",
|
|
||||||
"Holster",
|
|
||||||
"Knee Pads"
|
|
||||||
],
|
|
||||||
"abstention_reasons": [
|
|
||||||
"low_confidence",
|
|
||||||
"ambiguous_family",
|
|
||||||
"ambiguous_view",
|
|
||||||
"unsupported_family",
|
|
||||||
"unsupported_view",
|
|
||||||
"insufficient_visual_evidence",
|
|
||||||
"occluded",
|
|
||||||
"corrupt_image",
|
|
||||||
"resource_limit",
|
|
||||||
"cancelled"
|
|
||||||
],
|
|
||||||
"missing_reasons": [
|
|
||||||
"not_visible",
|
|
||||||
"occluded",
|
|
||||||
"unreadable",
|
|
||||||
"not_present_on_screen",
|
|
||||||
"unsupported",
|
|
||||||
"ambiguous",
|
|
||||||
"extraction_failed",
|
|
||||||
"not_applicable",
|
|
||||||
"redacted"
|
|
||||||
],
|
|
||||||
"states": {
|
|
||||||
"capture": {
|
|
||||||
"received": ["spooling", "deduplicated", "failed", "cancelled"],
|
|
||||||
"spooling": ["stored", "failed", "cancelled"],
|
|
||||||
"stored": ["queued", "failed"],
|
|
||||||
"deduplicated": [],
|
|
||||||
"queued": [],
|
|
||||||
"failed": ["spooling", "cancelled"],
|
|
||||||
"cancelled": []
|
|
||||||
},
|
|
||||||
"job": {
|
|
||||||
"queued": ["running", "cancelled"],
|
|
||||||
"running": ["succeeded", "retry_wait", "failed", "cancelled"],
|
|
||||||
"retry_wait": ["queued", "failed", "cancelled"],
|
|
||||||
"succeeded": [],
|
|
||||||
"failed": ["queued"],
|
|
||||||
"cancelled": []
|
|
||||||
},
|
|
||||||
"extraction": {
|
|
||||||
"pending": ["local_processing", "cancelled"],
|
|
||||||
"local_processing": [
|
|
||||||
"candidate_ready",
|
|
||||||
"fallback_pending",
|
|
||||||
"abstained",
|
|
||||||
"failed",
|
|
||||||
"cancelled"
|
|
||||||
],
|
|
||||||
"fallback_pending": ["fallback_processing", "cancelled"],
|
|
||||||
"fallback_processing": [
|
|
||||||
"candidate_ready",
|
|
||||||
"abstained",
|
|
||||||
"failed",
|
|
||||||
"cancelled"
|
|
||||||
],
|
|
||||||
"candidate_ready": [],
|
|
||||||
"abstained": [],
|
|
||||||
"failed": ["pending"],
|
|
||||||
"cancelled": []
|
|
||||||
},
|
|
||||||
"grouping": {
|
|
||||||
"ungrouped": ["proposed", "cancelled"],
|
|
||||||
"proposed": ["grouped", "rejected", "cancelled"],
|
|
||||||
"grouped": ["split", "superseded"],
|
|
||||||
"split": ["superseded"],
|
|
||||||
"superseded": [],
|
|
||||||
"rejected": ["ungrouped"],
|
|
||||||
"cancelled": []
|
|
||||||
},
|
|
||||||
"review": {
|
|
||||||
"pending": ["in_review", "cancelled"],
|
|
||||||
"in_review": ["changes_requested", "approved", "rejected", "cancelled"],
|
|
||||||
"changes_requested": ["in_review", "rejected", "cancelled"],
|
|
||||||
"approved": ["in_review"],
|
|
||||||
"rejected": ["in_review"],
|
|
||||||
"cancelled": []
|
|
||||||
},
|
|
||||||
"export": {
|
|
||||||
"not_requested": ["queued"],
|
|
||||||
"queued": ["building", "cancelled"],
|
|
||||||
"building": ["ready", "failed", "cancelled"],
|
|
||||||
"ready": ["expired"],
|
|
||||||
"failed": ["queued", "cancelled"],
|
|
||||||
"cancelled": [],
|
|
||||||
"expired": ["queued"]
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"open": ["retryable", "terminal", "dismissed"],
|
|
||||||
"retryable": ["retrying", "terminal", "dismissed"],
|
|
||||||
"retrying": ["recovered", "retryable", "terminal"],
|
|
||||||
"recovered": [],
|
|
||||||
"terminal": ["dismissed"],
|
|
||||||
"dismissed": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,924 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"$id": "https://shd.build/schemas/game-data-capture/evidence-archive-manifest-v1.json",
|
|
||||||
"title": "Game data capture evidence archive manifest v1",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"schema_version",
|
|
||||||
"archive_id",
|
|
||||||
"created_at",
|
|
||||||
"generator",
|
|
||||||
"artifacts",
|
|
||||||
"captures",
|
|
||||||
"screens",
|
|
||||||
"jobs",
|
|
||||||
"extractions",
|
|
||||||
"groups",
|
|
||||||
"reviews",
|
|
||||||
"exports",
|
|
||||||
"errors",
|
|
||||||
"evidence_values",
|
|
||||||
"observed_instances",
|
|
||||||
"canonical_candidates"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"schema_version": { "const": 1 },
|
|
||||||
"archive_id": { "$ref": "#/$defs/id" },
|
|
||||||
"created_at": { "$ref": "#/$defs/timestamp" },
|
|
||||||
"generator": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["name", "version", "policy_version"],
|
|
||||||
"properties": {
|
|
||||||
"name": { "type": "string", "minLength": 1, "maxLength": 100 },
|
|
||||||
"version": { "type": "string", "minLength": 1, "maxLength": 50 },
|
|
||||||
"policy_version": { "type": "string", "minLength": 1, "maxLength": 50 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"artifacts": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 10000,
|
|
||||||
"items": { "$ref": "#/$defs/artifact" }
|
|
||||||
},
|
|
||||||
"captures": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 10000,
|
|
||||||
"items": { "$ref": "#/$defs/capture" }
|
|
||||||
},
|
|
||||||
"screens": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 10000,
|
|
||||||
"items": { "$ref": "#/$defs/screen" }
|
|
||||||
},
|
|
||||||
"jobs": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 20000,
|
|
||||||
"items": { "$ref": "#/$defs/job" }
|
|
||||||
},
|
|
||||||
"extractions": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 20000,
|
|
||||||
"items": { "$ref": "#/$defs/extraction" }
|
|
||||||
},
|
|
||||||
"groups": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 10000,
|
|
||||||
"items": { "$ref": "#/$defs/group" }
|
|
||||||
},
|
|
||||||
"reviews": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 10000,
|
|
||||||
"items": { "$ref": "#/$defs/review" }
|
|
||||||
},
|
|
||||||
"exports": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 1000,
|
|
||||||
"items": { "$ref": "#/$defs/export" }
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 20000,
|
|
||||||
"items": { "$ref": "#/$defs/error" }
|
|
||||||
},
|
|
||||||
"evidence_values": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 100000,
|
|
||||||
"items": { "$ref": "#/$defs/evidenceValue" }
|
|
||||||
},
|
|
||||||
"observed_instances": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 20000,
|
|
||||||
"items": { "$ref": "#/$defs/observedInstance" }
|
|
||||||
},
|
|
||||||
"canonical_candidates": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 20000,
|
|
||||||
"items": { "$ref": "#/$defs/canonicalCandidate" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"$defs": {
|
|
||||||
"id": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
|
||||||
},
|
|
||||||
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
|
|
||||||
"timestamp": { "type": "string", "format": "date-time", "maxLength": 40 },
|
|
||||||
"family": {
|
|
||||||
"enum": [
|
|
||||||
"weapon",
|
|
||||||
"gear",
|
|
||||||
"skill_chip",
|
|
||||||
"os_protocol",
|
|
||||||
"specialization",
|
|
||||||
"weapon_mod",
|
|
||||||
"gear_mod",
|
|
||||||
"unknown"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"recordType": {
|
|
||||||
"enum": [
|
|
||||||
"weapon",
|
|
||||||
"weapon_talent",
|
|
||||||
"weapon_augmentation_profile",
|
|
||||||
"gear",
|
|
||||||
"brand_set",
|
|
||||||
"gear_talent",
|
|
||||||
"gear_augmentation_profile",
|
|
||||||
"skill_chip_set",
|
|
||||||
"skill_chip_set_bonus",
|
|
||||||
"skill_chip_roll_pool",
|
|
||||||
"skill_chip_talent",
|
|
||||||
"os_protocol",
|
|
||||||
"os_protocol_secondary_profile",
|
|
||||||
"os_protocol_talent",
|
|
||||||
"specialization",
|
|
||||||
"specialization_focus",
|
|
||||||
"specialization_ability",
|
|
||||||
"specialization_tree_node",
|
|
||||||
"specialization_tree_rank",
|
|
||||||
"weapon_mod",
|
|
||||||
"gear_mod"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"missingReason": {
|
|
||||||
"enum": [
|
|
||||||
"not_visible",
|
|
||||||
"occluded",
|
|
||||||
"unreadable",
|
|
||||||
"not_present_on_screen",
|
|
||||||
"unsupported",
|
|
||||||
"ambiguous",
|
|
||||||
"extraction_failed",
|
|
||||||
"not_applicable",
|
|
||||||
"redacted"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"jsonValue": {
|
|
||||||
"oneOf": [
|
|
||||||
{ "type": "null" },
|
|
||||||
{ "type": "boolean" },
|
|
||||||
{ "type": "number" },
|
|
||||||
{ "type": "string", "maxLength": 8192 },
|
|
||||||
{
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 500,
|
|
||||||
"items": { "$ref": "#/$defs/jsonValue" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"maxProperties": 500,
|
|
||||||
"additionalProperties": { "$ref": "#/$defs/jsonValue" }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"sourceEngine": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["kind", "engine_id", "engine_version"],
|
|
||||||
"properties": {
|
|
||||||
"kind": {
|
|
||||||
"enum": [
|
|
||||||
"local_ocr",
|
|
||||||
"openai_vision",
|
|
||||||
"classifier",
|
|
||||||
"manual",
|
|
||||||
"video_decoder"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"engine_id": { "type": "string", "minLength": 1, "maxLength": 100 },
|
|
||||||
"engine_version": { "type": "string", "minLength": 1, "maxLength": 100 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"region": {
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["coordinate_space", "x", "y", "width", "height"],
|
|
||||||
"properties": {
|
|
||||||
"coordinate_space": { "const": "normalized" },
|
|
||||||
"x": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
||||||
"y": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
||||||
"width": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 },
|
|
||||||
"height": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["coordinate_space", "x", "y", "width", "height"],
|
|
||||||
"properties": {
|
|
||||||
"coordinate_space": { "const": "pixels" },
|
|
||||||
"x": { "type": "integer", "minimum": 0, "maximum": 100000 },
|
|
||||||
"y": { "type": "integer", "minimum": 0, "maximum": 100000 },
|
|
||||||
"width": { "type": "integer", "minimum": 1, "maximum": 100000 },
|
|
||||||
"height": { "type": "integer", "minimum": 1, "maximum": 100000 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"valueState": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["raw_text", "normalized_value", "missing_reason"],
|
|
||||||
"properties": {
|
|
||||||
"raw_text": { "type": ["string", "null"], "maxLength": 8192 },
|
|
||||||
"normalized_value": { "$ref": "#/$defs/jsonValue" },
|
|
||||||
"missing_reason": {
|
|
||||||
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/missingReason" }]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"normalized_value": { "type": "null" },
|
|
||||||
"missing_reason": { "$ref": "#/$defs/missingReason" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"normalized_value": { "not": { "type": "null" } },
|
|
||||||
"missing_reason": { "type": "null" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"correction": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"correction_id",
|
|
||||||
"corrected_at",
|
|
||||||
"actor",
|
|
||||||
"reason",
|
|
||||||
"before",
|
|
||||||
"after"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"correction_id": { "$ref": "#/$defs/id" },
|
|
||||||
"corrected_at": { "$ref": "#/$defs/timestamp" },
|
|
||||||
"actor": { "enum": ["local_user", "system_reprocess"] },
|
|
||||||
"reason": { "type": "string", "minLength": 1, "maxLength": 1000 },
|
|
||||||
"before": { "$ref": "#/$defs/valueState" },
|
|
||||||
"after": { "$ref": "#/$defs/valueState" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"evidenceValue": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"evidence_id",
|
|
||||||
"field_path",
|
|
||||||
"raw_text",
|
|
||||||
"normalized_value",
|
|
||||||
"missing_reason",
|
|
||||||
"confidence",
|
|
||||||
"source_engine",
|
|
||||||
"image_artifact_id",
|
|
||||||
"image_sha256",
|
|
||||||
"region",
|
|
||||||
"correction_history"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"evidence_id": { "$ref": "#/$defs/id" },
|
|
||||||
"field_path": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^[A-Za-z0-9_.-]{1,256}$"
|
|
||||||
},
|
|
||||||
"raw_text": { "type": ["string", "null"], "maxLength": 8192 },
|
|
||||||
"normalized_value": { "$ref": "#/$defs/jsonValue" },
|
|
||||||
"missing_reason": {
|
|
||||||
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/missingReason" }]
|
|
||||||
},
|
|
||||||
"confidence": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
||||||
"source_engine": { "$ref": "#/$defs/sourceEngine" },
|
|
||||||
"image_artifact_id": { "$ref": "#/$defs/id" },
|
|
||||||
"image_sha256": { "$ref": "#/$defs/sha256" },
|
|
||||||
"region": { "$ref": "#/$defs/region" },
|
|
||||||
"correction_history": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 100,
|
|
||||||
"items": { "$ref": "#/$defs/correction" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"normalized_value": { "type": "null" },
|
|
||||||
"missing_reason": { "$ref": "#/$defs/missingReason" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"normalized_value": { "not": { "type": "null" } },
|
|
||||||
"missing_reason": { "type": "null" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"fieldClaim": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"path",
|
|
||||||
"value",
|
|
||||||
"missing_reason",
|
|
||||||
"evidence_refs",
|
|
||||||
"uncertainty_note"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"path": { "type": "string", "pattern": "^[A-Za-z0-9_.-]{1,256}$" },
|
|
||||||
"value": { "$ref": "#/$defs/jsonValue" },
|
|
||||||
"missing_reason": {
|
|
||||||
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/missingReason" }]
|
|
||||||
},
|
|
||||||
"evidence_refs": {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 100,
|
|
||||||
"uniqueItems": true,
|
|
||||||
"items": { "$ref": "#/$defs/id" }
|
|
||||||
},
|
|
||||||
"uncertainty_note": { "type": ["string", "null"], "maxLength": 2000 }
|
|
||||||
},
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"value": { "type": "null" },
|
|
||||||
"missing_reason": { "$ref": "#/$defs/missingReason" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"value": { "not": { "type": "null" } },
|
|
||||||
"missing_reason": { "type": "null" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"classification": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"family",
|
|
||||||
"view",
|
|
||||||
"gear_slot",
|
|
||||||
"record_types",
|
|
||||||
"confidence",
|
|
||||||
"abstention_reason"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"family": { "$ref": "#/$defs/family" },
|
|
||||||
"view": { "type": "string" },
|
|
||||||
"gear_slot": { "type": ["string", "null"] },
|
|
||||||
"record_types": {
|
|
||||||
"type": "array",
|
|
||||||
"uniqueItems": true,
|
|
||||||
"maxItems": 8,
|
|
||||||
"items": { "$ref": "#/$defs/recordType" }
|
|
||||||
},
|
|
||||||
"confidence": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
||||||
"abstention_reason": { "type": ["string", "null"] }
|
|
||||||
},
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"family": { "const": "weapon" },
|
|
||||||
"view": {
|
|
||||||
"enum": [
|
|
||||||
"detail",
|
|
||||||
"blueprint",
|
|
||||||
"crafting",
|
|
||||||
"stat_range",
|
|
||||||
"talent_grid",
|
|
||||||
"augmentation"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"gear_slot": { "const": null },
|
|
||||||
"record_types": {
|
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
|
||||||
"enum": [
|
|
||||||
"weapon",
|
|
||||||
"weapon_talent",
|
|
||||||
"weapon_augmentation_profile"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"abstention_reason": { "const": null }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"family": { "const": "gear" },
|
|
||||||
"view": {
|
|
||||||
"enum": ["detail", "blueprint", "talent", "augmentation"]
|
|
||||||
},
|
|
||||||
"gear_slot": {
|
|
||||||
"enum": [
|
|
||||||
"Mask",
|
|
||||||
"Gloves",
|
|
||||||
"Backpack",
|
|
||||||
"Body Armor",
|
|
||||||
"Holster",
|
|
||||||
"Knee Pads"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"record_types": {
|
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
|
||||||
"enum": [
|
|
||||||
"gear",
|
|
||||||
"brand_set",
|
|
||||||
"gear_talent",
|
|
||||||
"gear_augmentation_profile"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"abstention_reason": { "const": null }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"family": { "const": "skill_chip" },
|
|
||||||
"view": { "enum": ["detail", "set_bonus", "talent"] },
|
|
||||||
"gear_slot": { "const": null },
|
|
||||||
"record_types": {
|
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
|
||||||
"enum": [
|
|
||||||
"skill_chip_set",
|
|
||||||
"skill_chip_set_bonus",
|
|
||||||
"skill_chip_roll_pool",
|
|
||||||
"skill_chip_talent"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"abstention_reason": { "const": null }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"family": { "const": "os_protocol" },
|
|
||||||
"view": { "enum": ["detail", "secondary_profile", "talent"] },
|
|
||||||
"gear_slot": { "const": null },
|
|
||||||
"record_types": {
|
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
|
||||||
"enum": [
|
|
||||||
"os_protocol",
|
|
||||||
"os_protocol_secondary_profile",
|
|
||||||
"os_protocol_talent"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"abstention_reason": { "const": null }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"family": { "const": "specialization" },
|
|
||||||
"view": {
|
|
||||||
"enum": [
|
|
||||||
"overview",
|
|
||||||
"details",
|
|
||||||
"ability",
|
|
||||||
"focus_details",
|
|
||||||
"focus_tree",
|
|
||||||
"rank_details"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"gear_slot": { "const": null },
|
|
||||||
"record_types": {
|
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
|
||||||
"enum": [
|
|
||||||
"specialization",
|
|
||||||
"specialization_focus",
|
|
||||||
"specialization_ability",
|
|
||||||
"specialization_tree_node",
|
|
||||||
"specialization_tree_rank"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"abstention_reason": { "const": null }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"family": { "const": "weapon_mod" },
|
|
||||||
"view": { "const": "detail" },
|
|
||||||
"gear_slot": { "const": null },
|
|
||||||
"record_types": {
|
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 1,
|
|
||||||
"items": { "const": "weapon_mod" }
|
|
||||||
},
|
|
||||||
"abstention_reason": { "const": null }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"family": { "const": "gear_mod" },
|
|
||||||
"view": { "const": "detail" },
|
|
||||||
"gear_slot": { "const": null },
|
|
||||||
"record_types": {
|
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 1,
|
|
||||||
"items": { "const": "gear_mod" }
|
|
||||||
},
|
|
||||||
"abstention_reason": { "const": null }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"family": { "const": "unknown" },
|
|
||||||
"view": { "const": "unknown" },
|
|
||||||
"gear_slot": { "const": null },
|
|
||||||
"record_types": { "maxItems": 0 },
|
|
||||||
"abstention_reason": {
|
|
||||||
"enum": [
|
|
||||||
"low_confidence",
|
|
||||||
"ambiguous_family",
|
|
||||||
"ambiguous_view",
|
|
||||||
"unsupported_family",
|
|
||||||
"unsupported_view",
|
|
||||||
"insufficient_visual_evidence",
|
|
||||||
"occluded",
|
|
||||||
"corrupt_image",
|
|
||||||
"resource_limit",
|
|
||||||
"cancelled"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"artifact": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"artifact_id",
|
|
||||||
"sha256",
|
|
||||||
"media_type",
|
|
||||||
"byte_size",
|
|
||||||
"sensitivity",
|
|
||||||
"parent_artifact_id",
|
|
||||||
"video_timestamp_ms"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"artifact_id": { "$ref": "#/$defs/id" },
|
|
||||||
"sha256": { "$ref": "#/$defs/sha256" },
|
|
||||||
"media_type": {
|
|
||||||
"enum": [
|
|
||||||
"image/png",
|
|
||||||
"image/webp",
|
|
||||||
"image/avif",
|
|
||||||
"video/mp4",
|
|
||||||
"video/webm",
|
|
||||||
"video/x-matroska"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"byte_size": { "type": "integer", "minimum": 1 },
|
|
||||||
"sensitivity": { "const": "private" },
|
|
||||||
"parent_artifact_id": {
|
|
||||||
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/id" }]
|
|
||||||
},
|
|
||||||
"video_timestamp_ms": {
|
|
||||||
"type": ["integer", "null"],
|
|
||||||
"minimum": 0,
|
|
||||||
"maximum": 86400000
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"media_type": { "enum": ["image/png", "image/webp", "image/avif"] },
|
|
||||||
"byte_size": { "maximum": 67108864 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"media_type": {
|
|
||||||
"enum": ["video/mp4", "video/webm", "video/x-matroska"]
|
|
||||||
},
|
|
||||||
"byte_size": { "maximum": 17179869184 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"capture": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"capture_id",
|
|
||||||
"artifact_id",
|
|
||||||
"state",
|
|
||||||
"received_at",
|
|
||||||
"failure_reason"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"capture_id": { "$ref": "#/$defs/id" },
|
|
||||||
"artifact_id": { "$ref": "#/$defs/id" },
|
|
||||||
"state": {
|
|
||||||
"enum": [
|
|
||||||
"received",
|
|
||||||
"spooling",
|
|
||||||
"stored",
|
|
||||||
"deduplicated",
|
|
||||||
"queued",
|
|
||||||
"failed",
|
|
||||||
"cancelled"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"received_at": { "$ref": "#/$defs/timestamp" },
|
|
||||||
"failure_reason": { "type": ["string", "null"], "maxLength": 2000 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"screen": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"screen_id",
|
|
||||||
"artifact_id",
|
|
||||||
"classified_at",
|
|
||||||
"classification"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"screen_id": { "$ref": "#/$defs/id" },
|
|
||||||
"artifact_id": { "$ref": "#/$defs/id" },
|
|
||||||
"classified_at": { "$ref": "#/$defs/timestamp" },
|
|
||||||
"classification": { "$ref": "#/$defs/classification" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"job": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"job_id",
|
|
||||||
"kind",
|
|
||||||
"state",
|
|
||||||
"attempt",
|
|
||||||
"max_attempts",
|
|
||||||
"last_error_id"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"job_id": { "$ref": "#/$defs/id" },
|
|
||||||
"kind": {
|
|
||||||
"enum": [
|
|
||||||
"classify",
|
|
||||||
"ocr",
|
|
||||||
"vision_fallback",
|
|
||||||
"frame_extract",
|
|
||||||
"export"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"state": {
|
|
||||||
"enum": [
|
|
||||||
"queued",
|
|
||||||
"running",
|
|
||||||
"retry_wait",
|
|
||||||
"succeeded",
|
|
||||||
"failed",
|
|
||||||
"cancelled"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"attempt": { "type": "integer", "minimum": 0, "maximum": 20 },
|
|
||||||
"max_attempts": { "type": "integer", "minimum": 1, "maximum": 20 },
|
|
||||||
"last_error_id": {
|
|
||||||
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/id" }]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"extraction": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"extraction_id",
|
|
||||||
"screen_id",
|
|
||||||
"state",
|
|
||||||
"evidence_refs",
|
|
||||||
"abstention_reason"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"extraction_id": { "$ref": "#/$defs/id" },
|
|
||||||
"screen_id": { "$ref": "#/$defs/id" },
|
|
||||||
"state": {
|
|
||||||
"enum": [
|
|
||||||
"pending",
|
|
||||||
"local_processing",
|
|
||||||
"fallback_pending",
|
|
||||||
"fallback_processing",
|
|
||||||
"candidate_ready",
|
|
||||||
"abstained",
|
|
||||||
"failed",
|
|
||||||
"cancelled"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"evidence_refs": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 1000,
|
|
||||||
"uniqueItems": true,
|
|
||||||
"items": { "$ref": "#/$defs/id" }
|
|
||||||
},
|
|
||||||
"abstention_reason": { "type": ["string", "null"], "maxLength": 100 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"group": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["group_id", "state", "screen_ids", "supersedes_group_ids"],
|
|
||||||
"properties": {
|
|
||||||
"group_id": { "$ref": "#/$defs/id" },
|
|
||||||
"state": {
|
|
||||||
"enum": [
|
|
||||||
"ungrouped",
|
|
||||||
"proposed",
|
|
||||||
"grouped",
|
|
||||||
"split",
|
|
||||||
"superseded",
|
|
||||||
"rejected",
|
|
||||||
"cancelled"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"screen_ids": {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 100,
|
|
||||||
"uniqueItems": true,
|
|
||||||
"items": { "$ref": "#/$defs/id" }
|
|
||||||
},
|
|
||||||
"supersedes_group_ids": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 100,
|
|
||||||
"uniqueItems": true,
|
|
||||||
"items": { "$ref": "#/$defs/id" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"review": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["review_id", "candidate_id", "state", "updated_at", "note"],
|
|
||||||
"properties": {
|
|
||||||
"review_id": { "$ref": "#/$defs/id" },
|
|
||||||
"candidate_id": { "$ref": "#/$defs/id" },
|
|
||||||
"state": {
|
|
||||||
"enum": [
|
|
||||||
"pending",
|
|
||||||
"in_review",
|
|
||||||
"changes_requested",
|
|
||||||
"approved",
|
|
||||||
"rejected",
|
|
||||||
"cancelled"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"updated_at": { "$ref": "#/$defs/timestamp" },
|
|
||||||
"note": { "type": ["string", "null"], "maxLength": 4000 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"export": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["export_id", "state", "artifact_id", "failure_reason"],
|
|
||||||
"properties": {
|
|
||||||
"export_id": { "$ref": "#/$defs/id" },
|
|
||||||
"state": {
|
|
||||||
"enum": [
|
|
||||||
"not_requested",
|
|
||||||
"queued",
|
|
||||||
"building",
|
|
||||||
"ready",
|
|
||||||
"failed",
|
|
||||||
"cancelled",
|
|
||||||
"expired"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"artifact_id": {
|
|
||||||
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/id" }]
|
|
||||||
},
|
|
||||||
"failure_reason": { "type": ["string", "null"], "maxLength": 2000 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["error_id", "state", "code", "message", "retry_after"],
|
|
||||||
"properties": {
|
|
||||||
"error_id": { "$ref": "#/$defs/id" },
|
|
||||||
"state": {
|
|
||||||
"enum": [
|
|
||||||
"open",
|
|
||||||
"retryable",
|
|
||||||
"retrying",
|
|
||||||
"recovered",
|
|
||||||
"terminal",
|
|
||||||
"dismissed"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"code": {
|
|
||||||
"enum": [
|
|
||||||
"cancelled",
|
|
||||||
"resource_limit",
|
|
||||||
"timeout",
|
|
||||||
"invalid_input",
|
|
||||||
"engine_failure",
|
|
||||||
"storage_failure",
|
|
||||||
"network_failure",
|
|
||||||
"schema_rejected",
|
|
||||||
"unsupported",
|
|
||||||
"ambiguous"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"message": { "type": "string", "minLength": 1, "maxLength": 2000 },
|
|
||||||
"retry_after": {
|
|
||||||
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/timestamp" }]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"observedContext": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["level", "quality", "tier", "upgrade_state"],
|
|
||||||
"properties": {
|
|
||||||
"level": { "$ref": "#/$defs/fieldClaim" },
|
|
||||||
"quality": { "$ref": "#/$defs/fieldClaim" },
|
|
||||||
"tier": { "$ref": "#/$defs/fieldClaim" },
|
|
||||||
"upgrade_state": { "$ref": "#/$defs/fieldClaim" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"observedInstance": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"instance_id",
|
|
||||||
"family",
|
|
||||||
"source_screen_ids",
|
|
||||||
"observed_context",
|
|
||||||
"fields"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"instance_id": { "$ref": "#/$defs/id" },
|
|
||||||
"family": { "$ref": "#/$defs/family" },
|
|
||||||
"source_screen_ids": {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 100,
|
|
||||||
"uniqueItems": true,
|
|
||||||
"items": { "$ref": "#/$defs/id" }
|
|
||||||
},
|
|
||||||
"observed_context": { "$ref": "#/$defs/observedContext" },
|
|
||||||
"fields": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 1000,
|
|
||||||
"items": { "$ref": "#/$defs/fieldClaim" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"canonicalCandidate": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"candidate_id",
|
|
||||||
"record_type",
|
|
||||||
"source_instance_ids",
|
|
||||||
"evidence_refs",
|
|
||||||
"fields",
|
|
||||||
"completeness",
|
|
||||||
"relationship_status",
|
|
||||||
"mechanic_status"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"candidate_id": { "$ref": "#/$defs/id" },
|
|
||||||
"record_type": { "$ref": "#/$defs/recordType" },
|
|
||||||
"source_instance_ids": {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 100,
|
|
||||||
"uniqueItems": true,
|
|
||||||
"items": { "$ref": "#/$defs/id" }
|
|
||||||
},
|
|
||||||
"evidence_refs": {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 1000,
|
|
||||||
"uniqueItems": true,
|
|
||||||
"items": { "$ref": "#/$defs/id" }
|
|
||||||
},
|
|
||||||
"fields": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 1000,
|
|
||||||
"items": { "$ref": "#/$defs/fieldClaim" }
|
|
||||||
},
|
|
||||||
"completeness": { "enum": ["complete", "incomplete", "unsupported"] },
|
|
||||||
"relationship_status": {
|
|
||||||
"enum": ["evidenced", "unresolved", "unsupported", "not_applicable"]
|
|
||||||
},
|
|
||||||
"mechanic_status": {
|
|
||||||
"enum": ["evidenced", "unresolved", "unsupported", "not_applicable"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": 2,
|
|
||||||
"archive_id": "future-archive"
|
|
||||||
}
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": 1,
|
|
||||||
"archive_id": "archive-001",
|
|
||||||
"created_at": "2026-07-31T12:00:00Z",
|
|
||||||
"generator": {
|
|
||||||
"name": "game-data-capture",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"policy_version": "1"
|
|
||||||
},
|
|
||||||
"artifacts": [
|
|
||||||
{
|
|
||||||
"artifact_id": "image-001",
|
|
||||||
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
||||||
"media_type": "image/png",
|
|
||||||
"byte_size": 2048,
|
|
||||||
"sensitivity": "private",
|
|
||||||
"parent_artifact_id": null,
|
|
||||||
"video_timestamp_ms": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"captures": [
|
|
||||||
{
|
|
||||||
"capture_id": "capture-001",
|
|
||||||
"artifact_id": "image-001",
|
|
||||||
"state": "queued",
|
|
||||||
"received_at": "2026-07-31T11:59:55Z",
|
|
||||||
"failure_reason": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"screens": [
|
|
||||||
{
|
|
||||||
"screen_id": "screen-001",
|
|
||||||
"artifact_id": "image-001",
|
|
||||||
"classified_at": "2026-07-31T11:59:57Z",
|
|
||||||
"classification": {
|
|
||||||
"family": "weapon",
|
|
||||||
"view": "detail",
|
|
||||||
"gear_slot": null,
|
|
||||||
"record_types": ["weapon", "weapon_talent"],
|
|
||||||
"confidence": 0.97,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"jobs": [],
|
|
||||||
"extractions": [],
|
|
||||||
"groups": [],
|
|
||||||
"reviews": [
|
|
||||||
{
|
|
||||||
"review_id": "review-001",
|
|
||||||
"candidate_id": "candidate-001",
|
|
||||||
"state": "approved",
|
|
||||||
"updated_at": "2026-07-31T12:00:00Z",
|
|
||||||
"note": "Name verified against the image."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"exports": [],
|
|
||||||
"errors": [],
|
|
||||||
"evidence_values": [
|
|
||||||
{
|
|
||||||
"evidence_id": "evidence-name",
|
|
||||||
"field_path": "weapon.name",
|
|
||||||
"raw_text": "LVOA-C",
|
|
||||||
"normalized_value": "LVOA-C",
|
|
||||||
"missing_reason": null,
|
|
||||||
"confidence": 0.99,
|
|
||||||
"source_engine": {
|
|
||||||
"kind": "local_ocr",
|
|
||||||
"engine_id": "fixture-ocr",
|
|
||||||
"engine_version": "1"
|
|
||||||
},
|
|
||||||
"image_artifact_id": "image-001",
|
|
||||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
||||||
"region": {
|
|
||||||
"coordinate_space": "normalized",
|
|
||||||
"x": 0.1,
|
|
||||||
"y": 0.1,
|
|
||||||
"width": 0.3,
|
|
||||||
"height": 0.08
|
|
||||||
},
|
|
||||||
"correction_history": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"evidence_id": "evidence-level",
|
|
||||||
"field_path": "observed.level",
|
|
||||||
"raw_text": "20",
|
|
||||||
"normalized_value": 20,
|
|
||||||
"missing_reason": null,
|
|
||||||
"confidence": 0.98,
|
|
||||||
"source_engine": {
|
|
||||||
"kind": "local_ocr",
|
|
||||||
"engine_id": "fixture-ocr",
|
|
||||||
"engine_version": "1"
|
|
||||||
},
|
|
||||||
"image_artifact_id": "image-001",
|
|
||||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
||||||
"region": {
|
|
||||||
"coordinate_space": "pixels",
|
|
||||||
"x": 50,
|
|
||||||
"y": 50,
|
|
||||||
"width": 80,
|
|
||||||
"height": 30
|
|
||||||
},
|
|
||||||
"correction_history": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"evidence_id": "evidence-quality",
|
|
||||||
"field_path": "observed.quality",
|
|
||||||
"raw_text": "High-end",
|
|
||||||
"normalized_value": "High-end",
|
|
||||||
"missing_reason": null,
|
|
||||||
"confidence": 0.96,
|
|
||||||
"source_engine": {
|
|
||||||
"kind": "local_ocr",
|
|
||||||
"engine_id": "fixture-ocr",
|
|
||||||
"engine_version": "1"
|
|
||||||
},
|
|
||||||
"image_artifact_id": "image-001",
|
|
||||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
||||||
"region": {
|
|
||||||
"coordinate_space": "pixels",
|
|
||||||
"x": 50,
|
|
||||||
"y": 85,
|
|
||||||
"width": 140,
|
|
||||||
"height": 30
|
|
||||||
},
|
|
||||||
"correction_history": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"evidence_id": "evidence-tier",
|
|
||||||
"field_path": "observed.tier",
|
|
||||||
"raw_text": "T1",
|
|
||||||
"normalized_value": 1,
|
|
||||||
"missing_reason": null,
|
|
||||||
"confidence": 0.97,
|
|
||||||
"source_engine": {
|
|
||||||
"kind": "local_ocr",
|
|
||||||
"engine_id": "fixture-ocr",
|
|
||||||
"engine_version": "1"
|
|
||||||
},
|
|
||||||
"image_artifact_id": "image-001",
|
|
||||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
||||||
"region": {
|
|
||||||
"coordinate_space": "pixels",
|
|
||||||
"x": 200,
|
|
||||||
"y": 85,
|
|
||||||
"width": 60,
|
|
||||||
"height": 30
|
|
||||||
},
|
|
||||||
"correction_history": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"evidence_id": "evidence-upgrade",
|
|
||||||
"field_path": "observed.upgrade_state",
|
|
||||||
"raw_text": null,
|
|
||||||
"normalized_value": null,
|
|
||||||
"missing_reason": "not_visible",
|
|
||||||
"confidence": 0,
|
|
||||||
"source_engine": {
|
|
||||||
"kind": "manual",
|
|
||||||
"engine_id": "review",
|
|
||||||
"engine_version": "1"
|
|
||||||
},
|
|
||||||
"image_artifact_id": "image-001",
|
|
||||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
||||||
"region": {
|
|
||||||
"coordinate_space": "normalized",
|
|
||||||
"x": 0,
|
|
||||||
"y": 0,
|
|
||||||
"width": 1,
|
|
||||||
"height": 1
|
|
||||||
},
|
|
||||||
"correction_history": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"evidence_id": "evidence-damage",
|
|
||||||
"field_path": "observed.damage",
|
|
||||||
"raw_text": "12,400",
|
|
||||||
"normalized_value": 12400,
|
|
||||||
"missing_reason": null,
|
|
||||||
"confidence": 1,
|
|
||||||
"source_engine": {
|
|
||||||
"kind": "manual",
|
|
||||||
"engine_id": "review",
|
|
||||||
"engine_version": "1"
|
|
||||||
},
|
|
||||||
"image_artifact_id": "image-001",
|
|
||||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
||||||
"region": {
|
|
||||||
"coordinate_space": "pixels",
|
|
||||||
"x": 300,
|
|
||||||
"y": 200,
|
|
||||||
"width": 160,
|
|
||||||
"height": 40
|
|
||||||
},
|
|
||||||
"correction_history": [
|
|
||||||
{
|
|
||||||
"correction_id": "correction-001",
|
|
||||||
"corrected_at": "2026-07-31T12:00:00Z",
|
|
||||||
"actor": "local_user",
|
|
||||||
"reason": "Corrected a missing zero.",
|
|
||||||
"before": {
|
|
||||||
"raw_text": "1,240",
|
|
||||||
"normalized_value": 1240,
|
|
||||||
"missing_reason": null
|
|
||||||
},
|
|
||||||
"after": {
|
|
||||||
"raw_text": "12,400",
|
|
||||||
"normalized_value": 12400,
|
|
||||||
"missing_reason": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"observed_instances": [
|
|
||||||
{
|
|
||||||
"instance_id": "instance-001",
|
|
||||||
"family": "weapon",
|
|
||||||
"source_screen_ids": ["screen-001"],
|
|
||||||
"observed_context": {
|
|
||||||
"level": {
|
|
||||||
"path": "observed.level",
|
|
||||||
"value": 20,
|
|
||||||
"missing_reason": null,
|
|
||||||
"evidence_refs": ["evidence-level"],
|
|
||||||
"uncertainty_note": null
|
|
||||||
},
|
|
||||||
"quality": {
|
|
||||||
"path": "observed.quality",
|
|
||||||
"value": "High-end",
|
|
||||||
"missing_reason": null,
|
|
||||||
"evidence_refs": ["evidence-quality"],
|
|
||||||
"uncertainty_note": null
|
|
||||||
},
|
|
||||||
"tier": {
|
|
||||||
"path": "observed.tier",
|
|
||||||
"value": 1,
|
|
||||||
"missing_reason": null,
|
|
||||||
"evidence_refs": ["evidence-tier"],
|
|
||||||
"uncertainty_note": null
|
|
||||||
},
|
|
||||||
"upgrade_state": {
|
|
||||||
"path": "observed.upgrade_state",
|
|
||||||
"value": null,
|
|
||||||
"missing_reason": "not_visible",
|
|
||||||
"evidence_refs": ["evidence-upgrade"],
|
|
||||||
"uncertainty_note": "The screen does not show an upgrade indicator."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"path": "observed.damage",
|
|
||||||
"value": 12400,
|
|
||||||
"missing_reason": null,
|
|
||||||
"evidence_refs": ["evidence-damage"],
|
|
||||||
"uncertainty_note": "This is the configured level-20 instance value, not a canonical range endpoint."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"canonical_candidates": [
|
|
||||||
{
|
|
||||||
"candidate_id": "candidate-001",
|
|
||||||
"record_type": "weapon",
|
|
||||||
"source_instance_ids": ["instance-001"],
|
|
||||||
"evidence_refs": ["evidence-name"],
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"path": "weapon.name",
|
|
||||||
"value": "LVOA-C",
|
|
||||||
"missing_reason": null,
|
|
||||||
"evidence_refs": ["evidence-name"],
|
|
||||||
"uncertainty_note": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"completeness": "incomplete",
|
|
||||||
"relationship_status": "unresolved",
|
|
||||||
"mechanic_status": "not_applicable"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "gear",
|
|
||||||
"view": "detail",
|
|
||||||
"gear_slot": "Vest",
|
|
||||||
"record_types": ["gear"],
|
|
||||||
"confidence": 0.95,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "gear",
|
|
||||||
"view": "talent",
|
|
||||||
"gear_slot": "Body Armor",
|
|
||||||
"record_types": ["gear_talent"],
|
|
||||||
"confidence": 0.95,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "gear_mod",
|
|
||||||
"view": "detail",
|
|
||||||
"gear_slot": "Mask",
|
|
||||||
"record_types": ["gear_mod"],
|
|
||||||
"confidence": 0.95,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "gear_mod",
|
|
||||||
"view": "detail",
|
|
||||||
"gear_slot": null,
|
|
||||||
"record_types": ["gear_mod"],
|
|
||||||
"confidence": 0.95,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "os_protocol",
|
|
||||||
"view": "secondary_profile",
|
|
||||||
"gear_slot": null,
|
|
||||||
"record_types": ["os_protocol_secondary_profile"],
|
|
||||||
"confidence": 1.3,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "os_protocol",
|
|
||||||
"view": "secondary_profile",
|
|
||||||
"gear_slot": null,
|
|
||||||
"record_types": ["os_protocol_secondary_profile"],
|
|
||||||
"confidence": 0.93,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "skill_chip",
|
|
||||||
"view": "detail",
|
|
||||||
"gear_slot": null,
|
|
||||||
"record_types": ["weapon"],
|
|
||||||
"confidence": 0.94,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "skill_chip",
|
|
||||||
"view": "set_bonus",
|
|
||||||
"gear_slot": null,
|
|
||||||
"record_types": ["skill_chip_set_bonus"],
|
|
||||||
"confidence": 0.94,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "specialization",
|
|
||||||
"view": "focus_tree",
|
|
||||||
"gear_slot": null,
|
|
||||||
"record_types": ["specialization_tree_node"],
|
|
||||||
"confidence": 0.91,
|
|
||||||
"abstention_reason": "ambiguous_view"
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "specialization",
|
|
||||||
"view": "focus_tree",
|
|
||||||
"gear_slot": null,
|
|
||||||
"record_types": ["specialization_focus", "specialization_tree_node"],
|
|
||||||
"confidence": 0.91,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "unknown",
|
|
||||||
"view": "unknown",
|
|
||||||
"gear_slot": null,
|
|
||||||
"record_types": ["weapon"],
|
|
||||||
"confidence": 0.22,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "unknown",
|
|
||||||
"view": "unknown",
|
|
||||||
"gear_slot": null,
|
|
||||||
"record_types": [],
|
|
||||||
"confidence": 0.22,
|
|
||||||
"abstention_reason": "low_confidence"
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "weapon",
|
|
||||||
"view": "talent",
|
|
||||||
"gear_slot": null,
|
|
||||||
"record_types": ["weapon"],
|
|
||||||
"confidence": 0.9,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "weapon",
|
|
||||||
"view": "detail",
|
|
||||||
"gear_slot": null,
|
|
||||||
"record_types": ["weapon", "weapon_talent"],
|
|
||||||
"confidence": 0.97,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "weapon_mod",
|
|
||||||
"view": "effect",
|
|
||||||
"gear_slot": null,
|
|
||||||
"record_types": ["weapon_mod"],
|
|
||||||
"confidence": 0.96,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"family": "weapon_mod",
|
|
||||||
"view": "detail",
|
|
||||||
"gear_slot": null,
|
|
||||||
"record_types": ["weapon_mod"],
|
|
||||||
"confidence": 0.96,
|
|
||||||
"abstention_reason": null
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": 2,
|
|
||||||
"handoff_id": "future-handoff"
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": 1,
|
|
||||||
"handoff_id": "handoff-001",
|
|
||||||
"created_at": "2026-07-31T12:05:00Z",
|
|
||||||
"source_archive": {
|
|
||||||
"archive_id": "archive-001",
|
|
||||||
"manifest_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
|
||||||
"schema_version": 1
|
|
||||||
},
|
|
||||||
"trust_boundary": {
|
|
||||||
"intended_destination": "authenticated_isac_intake",
|
|
||||||
"mutation_authority": "none",
|
|
||||||
"requires_authenticated_action": true,
|
|
||||||
"preserves_moderation": true,
|
|
||||||
"creates_audit_events": false
|
|
||||||
},
|
|
||||||
"source_artifacts": [
|
|
||||||
{
|
|
||||||
"artifact_id": "image-001",
|
|
||||||
"sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
||||||
"media_type": "image/png",
|
|
||||||
"byte_size": 2048
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"evidence_values": [
|
|
||||||
{
|
|
||||||
"evidence_id": "evidence-name",
|
|
||||||
"field_path": "weapon.name",
|
|
||||||
"raw_text": "LVOA-C",
|
|
||||||
"normalized_value": "LVOA-C",
|
|
||||||
"missing_reason": null,
|
|
||||||
"confidence": 0.99,
|
|
||||||
"source_engine": {
|
|
||||||
"kind": "local_ocr",
|
|
||||||
"engine_id": "fixture-ocr",
|
|
||||||
"engine_version": "1"
|
|
||||||
},
|
|
||||||
"image_artifact_id": "image-001",
|
|
||||||
"image_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
||||||
"region": {
|
|
||||||
"coordinate_space": "normalized",
|
|
||||||
"x": 0.1,
|
|
||||||
"y": 0.1,
|
|
||||||
"width": 0.3,
|
|
||||||
"height": 0.08
|
|
||||||
},
|
|
||||||
"correction_history": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"records": [
|
|
||||||
{
|
|
||||||
"handoff_record_id": "handoff-record-001",
|
|
||||||
"record_type": "weapon",
|
|
||||||
"intent": "create_draft",
|
|
||||||
"target_id": null,
|
|
||||||
"review_state": "approved",
|
|
||||||
"source_candidate_ids": ["candidate-001"],
|
|
||||||
"observed_instance_ids": ["instance-001"],
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"path": "weapon.name",
|
|
||||||
"value": "LVOA-C",
|
|
||||||
"missing_reason": null,
|
|
||||||
"evidence_refs": ["evidence-name"],
|
|
||||||
"uncertainty_note": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"relationships": {
|
|
||||||
"status": "unresolved",
|
|
||||||
"evidence_refs": [],
|
|
||||||
"note": "ISAC must resolve and validate canonical relationships before submission."
|
|
||||||
},
|
|
||||||
"mechanics": {
|
|
||||||
"status": "not_applicable",
|
|
||||||
"evidence_refs": [],
|
|
||||||
"note": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"$id": "https://shd.build/schemas/game-data-capture/isac-handoff-v1.json",
|
|
||||||
"title": "Authenticated ISAC draft handoff v1",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"schema_version",
|
|
||||||
"handoff_id",
|
|
||||||
"created_at",
|
|
||||||
"source_archive",
|
|
||||||
"trust_boundary",
|
|
||||||
"source_artifacts",
|
|
||||||
"evidence_values",
|
|
||||||
"records"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"schema_version": { "const": 1 },
|
|
||||||
"handoff_id": { "$ref": "#/$defs/id" },
|
|
||||||
"created_at": { "$ref": "#/$defs/timestamp" },
|
|
||||||
"source_archive": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["archive_id", "manifest_sha256", "schema_version"],
|
|
||||||
"properties": {
|
|
||||||
"archive_id": { "$ref": "#/$defs/id" },
|
|
||||||
"manifest_sha256": { "$ref": "#/$defs/sha256" },
|
|
||||||
"schema_version": { "const": 1 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"trust_boundary": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"intended_destination",
|
|
||||||
"mutation_authority",
|
|
||||||
"requires_authenticated_action",
|
|
||||||
"preserves_moderation",
|
|
||||||
"creates_audit_events"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"intended_destination": { "const": "authenticated_isac_intake" },
|
|
||||||
"mutation_authority": { "const": "none" },
|
|
||||||
"requires_authenticated_action": { "const": true },
|
|
||||||
"preserves_moderation": { "const": true },
|
|
||||||
"creates_audit_events": { "const": false }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"source_artifacts": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 10000,
|
|
||||||
"items": { "$ref": "#/$defs/sourceArtifact" }
|
|
||||||
},
|
|
||||||
"evidence_values": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 100000,
|
|
||||||
"items": { "$ref": "#/$defs/evidenceValue" }
|
|
||||||
},
|
|
||||||
"records": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 20000,
|
|
||||||
"items": { "$ref": "#/$defs/handoffRecord" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"$defs": {
|
|
||||||
"id": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
|
||||||
},
|
|
||||||
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
|
|
||||||
"timestamp": { "type": "string", "format": "date-time", "maxLength": 40 },
|
|
||||||
"recordType": {
|
|
||||||
"enum": [
|
|
||||||
"weapon",
|
|
||||||
"weapon_talent",
|
|
||||||
"weapon_augmentation_profile",
|
|
||||||
"gear",
|
|
||||||
"brand_set",
|
|
||||||
"gear_talent",
|
|
||||||
"gear_augmentation_profile",
|
|
||||||
"skill_chip_set",
|
|
||||||
"skill_chip_set_bonus",
|
|
||||||
"skill_chip_roll_pool",
|
|
||||||
"skill_chip_talent",
|
|
||||||
"os_protocol",
|
|
||||||
"os_protocol_secondary_profile",
|
|
||||||
"os_protocol_talent",
|
|
||||||
"specialization",
|
|
||||||
"specialization_focus",
|
|
||||||
"specialization_ability",
|
|
||||||
"specialization_tree_node",
|
|
||||||
"specialization_tree_rank",
|
|
||||||
"weapon_mod",
|
|
||||||
"gear_mod"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"missingReason": {
|
|
||||||
"enum": [
|
|
||||||
"not_visible",
|
|
||||||
"occluded",
|
|
||||||
"unreadable",
|
|
||||||
"not_present_on_screen",
|
|
||||||
"unsupported",
|
|
||||||
"ambiguous",
|
|
||||||
"extraction_failed",
|
|
||||||
"not_applicable",
|
|
||||||
"redacted"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"jsonValue": {
|
|
||||||
"oneOf": [
|
|
||||||
{ "type": "null" },
|
|
||||||
{ "type": "boolean" },
|
|
||||||
{ "type": "number" },
|
|
||||||
{ "type": "string", "maxLength": 8192 },
|
|
||||||
{
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 500,
|
|
||||||
"items": { "$ref": "#/$defs/jsonValue" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"maxProperties": 500,
|
|
||||||
"additionalProperties": { "$ref": "#/$defs/jsonValue" }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"sourceEngine": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["kind", "engine_id", "engine_version"],
|
|
||||||
"properties": {
|
|
||||||
"kind": {
|
|
||||||
"enum": [
|
|
||||||
"local_ocr",
|
|
||||||
"openai_vision",
|
|
||||||
"classifier",
|
|
||||||
"manual",
|
|
||||||
"video_decoder"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"engine_id": { "type": "string", "minLength": 1, "maxLength": 100 },
|
|
||||||
"engine_version": { "type": "string", "minLength": 1, "maxLength": 100 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"region": {
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["coordinate_space", "x", "y", "width", "height"],
|
|
||||||
"properties": {
|
|
||||||
"coordinate_space": { "const": "normalized" },
|
|
||||||
"x": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
||||||
"y": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
||||||
"width": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 },
|
|
||||||
"height": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["coordinate_space", "x", "y", "width", "height"],
|
|
||||||
"properties": {
|
|
||||||
"coordinate_space": { "const": "pixels" },
|
|
||||||
"x": { "type": "integer", "minimum": 0, "maximum": 100000 },
|
|
||||||
"y": { "type": "integer", "minimum": 0, "maximum": 100000 },
|
|
||||||
"width": { "type": "integer", "minimum": 1, "maximum": 100000 },
|
|
||||||
"height": { "type": "integer", "minimum": 1, "maximum": 100000 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"valueState": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["raw_text", "normalized_value", "missing_reason"],
|
|
||||||
"properties": {
|
|
||||||
"raw_text": { "type": ["string", "null"], "maxLength": 8192 },
|
|
||||||
"normalized_value": { "$ref": "#/$defs/jsonValue" },
|
|
||||||
"missing_reason": {
|
|
||||||
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/missingReason" }]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"normalized_value": { "type": "null" },
|
|
||||||
"missing_reason": { "$ref": "#/$defs/missingReason" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"normalized_value": { "not": { "type": "null" } },
|
|
||||||
"missing_reason": { "type": "null" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"correction": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"correction_id",
|
|
||||||
"corrected_at",
|
|
||||||
"actor",
|
|
||||||
"reason",
|
|
||||||
"before",
|
|
||||||
"after"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"correction_id": { "$ref": "#/$defs/id" },
|
|
||||||
"corrected_at": { "$ref": "#/$defs/timestamp" },
|
|
||||||
"actor": { "enum": ["local_user", "system_reprocess"] },
|
|
||||||
"reason": { "type": "string", "minLength": 1, "maxLength": 1000 },
|
|
||||||
"before": { "$ref": "#/$defs/valueState" },
|
|
||||||
"after": { "$ref": "#/$defs/valueState" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"evidenceValue": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"evidence_id",
|
|
||||||
"field_path",
|
|
||||||
"raw_text",
|
|
||||||
"normalized_value",
|
|
||||||
"missing_reason",
|
|
||||||
"confidence",
|
|
||||||
"source_engine",
|
|
||||||
"image_artifact_id",
|
|
||||||
"image_sha256",
|
|
||||||
"region",
|
|
||||||
"correction_history"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"evidence_id": { "$ref": "#/$defs/id" },
|
|
||||||
"field_path": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^[A-Za-z0-9_.-]{1,256}$"
|
|
||||||
},
|
|
||||||
"raw_text": { "type": ["string", "null"], "maxLength": 8192 },
|
|
||||||
"normalized_value": { "$ref": "#/$defs/jsonValue" },
|
|
||||||
"missing_reason": {
|
|
||||||
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/missingReason" }]
|
|
||||||
},
|
|
||||||
"confidence": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
||||||
"source_engine": { "$ref": "#/$defs/sourceEngine" },
|
|
||||||
"image_artifact_id": { "$ref": "#/$defs/id" },
|
|
||||||
"image_sha256": { "$ref": "#/$defs/sha256" },
|
|
||||||
"region": { "$ref": "#/$defs/region" },
|
|
||||||
"correction_history": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 100,
|
|
||||||
"items": { "$ref": "#/$defs/correction" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"normalized_value": { "type": "null" },
|
|
||||||
"missing_reason": { "$ref": "#/$defs/missingReason" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"normalized_value": { "not": { "type": "null" } },
|
|
||||||
"missing_reason": { "type": "null" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"fieldClaim": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"path",
|
|
||||||
"value",
|
|
||||||
"missing_reason",
|
|
||||||
"evidence_refs",
|
|
||||||
"uncertainty_note"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"path": { "type": "string", "pattern": "^[A-Za-z0-9_.-]{1,256}$" },
|
|
||||||
"value": { "$ref": "#/$defs/jsonValue" },
|
|
||||||
"missing_reason": {
|
|
||||||
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/missingReason" }]
|
|
||||||
},
|
|
||||||
"evidence_refs": {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 100,
|
|
||||||
"uniqueItems": true,
|
|
||||||
"items": { "$ref": "#/$defs/id" }
|
|
||||||
},
|
|
||||||
"uncertainty_note": { "type": ["string", "null"], "maxLength": 2000 }
|
|
||||||
},
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"value": { "type": "null" },
|
|
||||||
"missing_reason": { "$ref": "#/$defs/missingReason" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"value": { "not": { "type": "null" } },
|
|
||||||
"missing_reason": { "type": "null" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"sourceArtifact": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["artifact_id", "sha256", "media_type", "byte_size"],
|
|
||||||
"properties": {
|
|
||||||
"artifact_id": { "$ref": "#/$defs/id" },
|
|
||||||
"sha256": { "$ref": "#/$defs/sha256" },
|
|
||||||
"media_type": { "enum": ["image/png", "image/webp", "image/avif"] },
|
|
||||||
"byte_size": { "type": "integer", "minimum": 1, "maximum": 67108864 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"evidenceSection": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["status", "evidence_refs", "note"],
|
|
||||||
"properties": {
|
|
||||||
"status": {
|
|
||||||
"enum": ["evidenced", "unresolved", "unsupported", "not_applicable"]
|
|
||||||
},
|
|
||||||
"evidence_refs": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 1000,
|
|
||||||
"uniqueItems": true,
|
|
||||||
"items": { "$ref": "#/$defs/id" }
|
|
||||||
},
|
|
||||||
"note": { "type": ["string", "null"], "maxLength": 4000 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"handoffRecord": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"handoff_record_id",
|
|
||||||
"record_type",
|
|
||||||
"intent",
|
|
||||||
"target_id",
|
|
||||||
"review_state",
|
|
||||||
"source_candidate_ids",
|
|
||||||
"observed_instance_ids",
|
|
||||||
"fields",
|
|
||||||
"relationships",
|
|
||||||
"mechanics"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"handoff_record_id": { "$ref": "#/$defs/id" },
|
|
||||||
"record_type": { "$ref": "#/$defs/recordType" },
|
|
||||||
"intent": { "enum": ["create_draft", "update_draft", "abstain"] },
|
|
||||||
"target_id": {
|
|
||||||
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/id" }]
|
|
||||||
},
|
|
||||||
"review_state": { "enum": ["approved", "abstained"] },
|
|
||||||
"source_candidate_ids": {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 100,
|
|
||||||
"uniqueItems": true,
|
|
||||||
"items": { "$ref": "#/$defs/id" }
|
|
||||||
},
|
|
||||||
"observed_instance_ids": {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 100,
|
|
||||||
"uniqueItems": true,
|
|
||||||
"items": { "$ref": "#/$defs/id" }
|
|
||||||
},
|
|
||||||
"fields": {
|
|
||||||
"type": "array",
|
|
||||||
"maxItems": 1000,
|
|
||||||
"items": { "$ref": "#/$defs/fieldClaim" }
|
|
||||||
},
|
|
||||||
"relationships": { "$ref": "#/$defs/evidenceSection" },
|
|
||||||
"mechanics": { "$ref": "#/$defs/evidenceSection" }
|
|
||||||
},
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"intent": { "const": "create_draft" },
|
|
||||||
"target_id": { "const": null },
|
|
||||||
"review_state": { "const": "approved" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"intent": { "const": "update_draft" },
|
|
||||||
"target_id": { "$ref": "#/$defs/id" },
|
|
||||||
"review_state": { "const": "approved" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"intent": { "const": "abstain" },
|
|
||||||
"target_id": { "const": null },
|
|
||||||
"review_state": { "const": "abstained" },
|
|
||||||
"fields": { "maxItems": 0 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"$id": "https://shd.build/schemas/game-data-capture/screenshot-classification-v1.json",
|
|
||||||
"title": "Screenshot classification v1",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"family",
|
|
||||||
"view",
|
|
||||||
"gear_slot",
|
|
||||||
"record_types",
|
|
||||||
"confidence",
|
|
||||||
"abstention_reason"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"family": {
|
|
||||||
"enum": [
|
|
||||||
"weapon",
|
|
||||||
"gear",
|
|
||||||
"skill_chip",
|
|
||||||
"os_protocol",
|
|
||||||
"specialization",
|
|
||||||
"weapon_mod",
|
|
||||||
"gear_mod",
|
|
||||||
"unknown"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"view": { "type": "string" },
|
|
||||||
"gear_slot": { "type": ["string", "null"] },
|
|
||||||
"record_types": {
|
|
||||||
"type": "array",
|
|
||||||
"uniqueItems": true,
|
|
||||||
"maxItems": 8,
|
|
||||||
"items": { "type": "string" }
|
|
||||||
},
|
|
||||||
"confidence": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
||||||
"abstention_reason": { "type": ["string", "null"] }
|
|
||||||
},
|
|
||||||
"oneOf": [
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"family": { "const": "weapon" },
|
|
||||||
"view": {
|
|
||||||
"enum": [
|
|
||||||
"detail",
|
|
||||||
"blueprint",
|
|
||||||
"crafting",
|
|
||||||
"stat_range",
|
|
||||||
"talent_grid",
|
|
||||||
"augmentation"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"gear_slot": { "const": null },
|
|
||||||
"record_types": {
|
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
|
||||||
"enum": ["weapon", "weapon_talent", "weapon_augmentation_profile"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"abstention_reason": { "const": null }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"family": { "const": "gear" },
|
|
||||||
"view": { "enum": ["detail", "blueprint", "talent", "augmentation"] },
|
|
||||||
"gear_slot": {
|
|
||||||
"enum": [
|
|
||||||
"Mask",
|
|
||||||
"Gloves",
|
|
||||||
"Backpack",
|
|
||||||
"Body Armor",
|
|
||||||
"Holster",
|
|
||||||
"Knee Pads"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"record_types": {
|
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
|
||||||
"enum": [
|
|
||||||
"gear",
|
|
||||||
"brand_set",
|
|
||||||
"gear_talent",
|
|
||||||
"gear_augmentation_profile"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"abstention_reason": { "const": null }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"family": { "const": "skill_chip" },
|
|
||||||
"view": { "enum": ["detail", "set_bonus", "talent"] },
|
|
||||||
"gear_slot": { "const": null },
|
|
||||||
"record_types": {
|
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
|
||||||
"enum": [
|
|
||||||
"skill_chip_set",
|
|
||||||
"skill_chip_set_bonus",
|
|
||||||
"skill_chip_roll_pool",
|
|
||||||
"skill_chip_talent"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"abstention_reason": { "const": null }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"family": { "const": "os_protocol" },
|
|
||||||
"view": { "enum": ["detail", "secondary_profile", "talent"] },
|
|
||||||
"gear_slot": { "const": null },
|
|
||||||
"record_types": {
|
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
|
||||||
"enum": [
|
|
||||||
"os_protocol",
|
|
||||||
"os_protocol_secondary_profile",
|
|
||||||
"os_protocol_talent"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"abstention_reason": { "const": null }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"family": { "const": "specialization" },
|
|
||||||
"view": {
|
|
||||||
"enum": [
|
|
||||||
"overview",
|
|
||||||
"details",
|
|
||||||
"ability",
|
|
||||||
"focus_details",
|
|
||||||
"focus_tree",
|
|
||||||
"rank_details"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"gear_slot": { "const": null },
|
|
||||||
"record_types": {
|
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
|
||||||
"enum": [
|
|
||||||
"specialization",
|
|
||||||
"specialization_focus",
|
|
||||||
"specialization_ability",
|
|
||||||
"specialization_tree_node",
|
|
||||||
"specialization_tree_rank"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"abstention_reason": { "const": null }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"family": { "const": "weapon_mod" },
|
|
||||||
"view": { "const": "detail" },
|
|
||||||
"gear_slot": { "const": null },
|
|
||||||
"record_types": {
|
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 1,
|
|
||||||
"items": { "const": "weapon_mod" }
|
|
||||||
},
|
|
||||||
"abstention_reason": { "const": null }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"family": { "const": "gear_mod" },
|
|
||||||
"view": { "const": "detail" },
|
|
||||||
"gear_slot": { "const": null },
|
|
||||||
"record_types": {
|
|
||||||
"minItems": 1,
|
|
||||||
"maxItems": 1,
|
|
||||||
"items": { "const": "gear_mod" }
|
|
||||||
},
|
|
||||||
"abstention_reason": { "const": null }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"properties": {
|
|
||||||
"family": { "const": "unknown" },
|
|
||||||
"view": { "const": "unknown" },
|
|
||||||
"gear_slot": { "const": null },
|
|
||||||
"record_types": { "maxItems": 0 },
|
|
||||||
"abstention_reason": {
|
|
||||||
"enum": [
|
|
||||||
"low_confidence",
|
|
||||||
"ambiguous_family",
|
|
||||||
"ambiguous_view",
|
|
||||||
"unsupported_family",
|
|
||||||
"unsupported_view",
|
|
||||||
"insufficient_visual_evidence",
|
|
||||||
"occluded",
|
|
||||||
"corrupt_image",
|
|
||||||
"resource_limit",
|
|
||||||
"cancelled"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
-- Additive utility-local schema. Migration content is fingerprinted at application time.
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
||||||
version INTEGER PRIMARY KEY,
|
|
||||||
filename TEXT NOT NULL UNIQUE,
|
|
||||||
checksum TEXT NOT NULL,
|
|
||||||
applied_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE blobs (
|
|
||||||
sha256 TEXT PRIMARY KEY CHECK(length(sha256) = 64),
|
|
||||||
byte_size INTEGER NOT NULL CHECK(byte_size > 0),
|
|
||||||
media_type TEXT NOT NULL CHECK(length(media_type) <= 100),
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE captures (
|
|
||||||
capture_id TEXT PRIMARY KEY,
|
|
||||||
blob_sha256 TEXT NOT NULL REFERENCES blobs(sha256) ON DELETE RESTRICT,
|
|
||||||
state TEXT NOT NULL,
|
|
||||||
received_at TEXT NOT NULL,
|
|
||||||
receipt_token TEXT NOT NULL UNIQUE,
|
|
||||||
failure_reason TEXT,
|
|
||||||
revision INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
CREATE TABLE jobs (
|
|
||||||
job_id TEXT PRIMARY KEY,
|
|
||||||
capture_id TEXT REFERENCES captures(capture_id) ON DELETE RESTRICT,
|
|
||||||
kind TEXT NOT NULL CHECK(length(kind) <= 100),
|
|
||||||
state TEXT NOT NULL,
|
|
||||||
attempt INTEGER NOT NULL DEFAULT 0 CHECK(attempt >= 0 AND attempt <= 20),
|
|
||||||
max_attempts INTEGER NOT NULL CHECK(max_attempts BETWEEN 1 AND 20),
|
|
||||||
lease_owner TEXT, lease_expires_at TEXT, last_error_id TEXT,
|
|
||||||
revision INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
CREATE INDEX jobs_available ON jobs(state, lease_expires_at);
|
|
||||||
CREATE TABLE ocr_runs (
|
|
||||||
run_id TEXT PRIMARY KEY, capture_id TEXT NOT NULL REFERENCES captures(capture_id),
|
|
||||||
engine_id TEXT NOT NULL, engine_version TEXT NOT NULL, created_at TEXT NOT NULL,
|
|
||||||
state TEXT NOT NULL, payload_json TEXT NOT NULL CHECK(length(payload_json) <= 1000000)
|
|
||||||
);
|
|
||||||
CREATE TABLE regions (
|
|
||||||
region_id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES ocr_runs(run_id),
|
|
||||||
region_json TEXT NOT NULL CHECK(length(region_json) <= 10000), created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE classifications (
|
|
||||||
classification_id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES ocr_runs(run_id),
|
|
||||||
classification_json TEXT NOT NULL CHECK(length(classification_json) <= 10000), created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE extraction_candidates (
|
|
||||||
candidate_id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES ocr_runs(run_id),
|
|
||||||
candidate_json TEXT NOT NULL CHECK(length(candidate_json) <= 1000000), created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE routing_decisions (
|
|
||||||
decision_id TEXT PRIMARY KEY, candidate_id TEXT REFERENCES extraction_candidates(candidate_id),
|
|
||||||
decision_json TEXT NOT NULL CHECK(length(decision_json) <= 10000), created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE review_events (
|
|
||||||
event_id TEXT PRIMARY KEY, candidate_id TEXT REFERENCES extraction_candidates(candidate_id),
|
|
||||||
event_json TEXT NOT NULL CHECK(length(event_json) <= 10000), created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE lineage (
|
|
||||||
lineage_id TEXT PRIMARY KEY, child_id TEXT NOT NULL, parent_id TEXT NOT NULL,
|
|
||||||
relation TEXT NOT NULL CHECK(length(relation) <= 100), created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE exports (
|
|
||||||
export_id TEXT PRIMARY KEY, state TEXT NOT NULL, manifest_json TEXT NOT NULL CHECK(length(manifest_json) <= 1000000), created_at TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
CREATE TABLE runtime_failures (
|
|
||||||
failure_id TEXT PRIMARY KEY, subject_type TEXT NOT NULL, subject_id TEXT NOT NULL,
|
|
||||||
code TEXT NOT NULL, message TEXT NOT NULL CHECK(length(message) <= 512), created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
-- Durable browser-corpus projections and append-only review evidence.
|
|
||||||
CREATE TABLE capture_labels (
|
|
||||||
capture_id TEXT PRIMARY KEY REFERENCES captures(capture_id) ON DELETE RESTRICT,
|
|
||||||
classification_json TEXT NOT NULL CHECK(length(classification_json) BETWEEN 2 AND 4096),
|
|
||||||
revision INTEGER NOT NULL DEFAULT 0 CHECK(revision >= 0),
|
|
||||||
labeled_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE capture_label_evidence (
|
|
||||||
evidence_id INTEGER PRIMARY KEY,
|
|
||||||
capture_id TEXT NOT NULL REFERENCES captures(capture_id) ON DELETE RESTRICT,
|
|
||||||
replaced_revision INTEGER NOT NULL CHECK(replaced_revision >= 0),
|
|
||||||
classification_json TEXT NOT NULL CHECK(length(classification_json) BETWEEN 2 AND 4096),
|
|
||||||
replaced_at TEXT NOT NULL,
|
|
||||||
UNIQUE(capture_id, replaced_revision)
|
|
||||||
);
|
|
||||||
CREATE INDEX capture_label_evidence_capture ON capture_label_evidence(capture_id, evidence_id);
|
|
||||||
CREATE INDEX captures_blob_sha256 ON captures(blob_sha256);
|
|
||||||
CREATE INDEX jobs_capture_id ON jobs(capture_id);
|
|
||||||
CREATE INDEX ocr_runs_capture_id ON ocr_runs(capture_id);
|
|
||||||
CREATE INDEX runtime_failures_capture_subject ON runtime_failures(subject_type, subject_id);
|
|
||||||
CREATE INDEX lineage_child_id ON lineage(child_id);
|
|
||||||
CREATE INDEX lineage_parent_id ON lineage(parent_id);
|
|
||||||
CREATE TABLE capture_rejections (
|
|
||||||
rejection_id INTEGER PRIMARY KEY,
|
|
||||||
capture_id TEXT NOT NULL UNIQUE REFERENCES captures(capture_id) ON DELETE RESTRICT,
|
|
||||||
rejected_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE TABLE capture_deletion_intents (
|
|
||||||
blob_sha256 TEXT PRIMARY KEY REFERENCES blobs(sha256) ON DELETE RESTRICT,
|
|
||||||
capture_id TEXT NOT NULL UNIQUE CHECK(length(capture_id) BETWEEN 8 AND 64),
|
|
||||||
byte_size INTEGER NOT NULL CHECK(byte_size > 0 AND byte_size <= 67108864),
|
|
||||||
capture_state TEXT NOT NULL CHECK(length(capture_state) BETWEEN 1 AND 32),
|
|
||||||
received_at TEXT NOT NULL CHECK(length(received_at) BETWEEN 1 AND 40),
|
|
||||||
receipt_token TEXT NOT NULL UNIQUE CHECK(length(receipt_token) BETWEEN 1 AND 128),
|
|
||||||
failure_reason TEXT,
|
|
||||||
revision INTEGER NOT NULL CHECK(revision >= 0),
|
|
||||||
created_at TEXT NOT NULL CHECK(length(created_at) BETWEEN 1 AND 40)
|
|
||||||
);
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
[build-system]
|
|
||||||
requires = ["hatchling==1.27.0"]
|
|
||||||
build-backend = "hatchling.build"
|
|
||||||
|
|
||||||
[project]
|
|
||||||
name = "shd-game-data-capture"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "Local, privacy-preserving game-data capture utility contracts."
|
|
||||||
requires-python = ">=3.12,<3.13"
|
|
||||||
license = { text = "UNLICENSED" }
|
|
||||||
authors = [{ name = "SHD.build" }]
|
|
||||||
dependencies = [
|
|
||||||
"fastapi==0.115.12",
|
|
||||||
"jinja2==3.1.6",
|
|
||||||
"python-multipart==0.0.20",
|
|
||||||
"uvicorn==0.34.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.scripts]
|
|
||||||
shd-capture = "game_data_capture.cli:main"
|
|
||||||
|
|
||||||
[dependency-groups]
|
|
||||||
dev = [
|
|
||||||
"httpx==0.28.1",
|
|
||||||
"jsonschema==4.25.1",
|
|
||||||
"mypy==1.15.0",
|
|
||||||
"pytest==8.3.5",
|
|
||||||
"ruff==0.11.0",
|
|
||||||
"types-jsonschema==4.25.1.20251009",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
|
||||||
packages = ["src/game_data_capture"]
|
|
||||||
|
|
||||||
[tool.ruff]
|
|
||||||
target-version = "py312"
|
|
||||||
line-length = 100
|
|
||||||
|
|
||||||
[tool.ruff.lint]
|
|
||||||
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
||||||
|
|
||||||
[tool.mypy]
|
|
||||||
python_version = "3.12"
|
|
||||||
strict = true
|
|
||||||
mypy_path = ["src"]
|
|
||||||
warn_unreachable = true
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
testpaths = ["tests"]
|
|
||||||
pythonpath = ["src"]
|
|
||||||
addopts = "-ra"
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
"""Contracts for the local SHD.build game-data capture utility.
|
|
||||||
|
|
||||||
Importing this package does not inspect the environment or initialize runtime resources.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
"""Module entry point for ``python -m game_data_capture``."""
|
|
||||||
|
|
||||||
from .cli import main
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
"""CLI contracts for the capture utility."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from dataclasses import replace
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from game_data_capture.config import AppConfig, ConfigError, ServerConfig, load_config
|
|
||||||
from game_data_capture.doctor import doctor_as_dict, run_doctor
|
|
||||||
from game_data_capture.paths import PathConfigurationError, RuntimePaths, resolve_runtime_paths
|
|
||||||
from game_data_capture.runtime import CaptureRuntime, RuntimeFailure
|
|
||||||
|
|
||||||
EXIT_OK = 0
|
|
||||||
EXIT_CONFIG_ERROR = 2
|
|
||||||
EXIT_UNIMPLEMENTED = 3
|
|
||||||
EXIT_RUNTIME_ERROR = 4
|
|
||||||
|
|
||||||
|
|
||||||
def _add_config_argument(parser: argparse.ArgumentParser) -> None:
|
|
||||||
parser.add_argument("--config", type=Path, help="absolute path to a TOML configuration file")
|
|
||||||
|
|
||||||
|
|
||||||
def _port_argument(raw_value: str) -> int:
|
|
||||||
try:
|
|
||||||
port = int(raw_value)
|
|
||||||
except ValueError as error:
|
|
||||||
raise argparse.ArgumentTypeError("port must be an integer from 1024 to 65535") from error
|
|
||||||
if not 1024 <= port <= 65535:
|
|
||||||
raise argparse.ArgumentTypeError("port must be an integer from 1024 to 65535")
|
|
||||||
return port
|
|
||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
|
||||||
"""Build the stable command surface without initializing runtime resources."""
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
prog="shd-capture", description="Local SHD.build game-data capture"
|
|
||||||
)
|
|
||||||
subcommands = parser.add_subparsers(dest="command", required=True)
|
|
||||||
|
|
||||||
doctor = subcommands.add_parser("doctor", help="report local prerequisite status")
|
|
||||||
_add_config_argument(doctor)
|
|
||||||
doctor.add_argument("--json", action="store_true", help="emit JSON diagnostics")
|
|
||||||
|
|
||||||
serve = subcommands.add_parser("serve", help="start the localhost review service")
|
|
||||||
_add_config_argument(serve)
|
|
||||||
serve.add_argument("--host", help="loopback IP literal override")
|
|
||||||
serve.add_argument("--port", type=_port_argument, help="TCP port override")
|
|
||||||
|
|
||||||
watch = subcommands.add_parser("watch", help="watch the Wayland clipboard for images")
|
|
||||||
_add_config_argument(watch)
|
|
||||||
watch_modes = watch.add_mutually_exclusive_group()
|
|
||||||
watch_modes.add_argument("--arm", action="store_true", help="arm live clipboard capture")
|
|
||||||
watch_modes.add_argument(
|
|
||||||
"--dry-run", action="store_true", help="validate the capture runtime plan"
|
|
||||||
)
|
|
||||||
|
|
||||||
ingest_image = subcommands.add_parser("ingest-image", help="stage one image for capture")
|
|
||||||
_add_config_argument(ingest_image)
|
|
||||||
ingest_image.add_argument("image", type=Path)
|
|
||||||
|
|
||||||
import_video = subcommands.add_parser("import-video", help="stage a video for frame extraction")
|
|
||||||
_add_config_argument(import_video)
|
|
||||||
import_video.add_argument("video", type=Path)
|
|
||||||
|
|
||||||
export = subcommands.add_parser("export", help="write a versioned ISAC handoff")
|
|
||||||
_add_config_argument(export)
|
|
||||||
export.add_argument("destination", type=Path)
|
|
||||||
|
|
||||||
verify = subcommands.add_parser("verify", help="verify an evidence archive")
|
|
||||||
_add_config_argument(verify)
|
|
||||||
verify.add_argument("archive", type=Path)
|
|
||||||
|
|
||||||
evaluate = subcommands.add_parser("eval", help="evaluate local OCR and classification")
|
|
||||||
_add_config_argument(evaluate)
|
|
||||||
evaluate.add_argument("corpus", type=Path)
|
|
||||||
|
|
||||||
purge = subcommands.add_parser("purge", help="purge retained local data")
|
|
||||||
_add_config_argument(purge)
|
|
||||||
purge.add_argument("--confirm", action="store_true", help="confirm a future destructive purge")
|
|
||||||
|
|
||||||
return parser
|
|
||||||
|
|
||||||
|
|
||||||
def _load_cli_config(config_path: Path | None) -> tuple[AppConfig, RuntimePaths]:
|
|
||||||
paths = resolve_runtime_paths()
|
|
||||||
return load_config(config_path=config_path, paths=paths), paths
|
|
||||||
|
|
||||||
|
|
||||||
def _doctor(arguments: argparse.Namespace) -> int:
|
|
||||||
config, paths = _load_cli_config(arguments.config)
|
|
||||||
diagnostics = doctor_as_dict(run_doctor(config, paths))
|
|
||||||
if arguments.json:
|
|
||||||
print(json.dumps(diagnostics, separators=(",", ":"), sort_keys=True))
|
|
||||||
else:
|
|
||||||
for diagnostic in diagnostics:
|
|
||||||
print(f"{diagnostic['name']}: {diagnostic['status']} ({diagnostic['detail']})")
|
|
||||||
return EXIT_OK
|
|
||||||
|
|
||||||
|
|
||||||
def _serve_contract(arguments: argparse.Namespace) -> int:
|
|
||||||
config, paths = _load_cli_config(arguments.config)
|
|
||||||
host = arguments.host if arguments.host is not None else config.server.host
|
|
||||||
port = arguments.port if arguments.port is not None else config.server.port
|
|
||||||
configured = replace(config, server=ServerConfig(host=host, port=port))
|
|
||||||
CaptureRuntime(configured, paths).serve()
|
|
||||||
return EXIT_OK
|
|
||||||
|
|
||||||
|
|
||||||
def _unimplemented(command: str) -> int:
|
|
||||||
print(
|
|
||||||
f"{command}: subsystem is not implemented; requires gdc-runtime-integration",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return EXIT_UNIMPLEMENTED
|
|
||||||
|
|
||||||
|
|
||||||
def _watch(arguments: argparse.Namespace) -> int:
|
|
||||||
config, paths = _load_cli_config(arguments.config)
|
|
||||||
if not arguments.arm and not arguments.dry_run:
|
|
||||||
return _unimplemented("watch")
|
|
||||||
runtime = CaptureRuntime(config, paths)
|
|
||||||
if arguments.dry_run:
|
|
||||||
runtime.validate_plan()
|
|
||||||
print("watch dry-run: capture runtime plan is valid")
|
|
||||||
return EXIT_OK
|
|
||||||
runtime.watch()
|
|
||||||
return EXIT_OK
|
|
||||||
|
|
||||||
|
|
||||||
def _ingest_image(arguments: argparse.Namespace) -> int:
|
|
||||||
config, paths = _load_cli_config(arguments.config)
|
|
||||||
CaptureRuntime(config, paths).ingest_image(arguments.image)
|
|
||||||
print("image capture stored")
|
|
||||||
return EXIT_OK
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: Sequence[str] | None = None) -> int:
|
|
||||||
"""Run one CLI command and return a stable process exit status."""
|
|
||||||
|
|
||||||
parser = build_parser()
|
|
||||||
arguments = parser.parse_args(argv)
|
|
||||||
try:
|
|
||||||
if arguments.command == "doctor":
|
|
||||||
return _doctor(arguments)
|
|
||||||
if arguments.command == "serve":
|
|
||||||
return _serve_contract(arguments)
|
|
||||||
if arguments.command == "watch":
|
|
||||||
return _watch(arguments)
|
|
||||||
if arguments.command == "ingest-image":
|
|
||||||
return _ingest_image(arguments)
|
|
||||||
_load_cli_config(arguments.config)
|
|
||||||
return _unimplemented(arguments.command)
|
|
||||||
except (ConfigError, PathConfigurationError) as error:
|
|
||||||
print(f"configuration error: {error}", file=sys.stderr)
|
|
||||||
return EXIT_CONFIG_ERROR
|
|
||||||
except RuntimeFailure:
|
|
||||||
print("capture runtime failed", file=sys.stderr)
|
|
||||||
return EXIT_RUNTIME_ERROR
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
"""Typed, bounded configuration for the capture runtime.
|
|
||||||
|
|
||||||
Configuration parsing reads an explicit TOML file only when a caller invokes
|
|
||||||
``load_config``. Importing this module has no filesystem or environment effects.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ipaddress
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import tomllib
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from game_data_capture.paths import RuntimePaths, resolve_runtime_paths
|
|
||||||
|
|
||||||
_ENVIRONMENT_NAME = re.compile(r"^[A-Z_][A-Z0-9_]{0,63}$")
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigError(ValueError):
|
|
||||||
"""A bounded, safe-to-display configuration error."""
|
|
||||||
|
|
||||||
|
|
||||||
def _error(field_name: str, constraint: str) -> ConfigError:
|
|
||||||
return ConfigError(f"Invalid configuration: {field_name} {constraint}.")
|
|
||||||
|
|
||||||
|
|
||||||
def _bounded_int(value: object, field_name: str, minimum: int, maximum: int) -> int:
|
|
||||||
if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
|
|
||||||
raise _error(field_name, f"must be an integer from {minimum} to {maximum}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _mapping(value: object, field_name: str) -> Mapping[str, object]:
|
|
||||||
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
|
||||||
raise _error(field_name, "must be a table")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _only_keys(section: Mapping[str, object], section_name: str, allowed: set[str]) -> None:
|
|
||||||
if set(section) - allowed:
|
|
||||||
raise _error(section_name, "contains an unsupported setting")
|
|
||||||
|
|
||||||
|
|
||||||
def _string(value: object, field_name: str, maximum: int) -> str:
|
|
||||||
if not isinstance(value, str) or not value or len(value) > maximum:
|
|
||||||
raise _error(field_name, f"must be a non-empty string up to {maximum} characters")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ServerConfig:
|
|
||||||
host: str = "127.0.0.1"
|
|
||||||
port: int = 8765
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
try:
|
|
||||||
address = ipaddress.ip_address(self.host)
|
|
||||||
except ValueError as error:
|
|
||||||
raise _error("server.host", "must be a loopback IP literal") from error
|
|
||||||
if not address.is_loopback:
|
|
||||||
raise _error("server.host", "must be a loopback IP literal")
|
|
||||||
_bounded_int(self.port, "server.port", 1024, 65535)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class LifecycleConfig:
|
|
||||||
"""One-instance settings consumed later by the runtime integration layer."""
|
|
||||||
|
|
||||||
instance_name: str = "shd-capture"
|
|
||||||
lock_timeout_seconds: int = 5
|
|
||||||
shutdown_timeout_seconds: int = 30
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,31}", self.instance_name):
|
|
||||||
raise _error(
|
|
||||||
"lifecycle.instance_name", "must use lowercase letters, digits, and hyphens"
|
|
||||||
)
|
|
||||||
_bounded_int(self.lock_timeout_seconds, "lifecycle.lock_timeout_seconds", 0, 60)
|
|
||||||
_bounded_int(self.shutdown_timeout_seconds, "lifecycle.shutdown_timeout_seconds", 1, 300)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CaptureLimits:
|
|
||||||
max_image_bytes: int = 32 * 1024 * 1024
|
|
||||||
max_video_bytes: int = 4 * 1024 * 1024 * 1024
|
|
||||||
max_queue_items: int = 64
|
|
||||||
max_ocr_text_characters: int = 20_000
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
_bounded_int(self.max_image_bytes, "limits.max_image_bytes", 1, 64 * 1024 * 1024)
|
|
||||||
_bounded_int(self.max_video_bytes, "limits.max_video_bytes", 1, 16 * 1024 * 1024 * 1024)
|
|
||||||
_bounded_int(self.max_queue_items, "limits.max_queue_items", 1, 256)
|
|
||||||
_bounded_int(self.max_ocr_text_characters, "limits.max_ocr_text_characters", 1, 100_000)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class OpenAIConfig:
|
|
||||||
api_key_env: str = "OPENAI_API_KEY"
|
|
||||||
model: str = "gpt-4.1-mini"
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
if not _ENVIRONMENT_NAME.fullmatch(self.api_key_env):
|
|
||||||
raise _error("openai.api_key_env", "must be an environment variable name")
|
|
||||||
_string(self.model, "openai.model", 128)
|
|
||||||
|
|
||||||
def is_configured(self, environment: Mapping[str, str] | None = None) -> bool:
|
|
||||||
env = os.environ if environment is None else environment
|
|
||||||
return bool(env.get(self.api_key_env))
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class AppConfig:
|
|
||||||
server: ServerConfig = field(default_factory=ServerConfig)
|
|
||||||
lifecycle: LifecycleConfig = field(default_factory=LifecycleConfig)
|
|
||||||
limits: CaptureLimits = field(default_factory=CaptureLimits)
|
|
||||||
openai: OpenAIConfig = field(default_factory=OpenAIConfig)
|
|
||||||
|
|
||||||
|
|
||||||
def _section(document: Mapping[str, object], name: str, allowed: set[str]) -> Mapping[str, object]:
|
|
||||||
value = document.get(name, {})
|
|
||||||
section = _mapping(value, name)
|
|
||||||
_only_keys(section, name, allowed)
|
|
||||||
return section
|
|
||||||
|
|
||||||
|
|
||||||
def parse_config(document: Mapping[str, object]) -> AppConfig:
|
|
||||||
"""Parse a TOML-shaped mapping with an allowlist of configuration keys."""
|
|
||||||
|
|
||||||
_only_keys(document, "root", {"server", "lifecycle", "limits", "openai"})
|
|
||||||
server = _section(document, "server", {"host", "port"})
|
|
||||||
lifecycle = _section(
|
|
||||||
document, "lifecycle", {"instance_name", "lock_timeout_seconds", "shutdown_timeout_seconds"}
|
|
||||||
)
|
|
||||||
limits = _section(
|
|
||||||
document,
|
|
||||||
"limits",
|
|
||||||
{"max_image_bytes", "max_video_bytes", "max_queue_items", "max_ocr_text_characters"},
|
|
||||||
)
|
|
||||||
openai = _section(document, "openai", {"api_key_env", "model"})
|
|
||||||
|
|
||||||
return AppConfig(
|
|
||||||
server=ServerConfig(
|
|
||||||
host=_string(server.get("host", "127.0.0.1"), "server.host", 64),
|
|
||||||
port=_bounded_int(server.get("port", 8765), "server.port", 1024, 65535),
|
|
||||||
),
|
|
||||||
lifecycle=LifecycleConfig(
|
|
||||||
instance_name=_string(
|
|
||||||
lifecycle.get("instance_name", "shd-capture"), "lifecycle.instance_name", 32
|
|
||||||
),
|
|
||||||
lock_timeout_seconds=_bounded_int(
|
|
||||||
lifecycle.get("lock_timeout_seconds", 5), "lifecycle.lock_timeout_seconds", 0, 60
|
|
||||||
),
|
|
||||||
shutdown_timeout_seconds=_bounded_int(
|
|
||||||
lifecycle.get("shutdown_timeout_seconds", 30),
|
|
||||||
"lifecycle.shutdown_timeout_seconds",
|
|
||||||
1,
|
|
||||||
300,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
limits=CaptureLimits(
|
|
||||||
max_image_bytes=_bounded_int(
|
|
||||||
limits.get("max_image_bytes", 32 * 1024 * 1024),
|
|
||||||
"limits.max_image_bytes",
|
|
||||||
1,
|
|
||||||
64 * 1024 * 1024,
|
|
||||||
),
|
|
||||||
max_video_bytes=_bounded_int(
|
|
||||||
limits.get("max_video_bytes", 4 * 1024 * 1024 * 1024),
|
|
||||||
"limits.max_video_bytes",
|
|
||||||
1,
|
|
||||||
16 * 1024 * 1024 * 1024,
|
|
||||||
),
|
|
||||||
max_queue_items=_bounded_int(
|
|
||||||
limits.get("max_queue_items", 64), "limits.max_queue_items", 1, 256
|
|
||||||
),
|
|
||||||
max_ocr_text_characters=_bounded_int(
|
|
||||||
limits.get("max_ocr_text_characters", 20_000),
|
|
||||||
"limits.max_ocr_text_characters",
|
|
||||||
1,
|
|
||||||
100_000,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
openai=OpenAIConfig(
|
|
||||||
api_key_env=_string(
|
|
||||||
openai.get("api_key_env", "OPENAI_API_KEY"), "openai.api_key_env", 64
|
|
||||||
),
|
|
||||||
model=_string(openai.get("model", "gpt-4.1-mini"), "openai.model", 128),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def load_config(config_path: Path | None = None, paths: RuntimePaths | None = None) -> AppConfig:
|
|
||||||
"""Read optional TOML configuration from the XDG config location.
|
|
||||||
|
|
||||||
An absent file selects safe defaults. Parse failures intentionally omit source values and paths.
|
|
||||||
"""
|
|
||||||
|
|
||||||
selected_path = (
|
|
||||||
config_path if config_path is not None else (paths or resolve_runtime_paths()).config_file
|
|
||||||
)
|
|
||||||
if not selected_path.is_absolute() or len(str(selected_path)) > 240:
|
|
||||||
raise _error("config path", "must be an absolute path within 240 characters")
|
|
||||||
if not selected_path.exists():
|
|
||||||
return AppConfig()
|
|
||||||
try:
|
|
||||||
with selected_path.open("rb") as handle:
|
|
||||||
document: Any = tomllib.load(handle)
|
|
||||||
except (OSError, tomllib.TOMLDecodeError) as error:
|
|
||||||
raise _error("config file", "could not be read") from error
|
|
||||||
if not isinstance(document, dict):
|
|
||||||
raise _error("root", "must be a table")
|
|
||||||
return parse_config(document)
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
"""Side-effect-limited local diagnostics for capture prerequisites."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
import shutil
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from dataclasses import asdict, dataclass
|
|
||||||
from os import environ
|
|
||||||
|
|
||||||
from game_data_capture.config import AppConfig
|
|
||||||
from game_data_capture.paths import (
|
|
||||||
PathConfigurationError,
|
|
||||||
RuntimePaths,
|
|
||||||
prepare_private_directories,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class Diagnostic:
|
|
||||||
name: str
|
|
||||||
status: str
|
|
||||||
detail: str
|
|
||||||
|
|
||||||
|
|
||||||
def _executable_diagnostic(name: str) -> Diagnostic:
|
|
||||||
return Diagnostic(
|
|
||||||
name=name,
|
|
||||||
status="available" if shutil.which(name) else "unavailable",
|
|
||||||
detail="executable found on PATH" if shutil.which(name) else "executable not found on PATH",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _gpu_diagnostic(environment: Mapping[str, str]) -> Diagnostic:
|
|
||||||
configured_provider = environment.get("GDC_OCR_PROVIDER")
|
|
||||||
if configured_provider:
|
|
||||||
return Diagnostic(
|
|
||||||
"gpu_provider", "unknown", "provider configured; runtime probe is deferred"
|
|
||||||
)
|
|
||||||
if importlib.util.find_spec("torch") is not None:
|
|
||||||
return Diagnostic(
|
|
||||||
"gpu_provider", "unknown", "torch is installed; runtime probe is deferred"
|
|
||||||
)
|
|
||||||
return Diagnostic("gpu_provider", "unknown", "no provider selected; runtime probe is deferred")
|
|
||||||
|
|
||||||
|
|
||||||
def _storage_diagnostic(paths: RuntimePaths) -> Diagnostic:
|
|
||||||
try:
|
|
||||||
prepare_private_directories(paths)
|
|
||||||
except (OSError, PathConfigurationError):
|
|
||||||
return Diagnostic(
|
|
||||||
"storage_permissions", "unavailable", "private XDG directories are not usable"
|
|
||||||
)
|
|
||||||
return Diagnostic(
|
|
||||||
"storage_permissions", "available", "private XDG directories use owner-only access"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_doctor(
|
|
||||||
config: AppConfig, paths: RuntimePaths, environment: Mapping[str, str] | None = None
|
|
||||||
) -> tuple[Diagnostic, ...]:
|
|
||||||
"""Inspect local prerequisites without launching processes or making network calls."""
|
|
||||||
|
|
||||||
env = environ if environment is None else environment
|
|
||||||
return (
|
|
||||||
_executable_diagnostic("niri"),
|
|
||||||
_executable_diagnostic("wl-paste"),
|
|
||||||
_executable_diagnostic("ffmpeg"),
|
|
||||||
_gpu_diagnostic(env),
|
|
||||||
_storage_diagnostic(paths),
|
|
||||||
Diagnostic(
|
|
||||||
"openai",
|
|
||||||
"configured" if config.openai.is_configured(env) else "missing",
|
|
||||||
"environment variable is present"
|
|
||||||
if config.openai.is_configured(env)
|
|
||||||
else "environment variable is absent",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def doctor_as_dict(diagnostics: tuple[Diagnostic, ...]) -> list[dict[str, str]]:
|
|
||||||
"""Return diagnostics in a serialization-ready form without paths or secret values."""
|
|
||||||
|
|
||||||
return [asdict(diagnostic) for diagnostic in diagnostics]
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"""Side-effect-free domain contracts for the game data capture utility."""
|
|
||||||
|
|
||||||
from .state_machines import InvalidStateTransition, StateMachine, require_transition
|
|
||||||
from .taxonomy import (
|
|
||||||
ABSTENTION_REASONS,
|
|
||||||
GEAR_SLOTS,
|
|
||||||
RECORD_TYPES_BY_VIEW,
|
|
||||||
SCREEN_VIEWS,
|
|
||||||
AbstentionReason,
|
|
||||||
Family,
|
|
||||||
GearSlot,
|
|
||||||
RecordType,
|
|
||||||
)
|
|
||||||
from .validation import (
|
|
||||||
ContractValidationError,
|
|
||||||
ensure_supported_version,
|
|
||||||
validate_archive_document,
|
|
||||||
validate_archive_references,
|
|
||||||
validate_classification,
|
|
||||||
validate_handoff_document,
|
|
||||||
validate_handoff_references,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"ABSTENTION_REASONS",
|
|
||||||
"GEAR_SLOTS",
|
|
||||||
"RECORD_TYPES_BY_VIEW",
|
|
||||||
"SCREEN_VIEWS",
|
|
||||||
"AbstentionReason",
|
|
||||||
"ContractValidationError",
|
|
||||||
"Family",
|
|
||||||
"GearSlot",
|
|
||||||
"InvalidStateTransition",
|
|
||||||
"RecordType",
|
|
||||||
"StateMachine",
|
|
||||||
"ensure_supported_version",
|
|
||||||
"require_transition",
|
|
||||||
"validate_archive_document",
|
|
||||||
"validate_archive_references",
|
|
||||||
"validate_classification",
|
|
||||||
"validate_handoff_document",
|
|
||||||
"validate_handoff_references",
|
|
||||||
]
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
"""Typed Python views of the portable contract structures."""
|
|
||||||
|
|
||||||
import typing
|
|
||||||
|
|
||||||
type JsonValue = None | bool | int | float | str | list[JsonValue] | dict[str, JsonValue]
|
|
||||||
type MissingReason = typing.Literal[
|
|
||||||
"not_visible",
|
|
||||||
"occluded",
|
|
||||||
"unreadable",
|
|
||||||
"not_present_on_screen",
|
|
||||||
"unsupported",
|
|
||||||
"ambiguous",
|
|
||||||
"extraction_failed",
|
|
||||||
"not_applicable",
|
|
||||||
"redacted",
|
|
||||||
]
|
|
||||||
type ScreenshotFamily = typing.Literal[
|
|
||||||
"weapon",
|
|
||||||
"gear",
|
|
||||||
"skill_chip",
|
|
||||||
"os_protocol",
|
|
||||||
"specialization",
|
|
||||||
"weapon_mod",
|
|
||||||
"gear_mod",
|
|
||||||
"unknown",
|
|
||||||
]
|
|
||||||
type CanonicalRecordType = typing.Literal[
|
|
||||||
"weapon",
|
|
||||||
"weapon_talent",
|
|
||||||
"weapon_augmentation_profile",
|
|
||||||
"gear",
|
|
||||||
"brand_set",
|
|
||||||
"gear_talent",
|
|
||||||
"gear_augmentation_profile",
|
|
||||||
"skill_chip_set",
|
|
||||||
"skill_chip_set_bonus",
|
|
||||||
"skill_chip_roll_pool",
|
|
||||||
"skill_chip_talent",
|
|
||||||
"os_protocol",
|
|
||||||
"os_protocol_secondary_profile",
|
|
||||||
"os_protocol_talent",
|
|
||||||
"specialization",
|
|
||||||
"specialization_focus",
|
|
||||||
"specialization_ability",
|
|
||||||
"specialization_tree_node",
|
|
||||||
"specialization_tree_rank",
|
|
||||||
"weapon_mod",
|
|
||||||
"gear_mod",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class SourceEngine(typing.TypedDict):
|
|
||||||
kind: typing.Literal["local_ocr", "openai_vision", "classifier", "manual", "video_decoder"]
|
|
||||||
engine_id: str
|
|
||||||
engine_version: str
|
|
||||||
|
|
||||||
|
|
||||||
class NormalizedRegion(typing.TypedDict):
|
|
||||||
coordinate_space: typing.Literal["normalized"]
|
|
||||||
x: float
|
|
||||||
y: float
|
|
||||||
width: float
|
|
||||||
height: float
|
|
||||||
|
|
||||||
|
|
||||||
class PixelRegion(typing.TypedDict):
|
|
||||||
coordinate_space: typing.Literal["pixels"]
|
|
||||||
x: int
|
|
||||||
y: int
|
|
||||||
width: int
|
|
||||||
height: int
|
|
||||||
|
|
||||||
|
|
||||||
type BoundingRegion = NormalizedRegion | PixelRegion
|
|
||||||
|
|
||||||
|
|
||||||
class ValueState(typing.TypedDict):
|
|
||||||
raw_text: str | None
|
|
||||||
normalized_value: JsonValue
|
|
||||||
missing_reason: MissingReason | None
|
|
||||||
|
|
||||||
|
|
||||||
class Correction(typing.TypedDict):
|
|
||||||
correction_id: str
|
|
||||||
corrected_at: str
|
|
||||||
actor: typing.Literal["local_user", "system_reprocess"]
|
|
||||||
reason: str
|
|
||||||
before: ValueState
|
|
||||||
after: ValueState
|
|
||||||
|
|
||||||
|
|
||||||
class EvidenceValue(ValueState):
|
|
||||||
evidence_id: str
|
|
||||||
field_path: str
|
|
||||||
confidence: float
|
|
||||||
source_engine: SourceEngine
|
|
||||||
image_artifact_id: str
|
|
||||||
image_sha256: str
|
|
||||||
region: BoundingRegion
|
|
||||||
correction_history: list[Correction]
|
|
||||||
|
|
||||||
|
|
||||||
class FieldClaim(typing.TypedDict):
|
|
||||||
path: str
|
|
||||||
value: JsonValue
|
|
||||||
missing_reason: MissingReason | None
|
|
||||||
evidence_refs: list[str]
|
|
||||||
uncertainty_note: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class ScreenshotClassification(typing.TypedDict):
|
|
||||||
family: ScreenshotFamily
|
|
||||||
view: str
|
|
||||||
gear_slot: (
|
|
||||||
typing.Literal["Mask", "Gloves", "Backpack", "Body Armor", "Holster", "Knee Pads"] | None
|
|
||||||
)
|
|
||||||
record_types: list[CanonicalRecordType]
|
|
||||||
confidence: float
|
|
||||||
abstention_reason: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class ObservedContext(typing.TypedDict):
|
|
||||||
level: FieldClaim
|
|
||||||
quality: FieldClaim
|
|
||||||
tier: FieldClaim
|
|
||||||
upgrade_state: FieldClaim
|
|
||||||
|
|
||||||
|
|
||||||
class ObservedItemInstance(typing.TypedDict):
|
|
||||||
"""Values shown for one configured item; they are not reusable definitions."""
|
|
||||||
|
|
||||||
instance_id: str
|
|
||||||
family: ScreenshotFamily
|
|
||||||
source_screen_ids: list[str]
|
|
||||||
observed_context: ObservedContext
|
|
||||||
fields: list[FieldClaim]
|
|
||||||
|
|
||||||
|
|
||||||
class CanonicalDefinitionCandidate(typing.TypedDict):
|
|
||||||
"""Evidence-backed reusable claims, kept separate from the observed item."""
|
|
||||||
|
|
||||||
candidate_id: str
|
|
||||||
record_type: CanonicalRecordType
|
|
||||||
source_instance_ids: list[str]
|
|
||||||
evidence_refs: list[str]
|
|
||||||
fields: list[FieldClaim]
|
|
||||||
completeness: typing.Literal["complete", "incomplete", "unsupported"]
|
|
||||||
relationship_status: typing.Literal["evidenced", "unresolved", "unsupported", "not_applicable"]
|
|
||||||
mechanic_status: typing.Literal["evidenced", "unresolved", "unsupported", "not_applicable"]
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
"""Trust-boundary ports. Implementations belong to downstream tasks."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Iterable, Mapping, Sequence
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
|
||||||
from enum import StrEnum
|
|
||||||
from math import isfinite
|
|
||||||
from typing import Protocol
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ResourceBudget:
|
|
||||||
deadline_monotonic: float | None
|
|
||||||
max_input_bytes: int
|
|
||||||
max_output_bytes: int
|
|
||||||
max_items: int
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
limits: tuple[tuple[str, object], ...] = (
|
|
||||||
("max_input_bytes", self.max_input_bytes),
|
|
||||||
("max_output_bytes", self.max_output_bytes),
|
|
||||||
("max_items", self.max_items),
|
|
||||||
)
|
|
||||||
for name, value in limits:
|
|
||||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
||||||
raise ValueError(f"{name} must be a non-negative integer")
|
|
||||||
deadline: object = self.deadline_monotonic
|
|
||||||
if deadline is not None:
|
|
||||||
if isinstance(deadline, bool) or not isinstance(deadline, int | float):
|
|
||||||
raise ValueError("deadline_monotonic must be a finite number or null")
|
|
||||||
if not isfinite(deadline):
|
|
||||||
raise ValueError("deadline_monotonic must be finite or null")
|
|
||||||
|
|
||||||
|
|
||||||
class CancellationToken(Protocol):
|
|
||||||
def is_cancelled(self) -> bool: ...
|
|
||||||
|
|
||||||
def raise_if_cancelled(self) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
class Clock(Protocol):
|
|
||||||
def now_utc(self) -> datetime: ...
|
|
||||||
|
|
||||||
def monotonic(self) -> float: ...
|
|
||||||
|
|
||||||
|
|
||||||
class IdGenerator(Protocol):
|
|
||||||
def new_id(self) -> str: ...
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class BlobReceipt:
|
|
||||||
sha256: str
|
|
||||||
byte_size: int
|
|
||||||
media_type: str
|
|
||||||
|
|
||||||
|
|
||||||
class BlobStorage(Protocol):
|
|
||||||
"""Stores immutable bytes by digest without exposing filesystem paths."""
|
|
||||||
|
|
||||||
def put(
|
|
||||||
self,
|
|
||||||
chunks: Iterable[bytes],
|
|
||||||
*,
|
|
||||||
expected_sha256: str | None,
|
|
||||||
media_type: str,
|
|
||||||
budget: ResourceBudget,
|
|
||||||
cancellation: CancellationToken,
|
|
||||||
) -> BlobReceipt: ...
|
|
||||||
|
|
||||||
def read(
|
|
||||||
self,
|
|
||||||
sha256: str,
|
|
||||||
*,
|
|
||||||
budget: ResourceBudget,
|
|
||||||
cancellation: CancellationToken,
|
|
||||||
) -> Iterable[bytes]: ...
|
|
||||||
|
|
||||||
def exists(self, sha256: str) -> bool: ...
|
|
||||||
|
|
||||||
|
|
||||||
class Repository[RecordT](Protocol):
|
|
||||||
def get(self, record_id: str) -> RecordT | None: ...
|
|
||||||
|
|
||||||
def add(self, record: RecordT) -> None: ...
|
|
||||||
|
|
||||||
def replace(self, record: RecordT, *, expected_revision: int) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
type CaptureRepository = Repository[object]
|
|
||||||
type JobRepository = Repository[object]
|
|
||||||
type ExtractionRepository = Repository[object]
|
|
||||||
type GroupingRepository = Repository[object]
|
|
||||||
type ReviewRepository = Repository[object]
|
|
||||||
type ExportRepository = Repository[object]
|
|
||||||
type ErrorRepository = Repository[object]
|
|
||||||
|
|
||||||
|
|
||||||
class UnitOfWork(Protocol):
|
|
||||||
captures: CaptureRepository
|
|
||||||
jobs: JobRepository
|
|
||||||
extractions: ExtractionRepository
|
|
||||||
groupings: GroupingRepository
|
|
||||||
reviews: ReviewRepository
|
|
||||||
exports: ExportRepository
|
|
||||||
errors: ErrorRepository
|
|
||||||
|
|
||||||
def commit(self) -> None: ...
|
|
||||||
|
|
||||||
def rollback(self) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ImageInput:
|
|
||||||
artifact_id: str
|
|
||||||
sha256: str
|
|
||||||
media_type: str
|
|
||||||
byte_size: int
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class EngineResult:
|
|
||||||
engine_id: str
|
|
||||||
engine_version: str
|
|
||||||
payload: Mapping[str, object]
|
|
||||||
|
|
||||||
|
|
||||||
class OcrEngine(Protocol):
|
|
||||||
def extract(
|
|
||||||
self,
|
|
||||||
image: ImageInput,
|
|
||||||
*,
|
|
||||||
regions: Sequence[Mapping[str, int | float | str]],
|
|
||||||
budget: ResourceBudget,
|
|
||||||
cancellation: CancellationToken,
|
|
||||||
) -> EngineResult: ...
|
|
||||||
|
|
||||||
|
|
||||||
class VisionFallback(Protocol):
|
|
||||||
"""Accepts bounded artifacts and never receives credentials in a request."""
|
|
||||||
|
|
||||||
def extract(
|
|
||||||
self,
|
|
||||||
images: Sequence[ImageInput],
|
|
||||||
*,
|
|
||||||
response_schema: Mapping[str, object],
|
|
||||||
request_fingerprint: str,
|
|
||||||
budget: ResourceBudget,
|
|
||||||
cancellation: CancellationToken,
|
|
||||||
) -> EngineResult: ...
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class SubprocessRequest:
|
|
||||||
tool: SubprocessTool
|
|
||||||
arguments: tuple[str, ...]
|
|
||||||
environment: Mapping[SubprocessEnvironmentKey, str]
|
|
||||||
stdin: bytes | None
|
|
||||||
budget: ResourceBudget
|
|
||||||
|
|
||||||
|
|
||||||
class SubprocessTool(StrEnum):
|
|
||||||
WL_PASTE = "wl-paste"
|
|
||||||
FFMPEG = "ffmpeg"
|
|
||||||
FFPROBE = "ffprobe"
|
|
||||||
|
|
||||||
|
|
||||||
class SubprocessEnvironmentKey(StrEnum):
|
|
||||||
WAYLAND_DISPLAY = "WAYLAND_DISPLAY"
|
|
||||||
XDG_RUNTIME_DIR = "XDG_RUNTIME_DIR"
|
|
||||||
PATH = "PATH"
|
|
||||||
HOME = "HOME"
|
|
||||||
LANG = "LANG"
|
|
||||||
LC_ALL = "LC_ALL"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class SubprocessResult:
|
|
||||||
return_code: int
|
|
||||||
stdout: bytes
|
|
||||||
stderr: bytes
|
|
||||||
timed_out: bool
|
|
||||||
cancelled: bool
|
|
||||||
|
|
||||||
|
|
||||||
class SubprocessRunner(Protocol):
|
|
||||||
"""Runs an argument array without a shell and with an explicit environment."""
|
|
||||||
|
|
||||||
def run(
|
|
||||||
self,
|
|
||||||
request: SubprocessRequest,
|
|
||||||
*,
|
|
||||||
cancellation: CancellationToken,
|
|
||||||
) -> SubprocessResult: ...
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class FrameScore:
|
|
||||||
timestamp_ms: int
|
|
||||||
stability: float
|
|
||||||
sharpness: float
|
|
||||||
ui_coverage: float
|
|
||||||
|
|
||||||
|
|
||||||
class FrameScorer(Protocol):
|
|
||||||
def score(
|
|
||||||
self,
|
|
||||||
frames: Sequence[ImageInput],
|
|
||||||
*,
|
|
||||||
budget: ResourceBudget,
|
|
||||||
cancellation: CancellationToken,
|
|
||||||
) -> Sequence[FrameScore]: ...
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
"""State vocabularies and allowed transitions for recoverable workflows."""
|
|
||||||
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from enum import StrEnum
|
|
||||||
from types import MappingProxyType
|
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
|
|
||||||
class CaptureState(StrEnum):
|
|
||||||
RECEIVED = "received"
|
|
||||||
SPOOLING = "spooling"
|
|
||||||
STORED = "stored"
|
|
||||||
DEDUPLICATED = "deduplicated"
|
|
||||||
QUEUED = "queued"
|
|
||||||
FAILED = "failed"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
class JobState(StrEnum):
|
|
||||||
QUEUED = "queued"
|
|
||||||
RUNNING = "running"
|
|
||||||
RETRY_WAIT = "retry_wait"
|
|
||||||
SUCCEEDED = "succeeded"
|
|
||||||
FAILED = "failed"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
class ExtractionState(StrEnum):
|
|
||||||
PENDING = "pending"
|
|
||||||
LOCAL_PROCESSING = "local_processing"
|
|
||||||
FALLBACK_PENDING = "fallback_pending"
|
|
||||||
FALLBACK_PROCESSING = "fallback_processing"
|
|
||||||
CANDIDATE_READY = "candidate_ready"
|
|
||||||
ABSTAINED = "abstained"
|
|
||||||
FAILED = "failed"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
class GroupingState(StrEnum):
|
|
||||||
UNGROUPED = "ungrouped"
|
|
||||||
PROPOSED = "proposed"
|
|
||||||
GROUPED = "grouped"
|
|
||||||
SPLIT = "split"
|
|
||||||
SUPERSEDED = "superseded"
|
|
||||||
REJECTED = "rejected"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
class ReviewState(StrEnum):
|
|
||||||
PENDING = "pending"
|
|
||||||
IN_REVIEW = "in_review"
|
|
||||||
CHANGES_REQUESTED = "changes_requested"
|
|
||||||
APPROVED = "approved"
|
|
||||||
REJECTED = "rejected"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
class ExportState(StrEnum):
|
|
||||||
NOT_REQUESTED = "not_requested"
|
|
||||||
QUEUED = "queued"
|
|
||||||
BUILDING = "building"
|
|
||||||
READY = "ready"
|
|
||||||
FAILED = "failed"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
EXPIRED = "expired"
|
|
||||||
|
|
||||||
|
|
||||||
class ErrorState(StrEnum):
|
|
||||||
OPEN = "open"
|
|
||||||
RETRYABLE = "retryable"
|
|
||||||
RETRYING = "retrying"
|
|
||||||
RECOVERED = "recovered"
|
|
||||||
TERMINAL = "terminal"
|
|
||||||
DISMISSED = "dismissed"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class StateMachine:
|
|
||||||
name: str
|
|
||||||
transitions: Mapping[str, frozenset[str]]
|
|
||||||
|
|
||||||
def allows(self, current: str, target: str) -> bool:
|
|
||||||
return target in self.transitions.get(current, frozenset())
|
|
||||||
|
|
||||||
|
|
||||||
def _machine(name: str, transitions: dict[str, set[str]]) -> StateMachine:
|
|
||||||
return StateMachine(
|
|
||||||
name=name,
|
|
||||||
transitions=MappingProxyType(
|
|
||||||
{state: frozenset(targets) for state, targets in transitions.items()}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
STATE_MACHINES: Final[Mapping[str, StateMachine]] = MappingProxyType(
|
|
||||||
{
|
|
||||||
"capture": _machine(
|
|
||||||
"capture",
|
|
||||||
{
|
|
||||||
"received": {"spooling", "deduplicated", "failed", "cancelled"},
|
|
||||||
"spooling": {"stored", "failed", "cancelled"},
|
|
||||||
"stored": {"queued", "failed"},
|
|
||||||
"deduplicated": set(),
|
|
||||||
"queued": set(),
|
|
||||||
"failed": {"spooling", "cancelled"},
|
|
||||||
"cancelled": set(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
"job": _machine(
|
|
||||||
"job",
|
|
||||||
{
|
|
||||||
"queued": {"running", "cancelled"},
|
|
||||||
"running": {"succeeded", "retry_wait", "failed", "cancelled"},
|
|
||||||
"retry_wait": {"queued", "failed", "cancelled"},
|
|
||||||
"succeeded": set(),
|
|
||||||
"failed": {"queued"},
|
|
||||||
"cancelled": set(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
"extraction": _machine(
|
|
||||||
"extraction",
|
|
||||||
{
|
|
||||||
"pending": {"local_processing", "cancelled"},
|
|
||||||
"local_processing": {
|
|
||||||
"candidate_ready",
|
|
||||||
"fallback_pending",
|
|
||||||
"abstained",
|
|
||||||
"failed",
|
|
||||||
"cancelled",
|
|
||||||
},
|
|
||||||
"fallback_pending": {"fallback_processing", "cancelled"},
|
|
||||||
"fallback_processing": {
|
|
||||||
"candidate_ready",
|
|
||||||
"abstained",
|
|
||||||
"failed",
|
|
||||||
"cancelled",
|
|
||||||
},
|
|
||||||
"candidate_ready": set(),
|
|
||||||
"abstained": set(),
|
|
||||||
"failed": {"pending"},
|
|
||||||
"cancelled": set(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
"grouping": _machine(
|
|
||||||
"grouping",
|
|
||||||
{
|
|
||||||
"ungrouped": {"proposed", "cancelled"},
|
|
||||||
"proposed": {"grouped", "rejected", "cancelled"},
|
|
||||||
"grouped": {"split", "superseded"},
|
|
||||||
"split": {"superseded"},
|
|
||||||
"superseded": set(),
|
|
||||||
"rejected": {"ungrouped"},
|
|
||||||
"cancelled": set(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
"review": _machine(
|
|
||||||
"review",
|
|
||||||
{
|
|
||||||
"pending": {"in_review", "cancelled"},
|
|
||||||
"in_review": {
|
|
||||||
"changes_requested",
|
|
||||||
"approved",
|
|
||||||
"rejected",
|
|
||||||
"cancelled",
|
|
||||||
},
|
|
||||||
"changes_requested": {"in_review", "rejected", "cancelled"},
|
|
||||||
"approved": {"in_review"},
|
|
||||||
"rejected": {"in_review"},
|
|
||||||
"cancelled": set(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
"export": _machine(
|
|
||||||
"export",
|
|
||||||
{
|
|
||||||
"not_requested": {"queued"},
|
|
||||||
"queued": {"building", "cancelled"},
|
|
||||||
"building": {"ready", "failed", "cancelled"},
|
|
||||||
"ready": {"expired"},
|
|
||||||
"failed": {"queued", "cancelled"},
|
|
||||||
"cancelled": set(),
|
|
||||||
"expired": {"queued"},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
"error": _machine(
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"open": {"retryable", "terminal", "dismissed"},
|
|
||||||
"retryable": {"retrying", "terminal", "dismissed"},
|
|
||||||
"retrying": {"recovered", "retryable", "terminal"},
|
|
||||||
"recovered": set(),
|
|
||||||
"terminal": {"dismissed"},
|
|
||||||
"dismissed": set(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidStateTransition(ValueError):
|
|
||||||
"""Raised when a caller attempts a transition absent from the contract."""
|
|
||||||
|
|
||||||
|
|
||||||
def require_transition(machine_name: str, current: str, target: str) -> None:
|
|
||||||
machine = STATE_MACHINES.get(machine_name)
|
|
||||||
if machine is None:
|
|
||||||
raise InvalidStateTransition(f"Unknown state machine: {machine_name}")
|
|
||||||
if current not in machine.transitions:
|
|
||||||
raise InvalidStateTransition(f"Unknown {machine_name} state: {current}")
|
|
||||||
if not machine.allows(current, target):
|
|
||||||
raise InvalidStateTransition(f"Invalid {machine_name} transition: {current} -> {target}")
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
"""Frozen screenshot taxonomy and canonical record vocabulary for contract v1."""
|
|
||||||
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from enum import StrEnum
|
|
||||||
from types import MappingProxyType
|
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
|
|
||||||
class Family(StrEnum):
|
|
||||||
WEAPON = "weapon"
|
|
||||||
GEAR = "gear"
|
|
||||||
SKILL_CHIP = "skill_chip"
|
|
||||||
OS_PROTOCOL = "os_protocol"
|
|
||||||
SPECIALIZATION = "specialization"
|
|
||||||
WEAPON_MOD = "weapon_mod"
|
|
||||||
GEAR_MOD = "gear_mod"
|
|
||||||
UNKNOWN = "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
class GearSlot(StrEnum):
|
|
||||||
MASK = "Mask"
|
|
||||||
GLOVES = "Gloves"
|
|
||||||
BACKPACK = "Backpack"
|
|
||||||
BODY_ARMOR = "Body Armor"
|
|
||||||
HOLSTER = "Holster"
|
|
||||||
KNEE_PADS = "Knee Pads"
|
|
||||||
|
|
||||||
|
|
||||||
class AbstentionReason(StrEnum):
|
|
||||||
LOW_CONFIDENCE = "low_confidence"
|
|
||||||
AMBIGUOUS_FAMILY = "ambiguous_family"
|
|
||||||
AMBIGUOUS_VIEW = "ambiguous_view"
|
|
||||||
UNSUPPORTED_FAMILY = "unsupported_family"
|
|
||||||
UNSUPPORTED_VIEW = "unsupported_view"
|
|
||||||
INSUFFICIENT_VISUAL_EVIDENCE = "insufficient_visual_evidence"
|
|
||||||
OCCLUDED = "occluded"
|
|
||||||
CORRUPT_IMAGE = "corrupt_image"
|
|
||||||
RESOURCE_LIMIT = "resource_limit"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
class RecordType(StrEnum):
|
|
||||||
WEAPON = "weapon"
|
|
||||||
WEAPON_TALENT = "weapon_talent"
|
|
||||||
WEAPON_AUGMENTATION_PROFILE = "weapon_augmentation_profile"
|
|
||||||
GEAR = "gear"
|
|
||||||
BRAND_SET = "brand_set"
|
|
||||||
GEAR_TALENT = "gear_talent"
|
|
||||||
GEAR_AUGMENTATION_PROFILE = "gear_augmentation_profile"
|
|
||||||
SKILL_CHIP_SET = "skill_chip_set"
|
|
||||||
SKILL_CHIP_SET_BONUS = "skill_chip_set_bonus"
|
|
||||||
SKILL_CHIP_ROLL_POOL = "skill_chip_roll_pool"
|
|
||||||
SKILL_CHIP_TALENT = "skill_chip_talent"
|
|
||||||
OS_PROTOCOL = "os_protocol"
|
|
||||||
OS_PROTOCOL_SECONDARY_PROFILE = "os_protocol_secondary_profile"
|
|
||||||
OS_PROTOCOL_TALENT = "os_protocol_talent"
|
|
||||||
SPECIALIZATION = "specialization"
|
|
||||||
SPECIALIZATION_FOCUS = "specialization_focus"
|
|
||||||
SPECIALIZATION_ABILITY = "specialization_ability"
|
|
||||||
SPECIALIZATION_TREE_NODE = "specialization_tree_node"
|
|
||||||
SPECIALIZATION_TREE_RANK = "specialization_tree_rank"
|
|
||||||
WEAPON_MOD = "weapon_mod"
|
|
||||||
GEAR_MOD = "gear_mod"
|
|
||||||
|
|
||||||
|
|
||||||
SCREEN_VIEWS: Final[Mapping[Family, tuple[str, ...]]] = MappingProxyType(
|
|
||||||
{
|
|
||||||
Family.WEAPON: (
|
|
||||||
"detail",
|
|
||||||
"blueprint",
|
|
||||||
"crafting",
|
|
||||||
"stat_range",
|
|
||||||
"talent_grid",
|
|
||||||
"augmentation",
|
|
||||||
),
|
|
||||||
Family.GEAR: ("detail", "blueprint", "talent", "augmentation"),
|
|
||||||
Family.SKILL_CHIP: ("detail", "set_bonus", "talent"),
|
|
||||||
Family.OS_PROTOCOL: ("detail", "secondary_profile", "talent"),
|
|
||||||
Family.SPECIALIZATION: (
|
|
||||||
"overview",
|
|
||||||
"details",
|
|
||||||
"ability",
|
|
||||||
"focus_details",
|
|
||||||
"focus_tree",
|
|
||||||
"rank_details",
|
|
||||||
),
|
|
||||||
Family.WEAPON_MOD: ("detail",),
|
|
||||||
Family.GEAR_MOD: ("detail",),
|
|
||||||
Family.UNKNOWN: ("unknown",),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
GEAR_SLOTS: Final[tuple[str, ...]] = tuple(slot.value for slot in GearSlot)
|
|
||||||
ABSTENTION_REASONS: Final[tuple[str, ...]] = tuple(reason.value for reason in AbstentionReason)
|
|
||||||
|
|
||||||
RECORD_TYPES_BY_VIEW: Final[Mapping[tuple[Family, str], tuple[RecordType, ...]]] = MappingProxyType(
|
|
||||||
{
|
|
||||||
(Family.WEAPON, "detail"): (RecordType.WEAPON, RecordType.WEAPON_TALENT),
|
|
||||||
(Family.WEAPON, "blueprint"): (RecordType.WEAPON, RecordType.WEAPON_TALENT),
|
|
||||||
(Family.WEAPON, "crafting"): (RecordType.WEAPON,),
|
|
||||||
(Family.WEAPON, "stat_range"): (RecordType.WEAPON,),
|
|
||||||
(Family.WEAPON, "talent_grid"): (RecordType.WEAPON_TALENT,),
|
|
||||||
(Family.WEAPON, "augmentation"): (RecordType.WEAPON_AUGMENTATION_PROFILE,),
|
|
||||||
(Family.GEAR, "detail"): (RecordType.GEAR, RecordType.BRAND_SET),
|
|
||||||
(Family.GEAR, "blueprint"): (RecordType.GEAR, RecordType.BRAND_SET),
|
|
||||||
(Family.GEAR, "talent"): (RecordType.GEAR_TALENT,),
|
|
||||||
(Family.GEAR, "augmentation"): (RecordType.GEAR_AUGMENTATION_PROFILE,),
|
|
||||||
(Family.SKILL_CHIP, "detail"): (
|
|
||||||
RecordType.SKILL_CHIP_SET,
|
|
||||||
RecordType.SKILL_CHIP_ROLL_POOL,
|
|
||||||
),
|
|
||||||
(Family.SKILL_CHIP, "set_bonus"): (RecordType.SKILL_CHIP_SET_BONUS,),
|
|
||||||
(Family.SKILL_CHIP, "talent"): (RecordType.SKILL_CHIP_TALENT,),
|
|
||||||
(Family.OS_PROTOCOL, "detail"): (RecordType.OS_PROTOCOL,),
|
|
||||||
(Family.OS_PROTOCOL, "secondary_profile"): (RecordType.OS_PROTOCOL_SECONDARY_PROFILE,),
|
|
||||||
(Family.OS_PROTOCOL, "talent"): (RecordType.OS_PROTOCOL_TALENT,),
|
|
||||||
(Family.SPECIALIZATION, "overview"): (RecordType.SPECIALIZATION,),
|
|
||||||
(Family.SPECIALIZATION, "details"): (RecordType.SPECIALIZATION,),
|
|
||||||
(Family.SPECIALIZATION, "ability"): (RecordType.SPECIALIZATION_ABILITY,),
|
|
||||||
(Family.SPECIALIZATION, "focus_details"): (RecordType.SPECIALIZATION_FOCUS,),
|
|
||||||
(Family.SPECIALIZATION, "focus_tree"): (
|
|
||||||
RecordType.SPECIALIZATION_FOCUS,
|
|
||||||
RecordType.SPECIALIZATION_TREE_NODE,
|
|
||||||
),
|
|
||||||
(Family.SPECIALIZATION, "rank_details"): (RecordType.SPECIALIZATION_TREE_RANK,),
|
|
||||||
(Family.WEAPON_MOD, "detail"): (RecordType.WEAPON_MOD,),
|
|
||||||
(Family.GEAR_MOD, "detail"): (RecordType.GEAR_MOD,),
|
|
||||||
(Family.UNKNOWN, "unknown"): (),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@@ -1,390 +0,0 @@
|
|||||||
"""Semantic checks that portable JSON Schema cannot express."""
|
|
||||||
|
|
||||||
from collections.abc import Iterable, Mapping
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from . import taxonomy
|
|
||||||
|
|
||||||
SUPPORTED_ARCHIVE_SCHEMA_VERSION = 1
|
|
||||||
SUPPORTED_HANDOFF_SCHEMA_VERSION = 1
|
|
||||||
|
|
||||||
|
|
||||||
class ContractValidationError(ValueError):
|
|
||||||
"""Raised when a document violates a version or referential contract."""
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_supported_version(document: Mapping[str, object], expected: int) -> None:
|
|
||||||
version = document.get("schema_version")
|
|
||||||
if type(version) is not int or version != expected:
|
|
||||||
raise ContractValidationError(
|
|
||||||
f"Unsupported schema_version {version!r}; expected {expected}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _objects(value: object, field: str) -> Iterable[Mapping[str, object]]:
|
|
||||||
if not isinstance(value, list):
|
|
||||||
raise ContractValidationError(f"{field} must be an array")
|
|
||||||
for index, item in enumerate(value):
|
|
||||||
if not isinstance(item, Mapping):
|
|
||||||
raise ContractValidationError(f"{field}[{index}] must be an object")
|
|
||||||
yield item
|
|
||||||
|
|
||||||
|
|
||||||
def _ids(items: Iterable[Mapping[str, object]], field: str) -> set[str]:
|
|
||||||
result: set[str] = set()
|
|
||||||
for item in items:
|
|
||||||
value = item.get(field)
|
|
||||||
if not isinstance(value, str):
|
|
||||||
raise ContractValidationError(f"{field} must be a string")
|
|
||||||
if value in result:
|
|
||||||
raise ContractValidationError(f"Duplicate {field}: {value}")
|
|
||||||
result.add(value)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _references(item: Mapping[str, object], field: str) -> set[str]:
|
|
||||||
raw = item.get(field, [])
|
|
||||||
if not isinstance(raw, list) or any(not isinstance(value, str) for value in raw):
|
|
||||||
raise ContractValidationError(f"{field} must contain string IDs")
|
|
||||||
return set(raw)
|
|
||||||
|
|
||||||
|
|
||||||
def _require_subset(values: set[str], allowed: set[str], context: str) -> None:
|
|
||||||
dangling = values - allowed
|
|
||||||
if dangling:
|
|
||||||
raise ContractValidationError(f"{context} contains dangling references: {sorted(dangling)}")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_classification(classification: Mapping[str, object]) -> None:
|
|
||||||
family_raw = classification.get("family")
|
|
||||||
if not isinstance(family_raw, str):
|
|
||||||
raise ContractValidationError("Unknown classification family")
|
|
||||||
try:
|
|
||||||
family = taxonomy.Family(family_raw)
|
|
||||||
except ValueError as error:
|
|
||||||
raise ContractValidationError("Unknown classification family") from error
|
|
||||||
view = classification.get("view")
|
|
||||||
if not isinstance(view, str) or view not in taxonomy.SCREEN_VIEWS[family]:
|
|
||||||
raise ContractValidationError(f"Unknown {family.value} view: {view!r}")
|
|
||||||
confidence = classification.get("confidence")
|
|
||||||
if (
|
|
||||||
isinstance(confidence, bool)
|
|
||||||
or not isinstance(confidence, int | float)
|
|
||||||
or not 0 <= confidence <= 1
|
|
||||||
):
|
|
||||||
raise ContractValidationError("Classification confidence must be between 0 and 1")
|
|
||||||
gear_slot = classification.get("gear_slot")
|
|
||||||
if family is taxonomy.Family.GEAR:
|
|
||||||
if gear_slot not in taxonomy.GEAR_SLOTS:
|
|
||||||
raise ContractValidationError(f"Unknown Gear slot: {gear_slot!r}")
|
|
||||||
elif gear_slot is not None:
|
|
||||||
raise ContractValidationError("Only Gear classifications may set gear_slot")
|
|
||||||
record_types = classification.get("record_types")
|
|
||||||
if not isinstance(record_types, list) or any(
|
|
||||||
not isinstance(record_type, str) for record_type in record_types
|
|
||||||
):
|
|
||||||
raise ContractValidationError("record_types must contain strings")
|
|
||||||
if len(record_types) != len(set(record_types)):
|
|
||||||
raise ContractValidationError("record_types must be unique")
|
|
||||||
if len(record_types) > 8:
|
|
||||||
raise ContractValidationError("record_types cannot contain more than 8 values")
|
|
||||||
try:
|
|
||||||
normalized_record_types = {taxonomy.RecordType(record_type) for record_type in record_types}
|
|
||||||
except ValueError as error:
|
|
||||||
raise ContractValidationError("Unknown canonical record type") from error
|
|
||||||
allowed = set(taxonomy.RECORD_TYPES_BY_VIEW[(family, view)])
|
|
||||||
if family is taxonomy.Family.UNKNOWN:
|
|
||||||
if record_types:
|
|
||||||
raise ContractValidationError("Unknown screens cannot claim canonical record types")
|
|
||||||
reason = classification.get("abstention_reason")
|
|
||||||
if reason not in taxonomy.ABSTENTION_REASONS:
|
|
||||||
raise ContractValidationError("Unknown screens require an abstention reason")
|
|
||||||
else:
|
|
||||||
if not record_types or not normalized_record_types.issubset(allowed):
|
|
||||||
raise ContractValidationError(
|
|
||||||
f"record_types are incompatible with {family.value}/{view}"
|
|
||||||
)
|
|
||||||
if classification.get("abstention_reason") is not None:
|
|
||||||
raise ContractValidationError("Known screens cannot set abstention_reason")
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_evidence(value: Mapping[str, object]) -> None:
|
|
||||||
_validate_value_state(value, "evidence")
|
|
||||||
confidence = value.get("confidence")
|
|
||||||
if (
|
|
||||||
isinstance(confidence, bool)
|
|
||||||
or not isinstance(confidence, int | float)
|
|
||||||
or not 0 <= confidence <= 1
|
|
||||||
):
|
|
||||||
raise ContractValidationError("Evidence confidence must be between 0 and 1")
|
|
||||||
region = value.get("region")
|
|
||||||
if not isinstance(region, Mapping):
|
|
||||||
raise ContractValidationError("Evidence region must be an object")
|
|
||||||
coordinate_space = region.get("coordinate_space")
|
|
||||||
numeric_coordinates: list[int | float] = []
|
|
||||||
for name in ("x", "y", "width", "height"):
|
|
||||||
number = region.get(name)
|
|
||||||
if isinstance(number, bool) or not isinstance(number, int | float):
|
|
||||||
raise ContractValidationError("Evidence bounds must be numeric")
|
|
||||||
numeric_coordinates.append(number)
|
|
||||||
x, y, width, height = numeric_coordinates
|
|
||||||
if coordinate_space == "normalized":
|
|
||||||
if x < 0 or y < 0 or width <= 0 or height <= 0 or x + width > 1 or y + height > 1:
|
|
||||||
raise ContractValidationError("Normalized evidence bounds must fit within the image")
|
|
||||||
elif coordinate_space == "pixels":
|
|
||||||
if any(not isinstance(number, int) for number in numeric_coordinates):
|
|
||||||
raise ContractValidationError("Pixel evidence bounds must be integers")
|
|
||||||
if x < 0 or y < 0 or width <= 0 or height <= 0:
|
|
||||||
raise ContractValidationError("Pixel evidence bounds must be positive and non-negative")
|
|
||||||
else:
|
|
||||||
raise ContractValidationError(f"Unknown coordinate space: {coordinate_space!r}")
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_value_state(value: Mapping[str, object], context: str) -> None:
|
|
||||||
raw_text = value.get("raw_text")
|
|
||||||
normalized = value.get("normalized_value")
|
|
||||||
missing_reason = value.get("missing_reason")
|
|
||||||
if raw_text is not None and not isinstance(raw_text, str):
|
|
||||||
raise ContractValidationError(f"{context} raw_text must be a string or null")
|
|
||||||
if normalized is None and not isinstance(missing_reason, str):
|
|
||||||
raise ContractValidationError(f"Null {context} requires missing_reason")
|
|
||||||
if normalized is not None and missing_reason is not None:
|
|
||||||
raise ContractValidationError(f"Present {context} cannot set missing_reason")
|
|
||||||
|
|
||||||
|
|
||||||
def _value_state(value: Mapping[str, object], context: str) -> tuple[object, object, object]:
|
|
||||||
_validate_value_state(value, context)
|
|
||||||
return value.get("raw_text"), value.get("normalized_value"), value.get("missing_reason")
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_correction_time(value: object) -> datetime:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
raise ContractValidationError("Correction corrected_at must be a timestamp")
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
||||||
except ValueError as error:
|
|
||||||
raise ContractValidationError("Correction corrected_at must be a timestamp") from error
|
|
||||||
if parsed.tzinfo is None:
|
|
||||||
raise ContractValidationError("Correction corrected_at must include a UTC offset")
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_correction_history(
|
|
||||||
evidence: Mapping[str, object], seen_correction_ids: set[str]
|
|
||||||
) -> None:
|
|
||||||
history = evidence.get("correction_history")
|
|
||||||
if not isinstance(history, list):
|
|
||||||
raise ContractValidationError("correction_history must be an array")
|
|
||||||
previous_after: tuple[object, object, object] | None = None
|
|
||||||
previous_time: datetime | None = None
|
|
||||||
for index, correction in enumerate(history):
|
|
||||||
if not isinstance(correction, Mapping):
|
|
||||||
raise ContractValidationError(f"correction_history[{index}] must be an object")
|
|
||||||
correction_id = correction.get("correction_id")
|
|
||||||
if not isinstance(correction_id, str):
|
|
||||||
raise ContractValidationError("correction_id must be a string")
|
|
||||||
if correction_id in seen_correction_ids:
|
|
||||||
raise ContractValidationError(f"Duplicate correction_id: {correction_id}")
|
|
||||||
seen_correction_ids.add(correction_id)
|
|
||||||
corrected_at = _parse_correction_time(correction.get("corrected_at"))
|
|
||||||
if previous_time is not None and corrected_at <= previous_time:
|
|
||||||
raise ContractValidationError("Correction history timestamps must be strictly ordered")
|
|
||||||
before = correction.get("before")
|
|
||||||
after = correction.get("after")
|
|
||||||
if not isinstance(before, Mapping) or not isinstance(after, Mapping):
|
|
||||||
raise ContractValidationError("Correction before and after must be value states")
|
|
||||||
before_state = _value_state(before, "correction before")
|
|
||||||
after_state = _value_state(after, "correction after")
|
|
||||||
if previous_after is not None and before_state != previous_after:
|
|
||||||
raise ContractValidationError("Correction history contains a disconnected value state")
|
|
||||||
previous_after = after_state
|
|
||||||
previous_time = corrected_at
|
|
||||||
if previous_after is not None and previous_after != _value_state(evidence, "evidence"):
|
|
||||||
raise ContractValidationError("Correction history final state does not match evidence")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_archive_document(document: Mapping[str, object]) -> None:
|
|
||||||
validate_archive_references(document)
|
|
||||||
for screen in _objects(document.get("screens"), "screens"):
|
|
||||||
classification = screen.get("classification")
|
|
||||||
if not isinstance(classification, Mapping):
|
|
||||||
raise ContractValidationError("screen.classification must be an object")
|
|
||||||
validate_classification(classification)
|
|
||||||
seen_correction_ids: set[str] = set()
|
|
||||||
for value in _objects(document.get("evidence_values"), "evidence_values"):
|
|
||||||
_validate_evidence(value)
|
|
||||||
_validate_correction_history(value, seen_correction_ids)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_handoff_document(document: Mapping[str, object]) -> None:
|
|
||||||
validate_handoff_references(document)
|
|
||||||
expected_boundary: dict[str, object] = {
|
|
||||||
"intended_destination": "authenticated_isac_intake",
|
|
||||||
"mutation_authority": "none",
|
|
||||||
"requires_authenticated_action": True,
|
|
||||||
"preserves_moderation": True,
|
|
||||||
"creates_audit_events": False,
|
|
||||||
}
|
|
||||||
if document.get("trust_boundary") != expected_boundary:
|
|
||||||
raise ContractValidationError("The handoff trust boundary is invalid")
|
|
||||||
seen_correction_ids: set[str] = set()
|
|
||||||
for value in _objects(document.get("evidence_values"), "evidence_values"):
|
|
||||||
_validate_evidence(value)
|
|
||||||
_validate_correction_history(value, seen_correction_ids)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_archive_references(document: Mapping[str, object]) -> None:
|
|
||||||
ensure_supported_version(document, SUPPORTED_ARCHIVE_SCHEMA_VERSION)
|
|
||||||
artifacts = list(_objects(document.get("artifacts"), "artifacts"))
|
|
||||||
captures = list(_objects(document.get("captures"), "captures"))
|
|
||||||
screens = list(_objects(document.get("screens"), "screens"))
|
|
||||||
jobs = list(_objects(document.get("jobs"), "jobs"))
|
|
||||||
extractions = list(_objects(document.get("extractions"), "extractions"))
|
|
||||||
groups = list(_objects(document.get("groups"), "groups"))
|
|
||||||
reviews = list(_objects(document.get("reviews"), "reviews"))
|
|
||||||
exports = list(_objects(document.get("exports"), "exports"))
|
|
||||||
errors = list(_objects(document.get("errors"), "errors"))
|
|
||||||
evidence = list(_objects(document.get("evidence_values"), "evidence_values"))
|
|
||||||
instances = list(_objects(document.get("observed_instances"), "observed_instances"))
|
|
||||||
candidates = list(_objects(document.get("canonical_candidates"), "canonical_candidates"))
|
|
||||||
artifact_ids = _ids(artifacts, "artifact_id")
|
|
||||||
_ids(captures, "capture_id")
|
|
||||||
screen_ids = _ids(screens, "screen_id")
|
|
||||||
_ids(jobs, "job_id")
|
|
||||||
_ids(extractions, "extraction_id")
|
|
||||||
group_ids = _ids(groups, "group_id")
|
|
||||||
_ids(reviews, "review_id")
|
|
||||||
_ids(exports, "export_id")
|
|
||||||
error_ids = _ids(errors, "error_id")
|
|
||||||
evidence_ids = _ids(evidence, "evidence_id")
|
|
||||||
instance_ids = _ids(instances, "instance_id")
|
|
||||||
candidate_ids = _ids(candidates, "candidate_id")
|
|
||||||
artifact_hashes = {
|
|
||||||
str(artifact.get("artifact_id")): str(artifact.get("sha256")) for artifact in artifacts
|
|
||||||
}
|
|
||||||
|
|
||||||
for capture in captures:
|
|
||||||
_require_subset({str(capture.get("artifact_id"))}, artifact_ids, "capture.artifact_id")
|
|
||||||
for artifact in artifacts:
|
|
||||||
parent_id = artifact.get("parent_artifact_id")
|
|
||||||
if parent_id is not None:
|
|
||||||
if str(parent_id) == str(artifact.get("artifact_id")):
|
|
||||||
raise ContractValidationError("artifact.parent_artifact_id cannot reference itself")
|
|
||||||
_require_subset({str(parent_id)}, artifact_ids, "artifact.parent_artifact_id")
|
|
||||||
for screen in screens:
|
|
||||||
_require_subset({str(screen.get("artifact_id"))}, artifact_ids, "screen.artifact_id")
|
|
||||||
for value in evidence:
|
|
||||||
artifact_id = str(value.get("image_artifact_id"))
|
|
||||||
_require_subset(
|
|
||||||
{artifact_id},
|
|
||||||
artifact_ids,
|
|
||||||
"evidence.image_artifact_id",
|
|
||||||
)
|
|
||||||
if artifact_hashes.get(artifact_id) != value.get("image_sha256"):
|
|
||||||
raise ContractValidationError("Evidence image_sha256 does not match its artifact")
|
|
||||||
for job in jobs:
|
|
||||||
last_error_id = job.get("last_error_id")
|
|
||||||
if last_error_id is not None:
|
|
||||||
_require_subset({str(last_error_id)}, error_ids, "job.last_error_id")
|
|
||||||
for extraction in extractions:
|
|
||||||
_require_subset({str(extraction.get("screen_id"))}, screen_ids, "extraction.screen_id")
|
|
||||||
_require_subset(
|
|
||||||
_references(extraction, "evidence_refs"),
|
|
||||||
evidence_ids,
|
|
||||||
"extraction.evidence_refs",
|
|
||||||
)
|
|
||||||
for group in groups:
|
|
||||||
_require_subset(_references(group, "screen_ids"), screen_ids, "group.screen_ids")
|
|
||||||
supersedes_group_ids = _references(group, "supersedes_group_ids")
|
|
||||||
if str(group.get("group_id")) in supersedes_group_ids:
|
|
||||||
raise ContractValidationError("group.supersedes_group_ids cannot reference itself")
|
|
||||||
_require_subset(
|
|
||||||
supersedes_group_ids,
|
|
||||||
group_ids,
|
|
||||||
"group.supersedes_group_ids",
|
|
||||||
)
|
|
||||||
for instance in instances:
|
|
||||||
_require_subset(
|
|
||||||
_references(instance, "source_screen_ids"),
|
|
||||||
screen_ids,
|
|
||||||
"observed_instance.source_screen_ids",
|
|
||||||
)
|
|
||||||
for field in _objects(instance.get("fields"), "observed_instance.fields"):
|
|
||||||
_require_subset(
|
|
||||||
_references(field, "evidence_refs"),
|
|
||||||
evidence_ids,
|
|
||||||
"observed_instance.fields.evidence_refs",
|
|
||||||
)
|
|
||||||
context = instance.get("observed_context")
|
|
||||||
if not isinstance(context, Mapping):
|
|
||||||
raise ContractValidationError("observed_instance.observed_context must be an object")
|
|
||||||
for context_name in ("level", "quality", "tier", "upgrade_state"):
|
|
||||||
context_field = context.get(context_name)
|
|
||||||
if not isinstance(context_field, Mapping):
|
|
||||||
raise ContractValidationError(f"observed_context.{context_name} must be an object")
|
|
||||||
_require_subset(
|
|
||||||
_references(context_field, "evidence_refs"),
|
|
||||||
evidence_ids,
|
|
||||||
f"observed_context.{context_name}.evidence_refs",
|
|
||||||
)
|
|
||||||
for candidate in candidates:
|
|
||||||
_require_subset(
|
|
||||||
_references(candidate, "source_instance_ids"),
|
|
||||||
instance_ids,
|
|
||||||
"canonical_candidate.source_instance_ids",
|
|
||||||
)
|
|
||||||
_require_subset(
|
|
||||||
_references(candidate, "evidence_refs"),
|
|
||||||
evidence_ids,
|
|
||||||
"canonical_candidate.evidence_refs",
|
|
||||||
)
|
|
||||||
for field in _objects(candidate.get("fields"), "canonical_candidate.fields"):
|
|
||||||
_require_subset(
|
|
||||||
_references(field, "evidence_refs"),
|
|
||||||
evidence_ids,
|
|
||||||
"canonical_candidate.fields.evidence_refs",
|
|
||||||
)
|
|
||||||
for review in reviews:
|
|
||||||
_require_subset({str(review.get("candidate_id"))}, candidate_ids, "review.candidate_id")
|
|
||||||
for export in exports:
|
|
||||||
export_artifact_id = export.get("artifact_id")
|
|
||||||
if export_artifact_id is not None:
|
|
||||||
_require_subset({str(export_artifact_id)}, artifact_ids, "export.artifact_id")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_handoff_references(document: Mapping[str, object]) -> None:
|
|
||||||
ensure_supported_version(document, SUPPORTED_HANDOFF_SCHEMA_VERSION)
|
|
||||||
evidence = list(_objects(document.get("evidence_values"), "evidence_values"))
|
|
||||||
records = list(_objects(document.get("records"), "records"))
|
|
||||||
evidence_ids = _ids(evidence, "evidence_id")
|
|
||||||
artifacts = list(_objects(document.get("source_artifacts"), "source_artifacts"))
|
|
||||||
artifact_ids = _ids(artifacts, "artifact_id")
|
|
||||||
artifact_hashes = {
|
|
||||||
str(artifact.get("artifact_id")): str(artifact.get("sha256")) for artifact in artifacts
|
|
||||||
}
|
|
||||||
_ids(records, "handoff_record_id")
|
|
||||||
for value in evidence:
|
|
||||||
artifact_id = str(value.get("image_artifact_id"))
|
|
||||||
_require_subset(
|
|
||||||
{artifact_id},
|
|
||||||
artifact_ids,
|
|
||||||
"evidence.image_artifact_id",
|
|
||||||
)
|
|
||||||
if artifact_hashes.get(artifact_id) != value.get("image_sha256"):
|
|
||||||
raise ContractValidationError("Evidence image_sha256 does not match its artifact")
|
|
||||||
for record in records:
|
|
||||||
for field in _objects(record.get("fields"), "record.fields"):
|
|
||||||
_require_subset(
|
|
||||||
_references(field, "evidence_refs"),
|
|
||||||
evidence_ids,
|
|
||||||
"record.fields.evidence_refs",
|
|
||||||
)
|
|
||||||
for section_name in ("relationships", "mechanics"):
|
|
||||||
section = record.get(section_name)
|
|
||||||
if not isinstance(section, Mapping):
|
|
||||||
raise ContractValidationError(f"record.{section_name} must be an object")
|
|
||||||
_require_subset(
|
|
||||||
_references(section, "evidence_refs"),
|
|
||||||
evidence_ids,
|
|
||||||
f"record.{section_name}.evidence_refs",
|
|
||||||
)
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
"""Wayland clipboard intake and private spool boundaries.
|
|
||||||
|
|
||||||
Submodules stay unimported so the stdin helper can start independently.
|
|
||||||
"""
|
|
||||||
@@ -1,510 +0,0 @@
|
|||||||
"""Bounded, shell-free supervision for Wayland PNG clipboard intake."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import signal
|
|
||||||
import stat
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from collections import deque
|
|
||||||
from collections.abc import Iterator, Mapping
|
|
||||||
from contextlib import suppress
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Final, cast
|
|
||||||
|
|
||||||
from game_data_capture.ingest.spool_helper import (
|
|
||||||
PNG_SIGNATURE,
|
|
||||||
acknowledge_receipt,
|
|
||||||
durable_receipts,
|
|
||||||
reconcile_spool,
|
|
||||||
)
|
|
||||||
|
|
||||||
_SAFE_ENV: Final = frozenset(
|
|
||||||
{"PATH", "HOME", "LANG", "LC_ALL", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR"}
|
|
||||||
)
|
|
||||||
_DENIED_ENV = re.compile(
|
|
||||||
r"(?:openai|authorization|token|secret|python(?:home|path|startup|userbase))", re.I
|
|
||||||
)
|
|
||||||
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
||||||
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
|
||||||
_MAX_METADATA = 16 * 1024
|
|
||||||
_MAX_RECEIPT = 1024
|
|
||||||
|
|
||||||
|
|
||||||
def _integer(value: object, minimum: int, maximum: int) -> bool:
|
|
||||||
return isinstance(value, int) and not isinstance(value, bool) and minimum <= value <= maximum
|
|
||||||
|
|
||||||
|
|
||||||
def _finite(value: object, minimum: float, maximum: float) -> bool:
|
|
||||||
return (
|
|
||||||
isinstance(value, int | float)
|
|
||||||
and not isinstance(value, bool)
|
|
||||||
and math.isfinite(value)
|
|
||||||
and minimum < value <= maximum
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class WatchConfig:
|
|
||||||
spool: Path
|
|
||||||
max_image_bytes: int
|
|
||||||
max_queue_items: int
|
|
||||||
policy_version: str
|
|
||||||
max_restarts: int = 3
|
|
||||||
restart_backoff_seconds: float = 0.25
|
|
||||||
shutdown_timeout_seconds: float = 2.0
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
if not self.spool.is_absolute() or len(str(self.spool)) > 240:
|
|
||||||
raise ValueError("spool must be a bounded absolute path")
|
|
||||||
if not _integer(self.max_image_bytes, 8, 64 * 1024 * 1024):
|
|
||||||
raise ValueError("invalid image limit")
|
|
||||||
if not _integer(self.max_queue_items, 1, 256) or not _integer(self.max_restarts, 0, 10):
|
|
||||||
raise ValueError("invalid queue or restart limit")
|
|
||||||
if not _finite(self.restart_backoff_seconds, 0, 60) or not _finite(
|
|
||||||
self.shutdown_timeout_seconds, 0, 30
|
|
||||||
):
|
|
||||||
raise ValueError("invalid timeout")
|
|
||||||
if not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", self.policy_version):
|
|
||||||
raise ValueError("invalid policy version")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class FocusedWindowMetadata:
|
|
||||||
app_id: str | None
|
|
||||||
title: str | None
|
|
||||||
window_id: int | None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class IntakeReceipt:
|
|
||||||
occurrence_id: str
|
|
||||||
receipt_token: str
|
|
||||||
sha256: str
|
|
||||||
byte_size: int
|
|
||||||
media_type: str
|
|
||||||
spool_path: Path
|
|
||||||
policy_version: str
|
|
||||||
processing_version: str
|
|
||||||
focused_window: FocusedWindowMetadata
|
|
||||||
|
|
||||||
|
|
||||||
class VerifiedSpoolReader:
|
|
||||||
"""Descriptor-verified stream for handing one receipt to GDC-003 safely."""
|
|
||||||
|
|
||||||
def __init__(self, receipt: IntakeReceipt) -> None:
|
|
||||||
self.receipt = receipt
|
|
||||||
self._fd: int | None = None
|
|
||||||
|
|
||||||
def __enter__(self) -> VerifiedSpoolReader:
|
|
||||||
self._fd = _verify_spool_file(self.receipt)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *_: object) -> None:
|
|
||||||
self.close()
|
|
||||||
|
|
||||||
def __iter__(self) -> Iterator[bytes]:
|
|
||||||
if self._fd is None:
|
|
||||||
raise RuntimeError("verified reader is not open")
|
|
||||||
while chunk := os.read(self._fd, 64 * 1024):
|
|
||||||
yield chunk
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
if self._fd is not None:
|
|
||||||
os.close(self._fd)
|
|
||||||
self._fd = None
|
|
||||||
|
|
||||||
|
|
||||||
def restricted_environment(environment: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
||||||
source = os.environ if environment is None else environment
|
|
||||||
return {
|
|
||||||
key: value[:4096]
|
|
||||||
for key, value in source.items()
|
|
||||||
if key in _SAFE_ENV
|
|
||||||
and not _DENIED_ENV.search(key)
|
|
||||||
and isinstance(value, str)
|
|
||||||
and "\x00" not in value
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _bounded_text(value: object) -> str | None:
|
|
||||||
return " ".join(value.split())[:256] or None if isinstance(value, str) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _bounded_command_output(
|
|
||||||
arguments: list[str], environment: Mapping[str, str], timeout: float
|
|
||||||
) -> bytes | None:
|
|
||||||
try:
|
|
||||||
process = subprocess.Popen(
|
|
||||||
arguments,
|
|
||||||
shell=False,
|
|
||||||
env=environment,
|
|
||||||
stdin=subprocess.DEVNULL,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
start_new_session=True,
|
|
||||||
)
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
output = bytearray()
|
|
||||||
done = threading.Event()
|
|
||||||
|
|
||||||
def collect() -> None:
|
|
||||||
if process.stdout is not None:
|
|
||||||
while len(output) <= _MAX_METADATA:
|
|
||||||
chunk = process.stdout.read(min(4096, _MAX_METADATA + 1 - len(output)))
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
output.extend(chunk)
|
|
||||||
done.set()
|
|
||||||
|
|
||||||
reader = threading.Thread(target=collect, daemon=True)
|
|
||||||
reader.start()
|
|
||||||
if not done.wait(timeout):
|
|
||||||
with suppress(OSError):
|
|
||||||
os.killpg(process.pid, signal.SIGKILL)
|
|
||||||
with suppress(subprocess.TimeoutExpired):
|
|
||||||
process.wait(timeout=timeout)
|
|
||||||
if process.poll() is None:
|
|
||||||
with suppress(OSError):
|
|
||||||
os.killpg(process.pid, signal.SIGKILL)
|
|
||||||
process.wait()
|
|
||||||
if process.stdout is not None:
|
|
||||||
process.stdout.close()
|
|
||||||
reader.join(timeout)
|
|
||||||
return (
|
|
||||||
bytes(output)
|
|
||||||
if done.is_set() and process.returncode == 0 and len(output) <= _MAX_METADATA
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def focused_window(
|
|
||||||
*, environment: Mapping[str, str] | None = None, timeout_seconds: float = 0.5
|
|
||||||
) -> FocusedWindowMetadata:
|
|
||||||
if not _finite(timeout_seconds, 0, 30):
|
|
||||||
return FocusedWindowMetadata(None, None, None)
|
|
||||||
raw = _bounded_command_output(
|
|
||||||
["niri", "msg", "-j", "focused-window"],
|
|
||||||
restricted_environment(environment),
|
|
||||||
float(timeout_seconds),
|
|
||||||
)
|
|
||||||
if raw is None:
|
|
||||||
return FocusedWindowMetadata(None, None, None)
|
|
||||||
try:
|
|
||||||
payload = json.loads(raw)
|
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
||||||
return FocusedWindowMetadata(None, None, None)
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
return FocusedWindowMetadata(None, None, None)
|
|
||||||
identifier = payload.get("id")
|
|
||||||
return FocusedWindowMetadata(
|
|
||||||
_bounded_text(payload.get("app_id")),
|
|
||||||
_bounded_text(payload.get("title")),
|
|
||||||
identifier if _integer(identifier, 0, 2**63 - 1) else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _verify_spool_file(receipt: IntakeReceipt) -> int:
|
|
||||||
spool = receipt.spool_path.parent
|
|
||||||
if spool.is_symlink() or not spool.is_dir() or (spool.stat().st_mode & 0o777) != 0o700:
|
|
||||||
raise ValueError("unsafe spool")
|
|
||||||
fd = os.open(receipt.spool_path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
||||||
try:
|
|
||||||
info = os.fstat(fd)
|
|
||||||
if (
|
|
||||||
not stat.S_ISREG(info.st_mode)
|
|
||||||
or (info.st_mode & 0o777) != 0o600
|
|
||||||
or info.st_size != receipt.byte_size
|
|
||||||
):
|
|
||||||
raise ValueError("invalid spool file")
|
|
||||||
first = os.read(fd, len(PNG_SIGNATURE))
|
|
||||||
if first != PNG_SIGNATURE:
|
|
||||||
raise ValueError("invalid spool png")
|
|
||||||
hasher = hashlib.sha256(first)
|
|
||||||
while chunk := os.read(fd, 64 * 1024):
|
|
||||||
hasher.update(chunk)
|
|
||||||
if hasher.hexdigest() != receipt.sha256:
|
|
||||||
raise ValueError("spool digest mismatch")
|
|
||||||
os.lseek(fd, 0, os.SEEK_SET)
|
|
||||||
return fd
|
|
||||||
except Exception:
|
|
||||||
os.close(fd)
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
class ClipboardIntake:
|
|
||||||
def __init__(
|
|
||||||
self, config: WatchConfig, *, environment: Mapping[str, str] | None = None
|
|
||||||
) -> None:
|
|
||||||
self.config, self.environment = config, restricted_environment(environment)
|
|
||||||
self.armed = self.paused = False
|
|
||||||
self._watcher: subprocess.Popen[bytes] | None = None
|
|
||||||
self._reader: threading.Thread | None = None
|
|
||||||
self._queue: deque[IntakeReceipt] = deque()
|
|
||||||
self._helper_payloads: deque[dict[str, object]] = deque()
|
|
||||||
self._seen_tokens: set[str] = set()
|
|
||||||
self._seen_order: deque[str] = deque()
|
|
||||||
self._recovered_tokens: set[str] = set()
|
|
||||||
self._restart_count = 0
|
|
||||||
self._restart_after = 0.0
|
|
||||||
self._restarts_exhausted = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def queue_depth(self) -> int:
|
|
||||||
return len(self._queue)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def helper_queue_depth(self) -> int:
|
|
||||||
return len(self._helper_payloads)
|
|
||||||
|
|
||||||
def arm(self) -> None:
|
|
||||||
self.armed, self.paused = True, False
|
|
||||||
self._start_if_due(time.monotonic())
|
|
||||||
|
|
||||||
def pause(self) -> None:
|
|
||||||
self.paused = True
|
|
||||||
self._stop_watcher()
|
|
||||||
|
|
||||||
def resume(self) -> None:
|
|
||||||
if self.armed:
|
|
||||||
self.paused = False
|
|
||||||
self._start_if_due(time.monotonic())
|
|
||||||
|
|
||||||
def disarm(self) -> None:
|
|
||||||
self.armed = self.paused = False
|
|
||||||
self._stop_watcher()
|
|
||||||
|
|
||||||
def cancel(self) -> None:
|
|
||||||
self.shutdown()
|
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
|
||||||
self.disarm()
|
|
||||||
self._queue.clear()
|
|
||||||
self._helper_payloads.clear()
|
|
||||||
self._seen_tokens.clear()
|
|
||||||
self._seen_order.clear()
|
|
||||||
self._recovered_tokens.clear()
|
|
||||||
|
|
||||||
def tick(self, now: float | None = None) -> None:
|
|
||||||
current = time.monotonic() if now is None else now
|
|
||||||
if self._watcher is not None and self._watcher.poll() is not None:
|
|
||||||
self._stop_watcher()
|
|
||||||
self._crashed(current)
|
|
||||||
self._start_if_due(current)
|
|
||||||
|
|
||||||
def take(self) -> IntakeReceipt | None:
|
|
||||||
return self._queue.popleft() if self._queue else None
|
|
||||||
|
|
||||||
def take_helper_receipt(self) -> dict[str, object] | None:
|
|
||||||
return self._helper_payloads.popleft() if self._helper_payloads else None
|
|
||||||
|
|
||||||
def unreferenced_spool_candidates(self) -> list[Path]:
|
|
||||||
queued = {item.spool_path.name for item in self._queue}
|
|
||||||
return [
|
|
||||||
path
|
|
||||||
for path in self.config.spool.glob("*.png")
|
|
||||||
if not path.is_symlink() and path.name not in queued
|
|
||||||
]
|
|
||||||
|
|
||||||
def open_verified(self, receipt: IntakeReceipt) -> VerifiedSpoolReader:
|
|
||||||
return VerifiedSpoolReader(receipt)
|
|
||||||
|
|
||||||
def accept_helper_receipt(
|
|
||||||
self,
|
|
||||||
payload: Mapping[str, object],
|
|
||||||
*,
|
|
||||||
occurrence_id: str,
|
|
||||||
processing_version: str,
|
|
||||||
metadata: FocusedWindowMetadata | None = None,
|
|
||||||
) -> bool:
|
|
||||||
token, digest, size, name = (
|
|
||||||
payload.get("receipt_token"),
|
|
||||||
payload.get("sha256"),
|
|
||||||
payload.get("byte_size"),
|
|
||||||
payload.get("spool_name"),
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
not self.armed
|
|
||||||
or self.paused
|
|
||||||
or len(self._queue) >= self.config.max_queue_items
|
|
||||||
or payload.get("receipt_version") != 1
|
|
||||||
or not isinstance(token, str)
|
|
||||||
or not _ID.fullmatch(token)
|
|
||||||
or token in self._seen_tokens
|
|
||||||
or not isinstance(digest, str)
|
|
||||||
or not _SHA256.fullmatch(digest)
|
|
||||||
or not _integer(size, 8, self.config.max_image_bytes)
|
|
||||||
or payload.get("media_type") != "image/png"
|
|
||||||
or payload.get("policy_version") != self.config.policy_version
|
|
||||||
or not isinstance(name, str)
|
|
||||||
or name != f"{digest}.png"
|
|
||||||
or not isinstance(occurrence_id, str)
|
|
||||||
or not _ID.fullmatch(occurrence_id)
|
|
||||||
or not isinstance(processing_version, str)
|
|
||||||
or not _ID.fullmatch(processing_version)
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
recovered = token in self._recovered_tokens
|
|
||||||
receipt = IntakeReceipt(
|
|
||||||
occurrence_id,
|
|
||||||
token,
|
|
||||||
digest,
|
|
||||||
cast(int, size),
|
|
||||||
"image/png",
|
|
||||||
self.config.spool / name,
|
|
||||||
self.config.policy_version,
|
|
||||||
processing_version,
|
|
||||||
FocusedWindowMetadata(None, None, None)
|
|
||||||
if recovered
|
|
||||||
else focused_window()
|
|
||||||
if metadata is None
|
|
||||||
else metadata,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
verified_fd = _verify_spool_file(receipt)
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return False
|
|
||||||
os.close(verified_fd)
|
|
||||||
self._seen_tokens.add(token)
|
|
||||||
self._seen_order.append(token)
|
|
||||||
if len(self._seen_order) > self.config.max_queue_items:
|
|
||||||
self._seen_tokens.remove(self._seen_order.popleft())
|
|
||||||
self._queue.append(receipt)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def reconcile(self) -> list[str]:
|
|
||||||
removed = reconcile_spool(self.config.spool)
|
|
||||||
capacity = self.config.max_queue_items - len(self._helper_payloads)
|
|
||||||
if capacity <= 0:
|
|
||||||
return removed
|
|
||||||
for payload in durable_receipts(self.config.spool, max_items=256):
|
|
||||||
if len(self._helper_payloads) >= self.config.max_queue_items:
|
|
||||||
break
|
|
||||||
token = payload["receipt_token"]
|
|
||||||
if isinstance(token, str) and token not in self._seen_tokens:
|
|
||||||
self._helper_payloads.append(payload)
|
|
||||||
self._recovered_tokens.add(token)
|
|
||||||
return removed
|
|
||||||
|
|
||||||
def acknowledge(self, receipt: IntakeReceipt) -> bool:
|
|
||||||
"""Delete this occurrence's durable receipt after repository confirmation only."""
|
|
||||||
if (
|
|
||||||
receipt.spool_path.parent != self.config.spool
|
|
||||||
or receipt.policy_version != self.config.policy_version
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
payload: dict[str, object] = {
|
|
||||||
"receipt_version": 1,
|
|
||||||
"receipt_token": receipt.receipt_token,
|
|
||||||
"sha256": receipt.sha256,
|
|
||||||
"byte_size": receipt.byte_size,
|
|
||||||
"media_type": receipt.media_type,
|
|
||||||
"spool_name": receipt.spool_path.name,
|
|
||||||
"policy_version": receipt.policy_version,
|
|
||||||
}
|
|
||||||
acknowledged = acknowledge_receipt(self.config.spool, payload)
|
|
||||||
if acknowledged:
|
|
||||||
self._recovered_tokens.discard(receipt.receipt_token)
|
|
||||||
return acknowledged
|
|
||||||
|
|
||||||
def _crashed(self, now: float) -> None:
|
|
||||||
if self.armed and not self.paused and self._restart_count < self.config.max_restarts:
|
|
||||||
self._restart_count += 1
|
|
||||||
self._restart_after = now + float(self.config.restart_backoff_seconds) * 2 ** (
|
|
||||||
self._restart_count - 1
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self._restarts_exhausted = True
|
|
||||||
|
|
||||||
def _start_if_due(self, now: float) -> None:
|
|
||||||
if (
|
|
||||||
not self.armed
|
|
||||||
or self.paused
|
|
||||||
or self._watcher is not None
|
|
||||||
or self._restarts_exhausted
|
|
||||||
or now < self._restart_after
|
|
||||||
):
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
self._watcher = subprocess.Popen(
|
|
||||||
[
|
|
||||||
"wl-paste",
|
|
||||||
"--type",
|
|
||||||
"image/png",
|
|
||||||
"--watch",
|
|
||||||
sys.executable,
|
|
||||||
"-m",
|
|
||||||
"game_data_capture.ingest.spool_helper",
|
|
||||||
"--spool",
|
|
||||||
str(self.config.spool),
|
|
||||||
"--max-bytes",
|
|
||||||
str(self.config.max_image_bytes),
|
|
||||||
"--policy-version",
|
|
||||||
self.config.policy_version,
|
|
||||||
"--media-type",
|
|
||||||
"image/png",
|
|
||||||
],
|
|
||||||
shell=False,
|
|
||||||
env=self.environment,
|
|
||||||
stdin=subprocess.DEVNULL,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
start_new_session=True,
|
|
||||||
)
|
|
||||||
except OSError:
|
|
||||||
self._crashed(now)
|
|
||||||
return
|
|
||||||
self._reader = threading.Thread(
|
|
||||||
target=self._read_receipts, args=(self._watcher,), daemon=True
|
|
||||||
)
|
|
||||||
self._reader.start()
|
|
||||||
|
|
||||||
def _read_receipts(self, process: subprocess.Popen[bytes]) -> None:
|
|
||||||
if process.stdout is None:
|
|
||||||
return
|
|
||||||
while True:
|
|
||||||
line = process.stdout.readline(_MAX_RECEIPT + 1)
|
|
||||||
if not line:
|
|
||||||
return
|
|
||||||
if len(line) > _MAX_RECEIPT and not line.endswith(b"\n"):
|
|
||||||
while line and not line.endswith(b"\n"):
|
|
||||||
line = process.stdout.readline(_MAX_RECEIPT + 1)
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
payload = json.loads(line)
|
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
||||||
continue
|
|
||||||
if (
|
|
||||||
isinstance(payload, dict)
|
|
||||||
and all(isinstance(key, str) for key in payload)
|
|
||||||
and len(self._helper_payloads) < self.config.max_queue_items
|
|
||||||
):
|
|
||||||
self._helper_payloads.append(payload)
|
|
||||||
|
|
||||||
def _stop_watcher(self) -> None:
|
|
||||||
process, self._watcher = self._watcher, None
|
|
||||||
if process is None:
|
|
||||||
return
|
|
||||||
if process.poll() is None:
|
|
||||||
with suppress(OSError):
|
|
||||||
os.killpg(process.pid, signal.SIGTERM)
|
|
||||||
try:
|
|
||||||
process.wait(timeout=float(self.config.shutdown_timeout_seconds))
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
with suppress(OSError):
|
|
||||||
os.killpg(process.pid, signal.SIGKILL)
|
|
||||||
process.wait()
|
|
||||||
if process.stdout is not None:
|
|
||||||
process.stdout.close()
|
|
||||||
if self._reader is not None:
|
|
||||||
self._reader.join(timeout=float(self.config.shutdown_timeout_seconds))
|
|
||||||
self._reader = None
|
|
||||||
@@ -1,486 +0,0 @@
|
|||||||
"""Small stdin-only PNG spool helper; intentionally independent of application services."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import fcntl
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import secrets
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from collections.abc import Iterator
|
|
||||||
from contextlib import contextmanager, suppress
|
|
||||||
from pathlib import Path
|
|
||||||
from stat import S_ISDIR, S_ISREG
|
|
||||||
|
|
||||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
||||||
RECEIPT_VERSION = 1
|
|
||||||
_TOKEN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
|
||||||
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
||||||
_CHUNK_SIZE = 64 * 1024
|
|
||||||
_MAX_RECEIPT_BYTES = 1024
|
|
||||||
_MAX_SIDECARS = 256
|
|
||||||
_MAX_RECONCILE_ENTRIES = 1024
|
|
||||||
_LOCK_NAME = ".spool.lock"
|
|
||||||
_LOCK_TIMEOUT_SECONDS = 1.0
|
|
||||||
_LOCK_RETRY_SECONDS = 0.01
|
|
||||||
|
|
||||||
|
|
||||||
class SpoolError(ValueError):
|
|
||||||
"""The supplied image or spool boundary is invalid."""
|
|
||||||
|
|
||||||
|
|
||||||
def _private_spool(path: Path) -> None:
|
|
||||||
if not path.is_absolute() or len(str(path)) > 240:
|
|
||||||
raise SpoolError("invalid spool")
|
|
||||||
if path.is_symlink():
|
|
||||||
raise SpoolError("unsafe spool")
|
|
||||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
||||||
mode = path.lstat().st_mode
|
|
||||||
if path.is_symlink() or not S_ISDIR(mode):
|
|
||||||
raise SpoolError("unsafe spool")
|
|
||||||
path.chmod(0o700)
|
|
||||||
|
|
||||||
|
|
||||||
def _write_all(fd: int, data: bytes) -> None:
|
|
||||||
view = memoryview(data)
|
|
||||||
while view:
|
|
||||||
written = os.write(fd, view)
|
|
||||||
if written <= 0:
|
|
||||||
raise OSError("short spool write")
|
|
||||||
view = view[written:]
|
|
||||||
|
|
||||||
|
|
||||||
def _fsync_directory(path: Path) -> None:
|
|
||||||
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
||||||
try:
|
|
||||||
os.fsync(fd)
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def _spool_lock(spool: Path) -> Iterator[None]:
|
|
||||||
"""Hold the private spool lock for one bounded receipt installation."""
|
|
||||||
try:
|
|
||||||
fd = os.open(spool / _LOCK_NAME, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
|
||||||
except OSError as error:
|
|
||||||
raise SpoolError("unsafe spool lock") from error
|
|
||||||
try:
|
|
||||||
info = os.fstat(fd)
|
|
||||||
if not S_ISREG(info.st_mode) or (info.st_mode & 0o777) != 0o600:
|
|
||||||
raise SpoolError("unsafe spool lock")
|
|
||||||
deadline = time.monotonic() + _LOCK_TIMEOUT_SECONDS
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
||||||
break
|
|
||||||
except BlockingIOError:
|
|
||||||
if time.monotonic() >= deadline:
|
|
||||||
raise SpoolError("spool busy") from None
|
|
||||||
time.sleep(_LOCK_RETRY_SECONDS)
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
with suppress(OSError):
|
|
||||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
||||||
os.close(fd)
|
|
||||||
|
|
||||||
|
|
||||||
def _verify_existing(path: Path, digest: str, size: int) -> bool:
|
|
||||||
try:
|
|
||||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
info = os.fstat(fd)
|
|
||||||
if not S_ISREG(info.st_mode) or (info.st_mode & 0o777) != 0o600 or info.st_size != size:
|
|
||||||
return False
|
|
||||||
hasher = hashlib.sha256()
|
|
||||||
prefix = os.read(fd, len(PNG_SIGNATURE))
|
|
||||||
if prefix != PNG_SIGNATURE:
|
|
||||||
return False
|
|
||||||
hasher.update(prefix)
|
|
||||||
while chunk := os.read(fd, _CHUNK_SIZE):
|
|
||||||
hasher.update(chunk)
|
|
||||||
return hasher.hexdigest() == digest
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
|
|
||||||
|
|
||||||
def receipt_name(token: str) -> str:
|
|
||||||
if not _TOKEN.fullmatch(token):
|
|
||||||
raise SpoolError("invalid receipt identity")
|
|
||||||
return f"{token}.receipt.json"
|
|
||||||
|
|
||||||
|
|
||||||
def _pending_name(token: str) -> str:
|
|
||||||
return f".{token}.receipt.pending"
|
|
||||||
|
|
||||||
|
|
||||||
def _receipt_bytes(receipt: dict[str, object]) -> bytes:
|
|
||||||
encoded = json.dumps(receipt, separators=(",", ":"), sort_keys=True).encode("ascii")
|
|
||||||
if len(encoded) > _MAX_RECEIPT_BYTES:
|
|
||||||
raise SpoolError("receipt exceeds limit")
|
|
||||||
return encoded
|
|
||||||
|
|
||||||
|
|
||||||
def _valid_receipt(payload: object, *, spool: Path, verify_png: bool) -> dict[str, object] | None:
|
|
||||||
if not isinstance(payload, dict) or set(payload) != {
|
|
||||||
"byte_size",
|
|
||||||
"media_type",
|
|
||||||
"policy_version",
|
|
||||||
"receipt_token",
|
|
||||||
"receipt_version",
|
|
||||||
"sha256",
|
|
||||||
"spool_name",
|
|
||||||
}:
|
|
||||||
return None
|
|
||||||
token, digest, size, name = (
|
|
||||||
payload.get("receipt_token"),
|
|
||||||
payload.get("sha256"),
|
|
||||||
payload.get("byte_size"),
|
|
||||||
payload.get("spool_name"),
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
payload.get("receipt_version") != RECEIPT_VERSION
|
|
||||||
or not isinstance(token, str)
|
|
||||||
or not _TOKEN.fullmatch(token)
|
|
||||||
or not isinstance(digest, str)
|
|
||||||
or not _SHA256.fullmatch(digest)
|
|
||||||
or isinstance(size, bool)
|
|
||||||
or not isinstance(size, int)
|
|
||||||
or size < len(PNG_SIGNATURE)
|
|
||||||
or payload.get("media_type") != "image/png"
|
|
||||||
or not isinstance(payload.get("policy_version"), str)
|
|
||||||
or not _TOKEN.fullmatch(payload["policy_version"])
|
|
||||||
or not isinstance(name, str)
|
|
||||||
or name != f"{digest}.png"
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
if verify_png and not _verify_existing(spool / name, digest, size):
|
|
||||||
return None
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _read_sidecar(path: Path, *, spool: Path, verify_png: bool) -> dict[str, object] | None:
|
|
||||||
try:
|
|
||||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
info = os.fstat(fd)
|
|
||||||
if (
|
|
||||||
not S_ISREG(info.st_mode)
|
|
||||||
or (info.st_mode & 0o777) != 0o600
|
|
||||||
or info.st_size > _MAX_RECEIPT_BYTES
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
raw = os.read(fd, _MAX_RECEIPT_BYTES + 1)
|
|
||||||
if len(raw) != info.st_size or len(raw) > _MAX_RECEIPT_BYTES:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return _valid_receipt(json.loads(raw), spool=spool, verify_png=verify_png)
|
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
|
|
||||||
|
|
||||||
def _install_pending_sidecar(spool: Path, receipt: dict[str, object]) -> Path:
|
|
||||||
token = receipt["receipt_token"]
|
|
||||||
assert isinstance(token, str)
|
|
||||||
pending = spool / _pending_name(token)
|
|
||||||
encoded = _receipt_bytes(receipt)
|
|
||||||
fd = os.open(pending, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
||||||
try:
|
|
||||||
_write_all(fd, encoded)
|
|
||||||
os.fsync(fd)
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
# Persist the recoverable pending name before promotion. A crash after this
|
|
||||||
# point cannot claim recovery until its PNG is immutably installed.
|
|
||||||
_fsync_directory(spool)
|
|
||||||
return pending
|
|
||||||
|
|
||||||
|
|
||||||
def _same_receipt(left: dict[str, object], right: dict[str, object]) -> bool:
|
|
||||||
return _receipt_bytes(left) == _receipt_bytes(right)
|
|
||||||
|
|
||||||
|
|
||||||
def _final_sidecar(spool: Path, receipt: dict[str, object]) -> dict[str, object] | None:
|
|
||||||
token = receipt["receipt_token"]
|
|
||||||
assert isinstance(token, str)
|
|
||||||
final = spool / receipt_name(token)
|
|
||||||
if not final.exists() and not final.is_symlink():
|
|
||||||
return None
|
|
||||||
stored = _read_sidecar(final, spool=spool, verify_png=True)
|
|
||||||
if stored is None or not _same_receipt(stored, receipt):
|
|
||||||
raise SpoolError("receipt token conflict")
|
|
||||||
return stored
|
|
||||||
|
|
||||||
|
|
||||||
def _receipt_capacity_available(spool: Path) -> bool:
|
|
||||||
"""Count bounded valid receipt sidecars while removing malformed regular entries."""
|
|
||||||
valid = 0
|
|
||||||
candidates = 0
|
|
||||||
for entry in spool.iterdir():
|
|
||||||
if entry.is_symlink() or not entry.is_file():
|
|
||||||
continue
|
|
||||||
pending = entry.name.startswith(".") and entry.name.endswith(".receipt.pending")
|
|
||||||
final = entry.name.endswith(".receipt.json")
|
|
||||||
if not pending and not final:
|
|
||||||
continue
|
|
||||||
candidates += 1
|
|
||||||
if candidates > _MAX_RECONCILE_ENTRIES:
|
|
||||||
return False
|
|
||||||
token = (
|
|
||||||
entry.name[1 : -len(".receipt.pending")]
|
|
||||||
if pending
|
|
||||||
else entry.name[: -len(".receipt.json")]
|
|
||||||
)
|
|
||||||
receipt = _read_sidecar(entry, spool=spool, verify_png=final)
|
|
||||||
if receipt is None or receipt.get("receipt_token") != token:
|
|
||||||
entry.unlink()
|
|
||||||
_fsync_directory(spool)
|
|
||||||
continue
|
|
||||||
valid += 1
|
|
||||||
if valid >= _MAX_SIDECARS:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _promote_sidecar(spool: Path, receipt: dict[str, object], pending: Path) -> None:
|
|
||||||
token = receipt["receipt_token"]
|
|
||||||
assert isinstance(token, str)
|
|
||||||
final = spool / receipt_name(token)
|
|
||||||
try:
|
|
||||||
os.link(pending, final, follow_symlinks=False)
|
|
||||||
except FileExistsError:
|
|
||||||
try:
|
|
||||||
_final_sidecar(spool, receipt)
|
|
||||||
except SpoolError:
|
|
||||||
pending.unlink()
|
|
||||||
_fsync_directory(spool)
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
_fsync_directory(spool)
|
|
||||||
pending.unlink()
|
|
||||||
_fsync_directory(spool)
|
|
||||||
|
|
||||||
|
|
||||||
def reconcile_spool(spool: Path) -> list[str]:
|
|
||||||
"""Remove incomplete files and promote only receipt sidecars backed by immutable PNGs."""
|
|
||||||
_private_spool(spool)
|
|
||||||
removed: list[str] = []
|
|
||||||
promoted = 0
|
|
||||||
candidates = 0
|
|
||||||
for entry in spool.iterdir():
|
|
||||||
if entry.is_symlink() or not entry.is_file():
|
|
||||||
continue
|
|
||||||
if entry.name.endswith(".partial"):
|
|
||||||
candidates += 1
|
|
||||||
if candidates > _MAX_RECONCILE_ENTRIES:
|
|
||||||
break
|
|
||||||
entry.unlink()
|
|
||||||
removed.append(entry.name)
|
|
||||||
elif entry.name.startswith(".") and entry.name.endswith(".receipt.pending"):
|
|
||||||
candidates += 1
|
|
||||||
if candidates > _MAX_RECONCILE_ENTRIES:
|
|
||||||
break
|
|
||||||
receipt = _read_sidecar(entry, spool=spool, verify_png=True)
|
|
||||||
token = entry.name[1 : -len(".receipt.pending")]
|
|
||||||
if receipt is not None and receipt.get("receipt_token") == token:
|
|
||||||
final = spool / receipt_name(token)
|
|
||||||
if final.exists() or final.is_symlink():
|
|
||||||
with suppress(SpoolError):
|
|
||||||
_final_sidecar(spool, receipt)
|
|
||||||
entry.unlink()
|
|
||||||
removed.append(entry.name)
|
|
||||||
elif promoted >= _MAX_SIDECARS:
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
_promote_sidecar(spool, receipt, entry)
|
|
||||||
promoted += 1
|
|
||||||
continue
|
|
||||||
entry.unlink()
|
|
||||||
removed.append(entry.name)
|
|
||||||
return removed
|
|
||||||
|
|
||||||
|
|
||||||
def durable_receipts(spool: Path, *, max_items: int) -> Iterator[dict[str, object]]:
|
|
||||||
"""Yield at most ``max_items`` validated final receipt sidecars without following links."""
|
|
||||||
if (
|
|
||||||
isinstance(max_items, bool)
|
|
||||||
or not isinstance(max_items, int)
|
|
||||||
or not 1 <= max_items <= _MAX_SIDECARS
|
|
||||||
):
|
|
||||||
raise SpoolError("invalid receipt limit")
|
|
||||||
_private_spool(spool)
|
|
||||||
yielded = 0
|
|
||||||
valid = 0
|
|
||||||
candidates = 0
|
|
||||||
for entry in spool.iterdir():
|
|
||||||
if entry.is_symlink() or not entry.is_file() or not entry.name.endswith(".receipt.json"):
|
|
||||||
continue
|
|
||||||
candidates += 1
|
|
||||||
if candidates > _MAX_RECONCILE_ENTRIES:
|
|
||||||
break
|
|
||||||
token = entry.name[: -len(".receipt.json")]
|
|
||||||
receipt = _read_sidecar(entry, spool=spool, verify_png=True)
|
|
||||||
if receipt is not None and receipt.get("receipt_token") == token:
|
|
||||||
valid += 1
|
|
||||||
if valid > _MAX_SIDECARS:
|
|
||||||
break
|
|
||||||
if yielded < max_items:
|
|
||||||
yielded += 1
|
|
||||||
yield receipt
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
info = entry.lstat()
|
|
||||||
if S_ISREG(info.st_mode):
|
|
||||||
entry.unlink()
|
|
||||||
_fsync_directory(spool)
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
def acknowledge_receipt(spool: Path, receipt: dict[str, object]) -> bool:
|
|
||||||
"""Remove exactly one verified durable sidecar; immutable PNG data is never removed here."""
|
|
||||||
_private_spool(spool)
|
|
||||||
token = receipt.get("receipt_token")
|
|
||||||
if not isinstance(token, str) or not _TOKEN.fullmatch(token):
|
|
||||||
return False
|
|
||||||
path = spool / receipt_name(token)
|
|
||||||
stored = _read_sidecar(path, spool=spool, verify_png=True)
|
|
||||||
if stored is None:
|
|
||||||
return False
|
|
||||||
if _receipt_bytes(stored) != _receipt_bytes(receipt):
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
info = path.lstat()
|
|
||||||
if not S_ISREG(info.st_mode) or (info.st_mode & 0o777) != 0o600:
|
|
||||||
return False
|
|
||||||
path.unlink()
|
|
||||||
_fsync_directory(spool)
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def spool_png(
|
|
||||||
input_stream: object,
|
|
||||||
*,
|
|
||||||
spool: Path,
|
|
||||||
max_bytes: int,
|
|
||||||
receipt_token: str,
|
|
||||||
policy_version: str,
|
|
||||||
media_type: str = "image/png",
|
|
||||||
) -> dict[str, object]:
|
|
||||||
"""Install immutable PNG bytes, then fsync a durable occurrence receipt before stdout."""
|
|
||||||
if (
|
|
||||||
isinstance(max_bytes, bool)
|
|
||||||
or not isinstance(max_bytes, int)
|
|
||||||
or not 8 <= max_bytes <= 64 * 1024 * 1024
|
|
||||||
):
|
|
||||||
raise SpoolError("invalid byte limit")
|
|
||||||
if (
|
|
||||||
media_type != "image/png"
|
|
||||||
or not _TOKEN.fullmatch(receipt_token)
|
|
||||||
or not _TOKEN.fullmatch(policy_version)
|
|
||||||
):
|
|
||||||
raise SpoolError("invalid receipt identity")
|
|
||||||
_private_spool(spool)
|
|
||||||
temporary = spool / f".{secrets.token_hex(16)}.partial"
|
|
||||||
pending: Path | None = None
|
|
||||||
immutable_installed = False
|
|
||||||
hasher = hashlib.sha256()
|
|
||||||
size = 0
|
|
||||||
try:
|
|
||||||
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
||||||
try:
|
|
||||||
prefix = bytearray()
|
|
||||||
while True:
|
|
||||||
chunk = input_stream.read(_CHUNK_SIZE) # type: ignore[attr-defined]
|
|
||||||
if not isinstance(chunk, bytes):
|
|
||||||
raise SpoolError("invalid input stream")
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
prefix.extend(chunk[: max(0, len(PNG_SIGNATURE) - len(prefix))])
|
|
||||||
size += len(chunk)
|
|
||||||
if size > max_bytes:
|
|
||||||
raise SpoolError("image exceeds byte limit")
|
|
||||||
hasher.update(chunk)
|
|
||||||
_write_all(fd, chunk)
|
|
||||||
if len(prefix) < len(PNG_SIGNATURE) or bytes(prefix) != PNG_SIGNATURE:
|
|
||||||
raise SpoolError("invalid png")
|
|
||||||
os.fsync(fd)
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
digest = hasher.hexdigest()
|
|
||||||
name = f"{digest}.png"
|
|
||||||
receipt: dict[str, object] = {
|
|
||||||
"receipt_version": RECEIPT_VERSION,
|
|
||||||
"receipt_token": receipt_token,
|
|
||||||
"sha256": digest,
|
|
||||||
"byte_size": size,
|
|
||||||
"media_type": "image/png",
|
|
||||||
"spool_name": name,
|
|
||||||
"policy_version": policy_version,
|
|
||||||
}
|
|
||||||
with _spool_lock(spool):
|
|
||||||
if _final_sidecar(spool, receipt) is not None:
|
|
||||||
return receipt
|
|
||||||
if not _receipt_capacity_available(spool):
|
|
||||||
raise SpoolError("spool receipt capacity reached")
|
|
||||||
pending = _install_pending_sidecar(spool, receipt)
|
|
||||||
destination = spool / name
|
|
||||||
if destination.exists():
|
|
||||||
if not _verify_existing(destination, digest, size):
|
|
||||||
raise SpoolError("spool collision")
|
|
||||||
else:
|
|
||||||
os.replace(temporary, destination)
|
|
||||||
os.chmod(destination, 0o600)
|
|
||||||
_fsync_directory(spool)
|
|
||||||
immutable_installed = True
|
|
||||||
_promote_sidecar(spool, receipt, pending)
|
|
||||||
return receipt
|
|
||||||
finally:
|
|
||||||
if temporary.exists() and not temporary.is_symlink():
|
|
||||||
temporary.unlink()
|
|
||||||
if (
|
|
||||||
pending is not None
|
|
||||||
and not immutable_installed
|
|
||||||
and pending.exists()
|
|
||||||
and not pending.is_symlink()
|
|
||||||
):
|
|
||||||
pending.unlink()
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
parser = argparse.ArgumentParser(add_help=False)
|
|
||||||
parser.add_argument("--spool", required=True)
|
|
||||||
parser.add_argument("--max-bytes", required=True, type=int)
|
|
||||||
parser.add_argument("--receipt-token", default=secrets.token_hex(16))
|
|
||||||
parser.add_argument("--policy-version", required=True)
|
|
||||||
parser.add_argument("--media-type", default="image/png")
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
try:
|
|
||||||
receipt = spool_png(
|
|
||||||
sys.stdin.buffer,
|
|
||||||
spool=Path(args.spool),
|
|
||||||
max_bytes=args.max_bytes,
|
|
||||||
receipt_token=args.receipt_token,
|
|
||||||
policy_version=args.policy_version,
|
|
||||||
media_type=args.media_type,
|
|
||||||
)
|
|
||||||
except (OSError, SpoolError):
|
|
||||||
return 2
|
|
||||||
sys.stdout.write(json.dumps(receipt, separators=(",", ":")) + "\n")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
"""Structured logging helpers that remove sensitive capture material."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
_SENSITIVE_FIELD = re.compile(
|
|
||||||
r"(?:api[_-]?key|authorization|auth[_-]?header|token|secret|password|"
|
|
||||||
r"image|payload|base64|data[_-]?url|ocr[_-]?text|recognized[_-]?text)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
_AUTHORIZATION_VALUE = re.compile(r"(?i)(authorization\s*[:=]\s*)(?:bearer\s+)?\S+")
|
|
||||||
_KEY_VALUE = re.compile(r"(?i)((?:api[_-]?key|token|secret|password)\s*[:=]\s*)\S+")
|
|
||||||
_OPENAI_KEY = re.compile(r"\bsk-[A-Za-z0-9_-]{8,}\b")
|
|
||||||
|
|
||||||
|
|
||||||
def redact_text(value: str) -> str:
|
|
||||||
"""Remove common secret-bearing fragments from a log message."""
|
|
||||||
|
|
||||||
value = _AUTHORIZATION_VALUE.sub(r"\1[REDACTED]", value)
|
|
||||||
value = _KEY_VALUE.sub(r"\1[REDACTED]", value)
|
|
||||||
return _OPENAI_KEY.sub("[REDACTED]", value)
|
|
||||||
|
|
||||||
|
|
||||||
def redact_log_data(value: object, field_name: str | None = None) -> object:
|
|
||||||
"""Redact secrets, image data, and unbounded OCR text from structured fields."""
|
|
||||||
|
|
||||||
if field_name is not None and _SENSITIVE_FIELD.search(field_name):
|
|
||||||
return "[REDACTED]"
|
|
||||||
if isinstance(value, Mapping):
|
|
||||||
return {str(key): redact_log_data(item, str(key)) for key, item in value.items()}
|
|
||||||
if isinstance(value, list | tuple | set | frozenset):
|
|
||||||
return [redact_log_data(item) for item in value]
|
|
||||||
if isinstance(value, str):
|
|
||||||
return redact_text(value[:512])
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class RedactingFormatter(logging.Formatter):
|
|
||||||
"""Formatter for static messages plus redacted structured event data."""
|
|
||||||
|
|
||||||
def format(self, record: logging.LogRecord) -> str:
|
|
||||||
original_message = record.msg
|
|
||||||
original_args = record.args
|
|
||||||
original_exception = record.exc_info
|
|
||||||
original_exception_text = record.exc_text
|
|
||||||
original_stack = record.stack_info
|
|
||||||
try:
|
|
||||||
if isinstance(record.msg, str):
|
|
||||||
record.msg = redact_text(record.msg)
|
|
||||||
if isinstance(record.args, Mapping):
|
|
||||||
record.args = {
|
|
||||||
str(key): redact_log_data(value, str(key)) for key, value in record.args.items()
|
|
||||||
}
|
|
||||||
elif record.args:
|
|
||||||
record.args = tuple(redact_log_data(item) for item in record.args)
|
|
||||||
record.exc_info = None
|
|
||||||
record.exc_text = None
|
|
||||||
record.stack_info = None
|
|
||||||
output = redact_text(super().format(record))
|
|
||||||
if original_exception is not None or original_exception_text is not None:
|
|
||||||
return f"{output}\n[REDACTED EXCEPTION]"
|
|
||||||
return output
|
|
||||||
finally:
|
|
||||||
record.msg = original_message
|
|
||||||
record.args = original_args
|
|
||||||
record.exc_info = original_exception
|
|
||||||
record.exc_text = original_exception_text
|
|
||||||
record.stack_info = original_stack
|
|
||||||
|
|
||||||
|
|
||||||
def configure_logging(level: int = logging.INFO) -> logging.Logger:
|
|
||||||
"""Create an isolated logger that writes redacted records to stderr."""
|
|
||||||
|
|
||||||
logger = logging.getLogger("game_data_capture")
|
|
||||||
logger.setLevel(level)
|
|
||||||
logger.propagate = False
|
|
||||||
for handler in logger.handlers[:]:
|
|
||||||
logger.removeHandler(handler)
|
|
||||||
handler = logging.StreamHandler()
|
|
||||||
handler.setFormatter(RedactingFormatter("%(levelname)s %(message)s"))
|
|
||||||
logger.addHandler(handler)
|
|
||||||
return logger
|
|
||||||
|
|
||||||
|
|
||||||
def log_event(logger: logging.Logger, event: str, **fields: Any) -> None:
|
|
||||||
"""Log a stable event name and scrubbed structured fields.
|
|
||||||
|
|
||||||
Callers must keep event names static and place external text in named fields.
|
|
||||||
"""
|
|
||||||
|
|
||||||
logger.info("%s %s", event, redact_log_data(fields))
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
"""XDG path resolution and private-directory preparation.
|
|
||||||
|
|
||||||
Path resolution is pure. Call :func:`prepare_private_directories` only at a runtime
|
|
||||||
boundary that is allowed to create the utility's private directories.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from os import environ
|
|
||||||
from pathlib import Path
|
|
||||||
from stat import S_ISDIR, S_ISLNK, S_ISREG
|
|
||||||
|
|
||||||
|
|
||||||
class PathConfigurationError(ValueError):
|
|
||||||
"""A configured path violates the local runtime contract."""
|
|
||||||
|
|
||||||
|
|
||||||
def _bounded_path_error(kind: str) -> PathConfigurationError:
|
|
||||||
return PathConfigurationError(f"{kind} path must be an absolute path within 240 characters")
|
|
||||||
|
|
||||||
|
|
||||||
def _read_xdg_path(environment: Mapping[str, str], key: str, fallback: Path, kind: str) -> Path:
|
|
||||||
raw_value = environment.get(key)
|
|
||||||
candidate = fallback if raw_value is None or raw_value == "" else Path(raw_value).expanduser()
|
|
||||||
if not candidate.is_absolute() or len(str(candidate)) > 240:
|
|
||||||
raise _bounded_path_error(kind)
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class RuntimePaths:
|
|
||||||
"""Private filesystem locations owned by one local capture installation."""
|
|
||||||
|
|
||||||
config_dir: Path
|
|
||||||
data_dir: Path
|
|
||||||
cache_dir: Path
|
|
||||||
runtime_dir: Path
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
for path in (self.config_dir, self.data_dir, self.cache_dir, self.runtime_dir):
|
|
||||||
if not path.is_absolute() or len(str(path)) > 240:
|
|
||||||
raise _bounded_path_error("runtime")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def config_file(self) -> Path:
|
|
||||||
return self.config_dir / "config.toml"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def instance_lock_file(self) -> Path:
|
|
||||||
return self.runtime_dir / "instance.lock"
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_runtime_paths(environment: Mapping[str, str] | None = None) -> RuntimePaths:
|
|
||||||
"""Resolve XDG paths without creating or inspecting filesystem entries."""
|
|
||||||
|
|
||||||
env = environ if environment is None else environment
|
|
||||||
home = Path(env.get("HOME", "~")).expanduser()
|
|
||||||
if not home.is_absolute() or len(str(home)) > 200:
|
|
||||||
raise _bounded_path_error("home")
|
|
||||||
|
|
||||||
config_root = _read_xdg_path(env, "XDG_CONFIG_HOME", home / ".config", "XDG config")
|
|
||||||
data_root = _read_xdg_path(env, "XDG_DATA_HOME", home / ".local" / "share", "XDG data")
|
|
||||||
cache_root = _read_xdg_path(env, "XDG_CACHE_HOME", home / ".cache", "XDG cache")
|
|
||||||
runtime_root = _read_xdg_path(env, "XDG_RUNTIME_DIR", data_root / "runtime", "XDG runtime")
|
|
||||||
|
|
||||||
return RuntimePaths(
|
|
||||||
config_dir=config_root / "shd-capture",
|
|
||||||
data_dir=data_root / "shd-capture",
|
|
||||||
cache_dir=cache_root / "shd-capture",
|
|
||||||
runtime_dir=runtime_root / "shd-capture",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_private_directory(path: Path) -> None:
|
|
||||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
||||||
mode = path.lstat().st_mode
|
|
||||||
if S_ISLNK(mode) or not S_ISDIR(mode):
|
|
||||||
raise PathConfigurationError("runtime path must name a directory")
|
|
||||||
path.chmod(0o700)
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_private_directories(paths: RuntimePaths) -> None:
|
|
||||||
"""Create utility directories with owner-only access at an explicit runtime boundary."""
|
|
||||||
|
|
||||||
for path in (paths.config_dir, paths.data_dir, paths.cache_dir, paths.runtime_dir):
|
|
||||||
_ensure_private_directory(path)
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_private_file(path: Path) -> None:
|
|
||||||
"""Create a runtime-owned regular file with owner-only access.
|
|
||||||
|
|
||||||
The lifecycle layer uses this contract for its instance lock and any future
|
|
||||||
state files. Callers must prepare the parent directory first.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not path.is_absolute() or len(str(path)) > 240:
|
|
||||||
raise _bounded_path_error("runtime")
|
|
||||||
if path.is_symlink():
|
|
||||||
raise PathConfigurationError("runtime file must be a regular file")
|
|
||||||
if path.exists():
|
|
||||||
mode = path.lstat().st_mode
|
|
||||||
if S_ISLNK(mode) or not S_ISREG(mode):
|
|
||||||
raise PathConfigurationError("runtime file must be a regular file")
|
|
||||||
else:
|
|
||||||
path.touch(mode=0o600, exist_ok=False)
|
|
||||||
path.chmod(0o600)
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
"""Bounded durable leases that follow the frozen job state machine."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
|
|
||||||
from game_data_capture.domain.state_machines import require_transition
|
|
||||||
from game_data_capture.storage.database import StorageDatabase
|
|
||||||
from game_data_capture.storage.repository import StorageError, _id
|
|
||||||
|
|
||||||
_KINDS = {"classify", "ocr", "vision_fallback", "frame_extract", "export"}
|
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
|
||||||
return datetime.now(UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def _lease(seconds: int) -> None:
|
|
||||||
if isinstance(seconds, bool) or not isinstance(seconds, int) or not 1 <= seconds <= 300:
|
|
||||||
raise ValueError("lease duration must be from 1 to 300 seconds")
|
|
||||||
|
|
||||||
|
|
||||||
class JobLeases:
|
|
||||||
def __init__(self, database: StorageDatabase) -> None:
|
|
||||||
self.database = database
|
|
||||||
|
|
||||||
def create(
|
|
||||||
self, job_id: str, kind: str, capture_id: str | None = None, max_attempts: int = 3
|
|
||||||
) -> None:
|
|
||||||
_id(job_id)
|
|
||||||
if capture_id is not None:
|
|
||||||
_id(capture_id)
|
|
||||||
if (
|
|
||||||
kind not in _KINDS
|
|
||||||
or isinstance(max_attempts, bool)
|
|
||||||
or not isinstance(max_attempts, int)
|
|
||||||
or not 1 <= max_attempts <= 20
|
|
||||||
):
|
|
||||||
raise ValueError("invalid job definition")
|
|
||||||
with self.database.connect() as connection:
|
|
||||||
connection.execute(
|
|
||||||
"INSERT INTO jobs(job_id, capture_id, kind, state, max_attempts) "
|
|
||||||
"VALUES (?, ?, ?, 'queued', ?)",
|
|
||||||
(job_id, capture_id, kind, max_attempts),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _recover_expired(self, connection: sqlite3.Connection, now: datetime) -> None:
|
|
||||||
# State changes use the contract path: running -> retry_wait -> queued/failed.
|
|
||||||
rows = list(
|
|
||||||
connection.execute(
|
|
||||||
"SELECT job_id, attempt, max_attempts FROM jobs "
|
|
||||||
"WHERE state = 'running' AND lease_expires_at < ?",
|
|
||||||
(now.isoformat(),),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for row in rows:
|
|
||||||
require_transition("job", "running", "retry_wait")
|
|
||||||
changed = connection.execute(
|
|
||||||
"UPDATE jobs SET state = 'retry_wait', lease_owner = NULL, "
|
|
||||||
"lease_expires_at = NULL, revision = revision + 1 "
|
|
||||||
"WHERE job_id = ? AND state = 'running'",
|
|
||||||
(row["job_id"],),
|
|
||||||
).rowcount
|
|
||||||
if changed != 1:
|
|
||||||
raise StorageError("expired job changed concurrently")
|
|
||||||
target = "failed" if row["attempt"] >= row["max_attempts"] else "queued"
|
|
||||||
require_transition("job", "retry_wait", target)
|
|
||||||
changed = connection.execute(
|
|
||||||
"UPDATE jobs SET state = ?, revision = revision + 1 "
|
|
||||||
"WHERE job_id = ? AND state = 'retry_wait'",
|
|
||||||
(target, row["job_id"]),
|
|
||||||
).rowcount
|
|
||||||
if changed != 1:
|
|
||||||
raise StorageError("expired job changed concurrently")
|
|
||||||
|
|
||||||
def acquire(self, owner: str, lease_seconds: int) -> str | None:
|
|
||||||
_id(owner)
|
|
||||||
_lease(lease_seconds)
|
|
||||||
now = _now()
|
|
||||||
with self.database.connect() as connection:
|
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
|
||||||
try:
|
|
||||||
self._recover_expired(connection, now)
|
|
||||||
row = connection.execute(
|
|
||||||
"SELECT job_id FROM jobs WHERE state = 'queued' "
|
|
||||||
"AND attempt < max_attempts ORDER BY job_id LIMIT 1"
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
connection.execute("COMMIT")
|
|
||||||
return None
|
|
||||||
result = connection.execute(
|
|
||||||
"UPDATE jobs SET state = 'running', lease_owner = ?, lease_expires_at = ?, "
|
|
||||||
"attempt = attempt + 1, revision = revision + 1 WHERE job_id = ? "
|
|
||||||
"AND state = 'queued' AND attempt < max_attempts",
|
|
||||||
(owner, (now + timedelta(seconds=lease_seconds)).isoformat(), row["job_id"]),
|
|
||||||
)
|
|
||||||
connection.execute("COMMIT")
|
|
||||||
return str(row["job_id"]) if result.rowcount == 1 else None
|
|
||||||
except Exception:
|
|
||||||
if connection.in_transaction:
|
|
||||||
connection.execute("ROLLBACK")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def renew(self, job_id: str, owner: str, lease_seconds: int) -> None:
|
|
||||||
_id(job_id)
|
|
||||||
_id(owner)
|
|
||||||
_lease(lease_seconds)
|
|
||||||
now = _now()
|
|
||||||
with self.database.connect() as connection:
|
|
||||||
result = connection.execute(
|
|
||||||
"UPDATE jobs SET lease_expires_at = ?, revision = revision + 1 "
|
|
||||||
"WHERE job_id = ? AND state = 'running' AND lease_owner = ? "
|
|
||||||
"AND lease_expires_at >= ?",
|
|
||||||
(
|
|
||||||
(now + timedelta(seconds=lease_seconds)).isoformat(),
|
|
||||||
job_id,
|
|
||||||
owner,
|
|
||||||
now.isoformat(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if result.rowcount != 1:
|
|
||||||
raise StorageError("lease is not owned or has expired")
|
|
||||||
|
|
||||||
def finish(self, job_id: str, owner: str, target: str) -> None:
|
|
||||||
_id(job_id)
|
|
||||||
_id(owner)
|
|
||||||
if target not in {"succeeded", "failed", "cancelled"}:
|
|
||||||
raise ValueError("job finish target must be terminal")
|
|
||||||
require_transition("job", "running", target)
|
|
||||||
now = _now()
|
|
||||||
with self.database.connect() as connection:
|
|
||||||
result = connection.execute(
|
|
||||||
"UPDATE jobs SET state = ?, lease_owner = NULL, lease_expires_at = NULL, "
|
|
||||||
"revision = revision + 1 WHERE job_id = ? AND state = 'running' "
|
|
||||||
"AND lease_owner = ? AND lease_expires_at >= ?",
|
|
||||||
(target, job_id, owner, now.isoformat()),
|
|
||||||
)
|
|
||||||
if result.rowcount != 1:
|
|
||||||
raise StorageError("lease is not owned or has expired")
|
|
||||||
|
|
||||||
def recover(self) -> int:
|
|
||||||
now = _now()
|
|
||||||
with self.database.connect() as connection:
|
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
|
||||||
try:
|
|
||||||
before = connection.execute(
|
|
||||||
"SELECT count(*) FROM jobs WHERE state = 'running' AND lease_expires_at < ?",
|
|
||||||
(now.isoformat(),),
|
|
||||||
).fetchone()[0]
|
|
||||||
self._recover_expired(connection, now)
|
|
||||||
connection.execute("COMMIT")
|
|
||||||
return int(before)
|
|
||||||
except Exception:
|
|
||||||
if connection.in_transaction:
|
|
||||||
connection.execute("ROLLBACK")
|
|
||||||
raise
|
|
||||||
@@ -1,547 +0,0 @@
|
|||||||
"""Bounded lifecycle wiring for local capture intake and utility storage."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import fcntl
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
import signal
|
|
||||||
import stat
|
|
||||||
import time
|
|
||||||
from collections.abc import Callable, Iterable, Iterator, Mapping
|
|
||||||
from contextlib import AbstractContextManager, contextmanager, suppress
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from threading import Event, RLock, Thread, current_thread, main_thread
|
|
||||||
from typing import Protocol, cast
|
|
||||||
|
|
||||||
import uvicorn
|
|
||||||
|
|
||||||
from game_data_capture.config import AppConfig
|
|
||||||
from game_data_capture.ingest.clipboard import ClipboardIntake, IntakeReceipt, WatchConfig
|
|
||||||
from game_data_capture.ingest.spool_helper import PNG_SIGNATURE
|
|
||||||
from game_data_capture.paths import RuntimePaths, prepare_private_directories, prepare_private_file
|
|
||||||
from game_data_capture.pipeline.jobs import JobLeases
|
|
||||||
from game_data_capture.storage import (
|
|
||||||
CaptureCorpus,
|
|
||||||
CaptureRepository,
|
|
||||||
ContentAddressedBlobStore,
|
|
||||||
StorageDatabase,
|
|
||||||
)
|
|
||||||
from game_data_capture.web.app import (
|
|
||||||
CaptureControlPort,
|
|
||||||
CaptureSessionStatus,
|
|
||||||
ControlOutcome,
|
|
||||||
WebConfig,
|
|
||||||
create_app,
|
|
||||||
)
|
|
||||||
from game_data_capture.web.security import new_bootstrap_token
|
|
||||||
|
|
||||||
_POLICY_VERSION = "capture-v1"
|
|
||||||
_PROCESSING_VERSION = "capture-v1"
|
|
||||||
_POLL_SECONDS = 0.1
|
|
||||||
# This is a bounded latest-history projection, not a corpus total or pagination.
|
|
||||||
_MAX_CAPTURE_PROJECTIONS = 256
|
|
||||||
|
|
||||||
|
|
||||||
class RuntimeFailure(RuntimeError):
|
|
||||||
"""A safe-to-display runtime failure that does not disclose local inputs."""
|
|
||||||
|
|
||||||
|
|
||||||
class IntakePort(Protocol):
|
|
||||||
"""The accepted media descriptor boundary consumed by this runtime."""
|
|
||||||
|
|
||||||
def reconcile(self) -> list[str]: ...
|
|
||||||
|
|
||||||
def arm(self) -> None: ...
|
|
||||||
|
|
||||||
def tick(self) -> None: ...
|
|
||||||
|
|
||||||
def take_helper_receipt(self) -> dict[str, object] | None: ...
|
|
||||||
|
|
||||||
def accept_helper_receipt(
|
|
||||||
self,
|
|
||||||
payload: Mapping[str, object],
|
|
||||||
*,
|
|
||||||
occurrence_id: str,
|
|
||||||
processing_version: str,
|
|
||||||
) -> bool: ...
|
|
||||||
|
|
||||||
def take(self) -> IntakeReceipt | None: ...
|
|
||||||
|
|
||||||
def open_verified(self, receipt: IntakeReceipt) -> AbstractContextManager[Iterable[bytes]]: ...
|
|
||||||
|
|
||||||
def acknowledge(self, receipt: IntakeReceipt) -> bool: ...
|
|
||||||
|
|
||||||
def shutdown(self) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
class InstanceLock:
|
|
||||||
"""Descriptor-held ownership that safely reuses an unlocked stale lock file."""
|
|
||||||
|
|
||||||
def __init__(self, file_descriptor: int) -> None:
|
|
||||||
self._file_descriptor: int | None = file_descriptor
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def acquire(cls, path: Path, timeout_seconds: int) -> InstanceLock:
|
|
||||||
try:
|
|
||||||
prepare_private_file(path)
|
|
||||||
descriptor = os.open(path, os.O_RDWR | os.O_NOFOLLOW | os.O_CLOEXEC)
|
|
||||||
except OSError as error:
|
|
||||||
raise RuntimeFailure("capture instance lock is unavailable") from error
|
|
||||||
try:
|
|
||||||
info = os.fstat(descriptor)
|
|
||||||
if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) != 0o600:
|
|
||||||
raise RuntimeFailure("capture instance lock is unsafe")
|
|
||||||
deadline = time.monotonic() + timeout_seconds
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
||||||
return cls(descriptor)
|
|
||||||
except BlockingIOError:
|
|
||||||
if time.monotonic() >= deadline:
|
|
||||||
raise RuntimeFailure("another capture instance is active") from None
|
|
||||||
time.sleep(min(0.05, max(0.0, deadline - time.monotonic())))
|
|
||||||
except Exception:
|
|
||||||
os.close(descriptor)
|
|
||||||
raise
|
|
||||||
|
|
||||||
def release(self) -> None:
|
|
||||||
if self._file_descriptor is None:
|
|
||||||
return
|
|
||||||
descriptor, self._file_descriptor = self._file_descriptor, None
|
|
||||||
with suppress(OSError):
|
|
||||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
|
||||||
with suppress(OSError):
|
|
||||||
os.close(descriptor)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class _Resources:
|
|
||||||
repository: CaptureRepository
|
|
||||||
corpus: CaptureCorpus
|
|
||||||
lock: InstanceLock
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
self.lock.release()
|
|
||||||
|
|
||||||
|
|
||||||
class _CaptureControl(CaptureControlPort):
|
|
||||||
"""Serialize one optional armed intake beneath a running local service."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
runtime: CaptureRuntime,
|
|
||||||
repository: CaptureRepository,
|
|
||||||
) -> None:
|
|
||||||
self._runtime = runtime
|
|
||||||
self._repository = repository
|
|
||||||
self._lock = RLock()
|
|
||||||
self._cancelled: Event | None = None
|
|
||||||
self._intake: IntakePort | None = None
|
|
||||||
self._worker: Thread | None = None
|
|
||||||
self._state = "disarmed"
|
|
||||||
|
|
||||||
def status(self) -> CaptureSessionStatus:
|
|
||||||
with self._lock:
|
|
||||||
self._reap_finished_worker()
|
|
||||||
intake = self._intake
|
|
||||||
depth = self._queue_depth(intake)
|
|
||||||
return CaptureSessionStatus(
|
|
||||||
armed=self._state in {"armed", "stopping"},
|
|
||||||
queue_depth=depth,
|
|
||||||
queue_capacity=self._runtime.config.limits.max_queue_items,
|
|
||||||
)
|
|
||||||
|
|
||||||
def start_session(self) -> ControlOutcome:
|
|
||||||
with self._lock:
|
|
||||||
self._reap_finished_worker()
|
|
||||||
if self._state == "armed":
|
|
||||||
return "already_armed"
|
|
||||||
if self._state == "stopping":
|
|
||||||
return "conflict"
|
|
||||||
if self._worker is not None:
|
|
||||||
return "conflict"
|
|
||||||
intake: IntakePort | None = None
|
|
||||||
try:
|
|
||||||
intake = self._runtime.intake_factory(self._runtime._watch_config())
|
|
||||||
# Durable media always becomes eligible before the watcher is armed.
|
|
||||||
intake.reconcile()
|
|
||||||
intake.arm()
|
|
||||||
except Exception:
|
|
||||||
if intake is not None:
|
|
||||||
with suppress(Exception):
|
|
||||||
intake.shutdown()
|
|
||||||
self._state = "disarmed"
|
|
||||||
return "conflict"
|
|
||||||
assert intake is not None
|
|
||||||
cancelled = Event()
|
|
||||||
worker = Thread(
|
|
||||||
target=self._drain_loop,
|
|
||||||
args=(intake, cancelled),
|
|
||||||
name="shd-capture-drain",
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
self._intake = intake
|
|
||||||
self._cancelled = cancelled
|
|
||||||
self._worker = worker
|
|
||||||
self._state = "armed"
|
|
||||||
try:
|
|
||||||
worker.start()
|
|
||||||
except Exception:
|
|
||||||
self._state = "disarmed"
|
|
||||||
self._intake = self._cancelled = self._worker = None
|
|
||||||
with suppress(Exception):
|
|
||||||
intake.shutdown()
|
|
||||||
return "conflict"
|
|
||||||
return "started"
|
|
||||||
|
|
||||||
def stop_session(self) -> ControlOutcome:
|
|
||||||
with self._lock:
|
|
||||||
self._reap_finished_worker()
|
|
||||||
worker = self._worker
|
|
||||||
if worker is None:
|
|
||||||
return "already_stopped"
|
|
||||||
if self._state == "stopping":
|
|
||||||
return "conflict"
|
|
||||||
self._state = "stopping"
|
|
||||||
cancelled = self._cancelled
|
|
||||||
intake = self._intake
|
|
||||||
return "stopped" if self._stop_worker(worker, intake, cancelled) else "conflict"
|
|
||||||
|
|
||||||
def shutdown(self) -> bool:
|
|
||||||
"""Request child shutdown before the service gives up its instance lock."""
|
|
||||||
|
|
||||||
with self._lock:
|
|
||||||
self._reap_finished_worker()
|
|
||||||
worker = self._worker
|
|
||||||
if worker is None:
|
|
||||||
return True
|
|
||||||
self._state = "stopping"
|
|
||||||
intake = self._intake
|
|
||||||
cancelled = self._cancelled
|
|
||||||
return self._stop_worker(worker, intake, cancelled)
|
|
||||||
|
|
||||||
def defer_resource_close(self, close: Callable[[], None]) -> None:
|
|
||||||
"""Release the service lock only after a timed-out drain worker exits."""
|
|
||||||
|
|
||||||
with self._lock:
|
|
||||||
self._reap_finished_worker()
|
|
||||||
worker = self._worker
|
|
||||||
if worker is None:
|
|
||||||
close()
|
|
||||||
return
|
|
||||||
|
|
||||||
def wait_for_worker() -> None:
|
|
||||||
worker.join()
|
|
||||||
close()
|
|
||||||
|
|
||||||
Thread(target=wait_for_worker, name="shd-capture-release", daemon=True).start()
|
|
||||||
|
|
||||||
def _stop_worker(
|
|
||||||
self,
|
|
||||||
worker: Thread,
|
|
||||||
intake: IntakePort | None,
|
|
||||||
cancelled: Event | None,
|
|
||||||
) -> bool:
|
|
||||||
if cancelled is not None:
|
|
||||||
cancelled.set()
|
|
||||||
timeout = float(self._runtime.config.lifecycle.shutdown_timeout_seconds)
|
|
||||||
initial_timeout = timeout / 2
|
|
||||||
# Never hold the control lock while waiting for a drain or child cleanup.
|
|
||||||
worker.join(timeout=initial_timeout)
|
|
||||||
if worker.is_alive():
|
|
||||||
# A receipt in flight remains durable because cancellation prevents its
|
|
||||||
# acknowledgement. Stopping intake unblocks watcher/helper cleanup.
|
|
||||||
if intake is not None:
|
|
||||||
with suppress(Exception):
|
|
||||||
intake.shutdown()
|
|
||||||
worker.join(timeout=timeout - initial_timeout)
|
|
||||||
with self._lock:
|
|
||||||
self._reap_finished_worker()
|
|
||||||
return not worker.is_alive()
|
|
||||||
|
|
||||||
def _drain_loop(self, intake: IntakePort, cancelled: Event) -> None:
|
|
||||||
try:
|
|
||||||
while not cancelled.is_set():
|
|
||||||
intake.tick()
|
|
||||||
self._runtime._drain(intake, self._repository, cancelled.is_set)
|
|
||||||
cancelled.wait(_POLL_SECONDS)
|
|
||||||
except Exception:
|
|
||||||
# The HTTP boundary exposes only bounded armed/queue state, not an
|
|
||||||
# intake, storage, helper, or media exception.
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
with suppress(Exception):
|
|
||||||
intake.shutdown()
|
|
||||||
with self._lock:
|
|
||||||
if self._intake is intake:
|
|
||||||
self._state = "disarmed"
|
|
||||||
|
|
||||||
def _reap_finished_worker(self) -> None:
|
|
||||||
if self._worker is not None and not self._worker.is_alive():
|
|
||||||
self._worker = self._cancelled = self._intake = None
|
|
||||||
self._state = "disarmed"
|
|
||||||
|
|
||||||
def _queue_depth(self, intake: IntakePort | None) -> int:
|
|
||||||
if intake is None:
|
|
||||||
return 0
|
|
||||||
depths: list[int] = []
|
|
||||||
for name in ("queue_depth", "helper_queue_depth"):
|
|
||||||
try:
|
|
||||||
value = getattr(intake, name, 0)
|
|
||||||
except Exception:
|
|
||||||
value = 0
|
|
||||||
if not isinstance(value, bool) and isinstance(value, int) and value >= 0:
|
|
||||||
depths.append(value)
|
|
||||||
return min(sum(depths), self._runtime.config.limits.max_queue_items)
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def _verified_png_file(path: Path, max_bytes: int) -> Iterator[Iterable[bytes]]:
|
|
||||||
"""Yield a one-descriptor PNG stream and reject unsafe input without path disclosure."""
|
|
||||||
|
|
||||||
candidate = path.absolute()
|
|
||||||
if len(str(candidate)) > 240:
|
|
||||||
raise RuntimeFailure("input image is not a safe PNG")
|
|
||||||
try:
|
|
||||||
descriptor = os.open(candidate, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
|
||||||
except OSError as error:
|
|
||||||
raise RuntimeFailure("input image is not a safe PNG") from error
|
|
||||||
try:
|
|
||||||
info = os.fstat(descriptor)
|
|
||||||
if (
|
|
||||||
not stat.S_ISREG(info.st_mode)
|
|
||||||
or stat.S_IMODE(info.st_mode) & 0o022
|
|
||||||
or not 8 <= info.st_size <= max_bytes
|
|
||||||
):
|
|
||||||
raise RuntimeFailure("input image is not a safe PNG")
|
|
||||||
|
|
||||||
def chunks() -> Iterator[bytes]:
|
|
||||||
first = os.read(descriptor, len(PNG_SIGNATURE))
|
|
||||||
if first != PNG_SIGNATURE:
|
|
||||||
raise RuntimeFailure("input image is not a safe PNG")
|
|
||||||
total = len(first)
|
|
||||||
yield first
|
|
||||||
while chunk := os.read(descriptor, 64 * 1024):
|
|
||||||
total += len(chunk)
|
|
||||||
if total > max_bytes:
|
|
||||||
raise RuntimeFailure("input image is not a safe PNG")
|
|
||||||
yield chunk
|
|
||||||
if total != info.st_size:
|
|
||||||
raise RuntimeFailure("input image is not a safe PNG")
|
|
||||||
|
|
||||||
yield chunks()
|
|
||||||
finally:
|
|
||||||
os.close(descriptor)
|
|
||||||
|
|
||||||
|
|
||||||
class CaptureRuntime:
|
|
||||||
"""Own utility-local storage, then stream verified media receipts into it."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
config: AppConfig,
|
|
||||||
paths: RuntimePaths,
|
|
||||||
*,
|
|
||||||
migrations_dir: Path | None = None,
|
|
||||||
intake_factory: Callable[[WatchConfig], IntakePort] = ClipboardIntake,
|
|
||||||
) -> None:
|
|
||||||
self.config = config
|
|
||||||
self.paths = paths
|
|
||||||
self.migrations_dir = (
|
|
||||||
Path(__file__).resolve().parents[2] / "migrations"
|
|
||||||
if migrations_dir is None
|
|
||||||
else migrations_dir
|
|
||||||
)
|
|
||||||
self.intake_factory = intake_factory
|
|
||||||
|
|
||||||
def _watch_config(self) -> WatchConfig:
|
|
||||||
return WatchConfig(
|
|
||||||
spool=self.paths.cache_dir / "spool",
|
|
||||||
max_image_bytes=self.config.limits.max_image_bytes,
|
|
||||||
max_queue_items=self.config.limits.max_queue_items,
|
|
||||||
policy_version=_POLICY_VERSION,
|
|
||||||
shutdown_timeout_seconds=float(min(30, self.config.lifecycle.shutdown_timeout_seconds)),
|
|
||||||
)
|
|
||||||
|
|
||||||
def validate_plan(self) -> None:
|
|
||||||
"""Validate static configuration and paths without creating runtime resources."""
|
|
||||||
|
|
||||||
self._watch_config()
|
|
||||||
if not self.migrations_dir.is_absolute() or not self.migrations_dir.is_dir():
|
|
||||||
raise RuntimeFailure("capture runtime resources are unavailable")
|
|
||||||
StorageDatabase(self.paths.data_dir, self.migrations_dir)
|
|
||||||
ContentAddressedBlobStore(self.paths.data_dir)
|
|
||||||
|
|
||||||
def _start(self) -> _Resources:
|
|
||||||
self.validate_plan()
|
|
||||||
prepare_private_directories(self.paths)
|
|
||||||
lock = InstanceLock.acquire(
|
|
||||||
self.paths.instance_lock_file, self.config.lifecycle.lock_timeout_seconds
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
database = StorageDatabase(self.paths.data_dir, self.migrations_dir)
|
|
||||||
database.migrate()
|
|
||||||
blobs = ContentAddressedBlobStore(self.paths.data_dir)
|
|
||||||
blobs.reconcile()
|
|
||||||
JobLeases(database).recover()
|
|
||||||
corpus = CaptureCorpus(
|
|
||||||
database,
|
|
||||||
blobs,
|
|
||||||
max_media_bytes=self.config.limits.max_image_bytes,
|
|
||||||
)
|
|
||||||
corpus.reconcile_deletions()
|
|
||||||
return _Resources(CaptureRepository(database, blobs), corpus, lock)
|
|
||||||
except RuntimeFailure:
|
|
||||||
lock.release()
|
|
||||||
raise
|
|
||||||
except Exception as error:
|
|
||||||
lock.release()
|
|
||||||
raise RuntimeFailure("capture runtime initialization failed") from error
|
|
||||||
|
|
||||||
def ingest_image(self, image: Path) -> str:
|
|
||||||
"""Persist one descriptor-verified PNG as a unique capture occurrence."""
|
|
||||||
|
|
||||||
resources = self._start()
|
|
||||||
token = secrets.token_hex(16)
|
|
||||||
try:
|
|
||||||
with _verified_png_file(image, self.config.limits.max_image_bytes) as stream:
|
|
||||||
return resources.repository.receive(
|
|
||||||
f"capture-{token}",
|
|
||||||
f"image-{token}",
|
|
||||||
stream,
|
|
||||||
media_type="image/png",
|
|
||||||
max_bytes=self.config.limits.max_image_bytes,
|
|
||||||
)
|
|
||||||
except RuntimeFailure:
|
|
||||||
raise
|
|
||||||
except Exception as error:
|
|
||||||
raise RuntimeFailure("image capture could not be stored") from error
|
|
||||||
finally:
|
|
||||||
resources.close()
|
|
||||||
|
|
||||||
def _drain(
|
|
||||||
self,
|
|
||||||
intake: IntakePort,
|
|
||||||
repository: CaptureRepository,
|
|
||||||
cancellation_requested: Callable[[], bool],
|
|
||||||
) -> None:
|
|
||||||
while (
|
|
||||||
not cancellation_requested() and (payload := intake.take_helper_receipt()) is not None
|
|
||||||
):
|
|
||||||
intake.accept_helper_receipt(
|
|
||||||
payload,
|
|
||||||
occurrence_id=f"capture-{secrets.token_hex(16)}",
|
|
||||||
processing_version=_PROCESSING_VERSION,
|
|
||||||
)
|
|
||||||
while not cancellation_requested() and (receipt := intake.take()) is not None:
|
|
||||||
with intake.open_verified(receipt) as reader:
|
|
||||||
repository.receive(
|
|
||||||
receipt.occurrence_id,
|
|
||||||
receipt.receipt_token,
|
|
||||||
reader,
|
|
||||||
media_type="image/png",
|
|
||||||
max_bytes=self.config.limits.max_image_bytes,
|
|
||||||
)
|
|
||||||
if cancellation_requested():
|
|
||||||
return
|
|
||||||
if not intake.acknowledge(receipt):
|
|
||||||
raise RuntimeFailure("capture receipt acknowledgement failed")
|
|
||||||
|
|
||||||
def serve(self) -> None:
|
|
||||||
"""Run one local web service with unarmed, durable capture control."""
|
|
||||||
|
|
||||||
bootstrap_token = new_bootstrap_token()
|
|
||||||
resources = self._start()
|
|
||||||
control = _CaptureControl(self, resources.repository)
|
|
||||||
authority = (
|
|
||||||
f"[{self.config.server.host}]"
|
|
||||||
if ":" in self.config.server.host
|
|
||||||
else self.config.server.host
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
app = create_app(
|
|
||||||
config=WebConfig(
|
|
||||||
server=self.config.server,
|
|
||||||
max_capture_items=_MAX_CAPTURE_PROJECTIONS,
|
|
||||||
max_image_bytes=self.config.limits.max_image_bytes,
|
|
||||||
),
|
|
||||||
capture_control=control,
|
|
||||||
corpus=resources.corpus,
|
|
||||||
bootstrap_token=bootstrap_token,
|
|
||||||
)
|
|
||||||
print(
|
|
||||||
f"Open local review service: http://{authority}:{self.config.server.port}/"
|
|
||||||
f"#bootstrap={bootstrap_token}"
|
|
||||||
)
|
|
||||||
print("H1: capture is disarmed; use Start Session before taking a screenshot.")
|
|
||||||
try:
|
|
||||||
uvicorn.run(
|
|
||||||
app,
|
|
||||||
host=self.config.server.host,
|
|
||||||
port=self.config.server.port,
|
|
||||||
reload=False,
|
|
||||||
workers=1,
|
|
||||||
proxy_headers=False,
|
|
||||||
)
|
|
||||||
except SystemExit as error:
|
|
||||||
if error.code not in (None, 0):
|
|
||||||
raise RuntimeFailure("capture service failed") from error
|
|
||||||
raise
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
return
|
|
||||||
except RuntimeFailure:
|
|
||||||
raise
|
|
||||||
except Exception as error:
|
|
||||||
raise RuntimeFailure("capture service failed") from error
|
|
||||||
finally:
|
|
||||||
if control.shutdown():
|
|
||||||
resources.close()
|
|
||||||
else:
|
|
||||||
control.defer_resource_close(resources.close)
|
|
||||||
raise RuntimeFailure("capture service shutdown failed")
|
|
||||||
|
|
||||||
def watch(self, *, cancelled: Event | None = None, poll_seconds: float = _POLL_SECONDS) -> None:
|
|
||||||
"""Run the armed intake until cancellation, signal, or keyboard interruption."""
|
|
||||||
|
|
||||||
if not 0 < poll_seconds <= 1:
|
|
||||||
raise ValueError("invalid runtime poll interval")
|
|
||||||
resources = self._start()
|
|
||||||
intake: IntakePort | None = None
|
|
||||||
stop_requested = Event()
|
|
||||||
handlers: dict[signal.Signals, object] = {}
|
|
||||||
|
|
||||||
def request_stop(_signal_number: int, _frame: object) -> None:
|
|
||||||
stop_requested.set()
|
|
||||||
|
|
||||||
try:
|
|
||||||
if current_thread() is main_thread():
|
|
||||||
for configured_signal in (signal.SIGINT, signal.SIGTERM):
|
|
||||||
handlers[configured_signal] = signal.signal(configured_signal, request_stop)
|
|
||||||
intake = self.intake_factory(self._watch_config())
|
|
||||||
intake.reconcile()
|
|
||||||
intake.arm()
|
|
||||||
while not stop_requested.is_set() and (cancelled is None or not cancelled.is_set()):
|
|
||||||
intake.tick()
|
|
||||||
self._drain(
|
|
||||||
intake,
|
|
||||||
resources.repository,
|
|
||||||
lambda: (
|
|
||||||
stop_requested.is_set() or (cancelled is not None and cancelled.is_set())
|
|
||||||
),
|
|
||||||
)
|
|
||||||
time.sleep(poll_seconds)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
return
|
|
||||||
except RuntimeFailure:
|
|
||||||
raise
|
|
||||||
except Exception as error:
|
|
||||||
raise RuntimeFailure("capture watch failed") from error
|
|
||||||
finally:
|
|
||||||
if intake is not None:
|
|
||||||
with suppress(OSError, RuntimeError):
|
|
||||||
intake.shutdown()
|
|
||||||
for restored_signal, handler in handlers.items():
|
|
||||||
signal.signal(restored_signal, cast(signal.Handlers, handler))
|
|
||||||
resources.close()
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
"""Utility-local durable storage; it never opens the SHD.build application database."""
|
|
||||||
|
|
||||||
from game_data_capture.storage.blobs import BlobCorruptionError, ContentAddressedBlobStore
|
|
||||||
from game_data_capture.storage.corpus import CaptureCorpus
|
|
||||||
from game_data_capture.storage.database import StorageDatabase
|
|
||||||
from game_data_capture.storage.repository import CaptureRepository, StorageError
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"BlobCorruptionError",
|
|
||||||
"CaptureCorpus",
|
|
||||||
"CaptureRepository",
|
|
||||||
"ContentAddressedBlobStore",
|
|
||||||
"StorageDatabase",
|
|
||||||
"StorageError",
|
|
||||||
]
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
"""Verified immutable blobs with no symlink traversal at storage boundaries."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
import time
|
|
||||||
from collections.abc import Iterable, Iterator
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from pathlib import Path
|
|
||||||
from stat import S_IMODE, S_ISDIR, S_ISREG
|
|
||||||
|
|
||||||
|
|
||||||
class BlobCorruptionError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class BlobLimitError(ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class BlobLockError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _digest(value: str) -> None:
|
|
||||||
if len(value) != 64 or any(char not in "0123456789abcdef" for char in value):
|
|
||||||
raise ValueError("invalid SHA-256 digest")
|
|
||||||
|
|
||||||
|
|
||||||
class ContentAddressedBlobStore:
|
|
||||||
def __init__(self, root: Path) -> None:
|
|
||||||
if not root.is_absolute():
|
|
||||||
raise ValueError("blob root must be absolute")
|
|
||||||
self.root = root
|
|
||||||
self.blobs = root / "blobs"
|
|
||||||
self.temporary = root / "tmp"
|
|
||||||
self.lock_path = root / ".blob-operation.lock"
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def operation_lock(self, *, timeout_seconds: float = 1.0) -> Iterator[None]:
|
|
||||||
"""Hold the private cross-process lock for receipt finalization or deletion."""
|
|
||||||
if (
|
|
||||||
isinstance(timeout_seconds, bool)
|
|
||||||
or not isinstance(timeout_seconds, int | float)
|
|
||||||
or not 0 < timeout_seconds <= 5
|
|
||||||
):
|
|
||||||
raise ValueError("blob lock timeout is invalid")
|
|
||||||
self._directory(self.root, create=True)
|
|
||||||
try:
|
|
||||||
fd = os.open(self.lock_path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
|
||||||
except OSError as error:
|
|
||||||
raise BlobLockError("blob operation lock is unavailable") from error
|
|
||||||
try:
|
|
||||||
info = os.fstat(fd)
|
|
||||||
if not S_ISREG(info.st_mode) or S_IMODE(info.st_mode) != 0o600:
|
|
||||||
raise BlobLockError("blob operation lock is unsafe")
|
|
||||||
import fcntl
|
|
||||||
|
|
||||||
deadline = time.monotonic() + timeout_seconds
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
||||||
break
|
|
||||||
except BlockingIOError:
|
|
||||||
if time.monotonic() >= deadline:
|
|
||||||
raise BlobLockError("blob operation lock is unavailable") from None
|
|
||||||
remaining = deadline - time.monotonic()
|
|
||||||
if remaining <= 0:
|
|
||||||
raise BlobLockError("blob operation lock is unavailable") from None
|
|
||||||
time.sleep(min(0.02, remaining))
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
import fcntl
|
|
||||||
|
|
||||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
|
|
||||||
def _directory(self, path: Path, *, create: bool) -> None:
|
|
||||||
if path.is_symlink():
|
|
||||||
raise BlobCorruptionError("storage directory is unsafe")
|
|
||||||
if create:
|
|
||||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
||||||
if not path.exists() or path.is_symlink() or not S_ISDIR(path.lstat().st_mode):
|
|
||||||
raise BlobCorruptionError("storage directory is unsafe")
|
|
||||||
path.chmod(0o700)
|
|
||||||
|
|
||||||
def _path(self, digest: str) -> Path:
|
|
||||||
_digest(digest)
|
|
||||||
return self.blobs / digest[:2] / digest[2:]
|
|
||||||
|
|
||||||
def _fsync_directory(self, path: Path) -> None:
|
|
||||||
if path.is_symlink():
|
|
||||||
raise BlobCorruptionError("storage directory is unsafe")
|
|
||||||
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
||||||
try:
|
|
||||||
os.fsync(fd)
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
|
|
||||||
def _verify_fd(self, fd: int, digest: str, expected_size: int | None) -> int:
|
|
||||||
stat = os.fstat(fd)
|
|
||||||
if not S_ISREG(stat.st_mode):
|
|
||||||
raise BlobCorruptionError("blob is not a regular file")
|
|
||||||
if expected_size is not None and stat.st_size != expected_size:
|
|
||||||
raise BlobCorruptionError("blob content does not match receipt")
|
|
||||||
os.lseek(fd, 0, os.SEEK_SET)
|
|
||||||
hasher = hashlib.sha256()
|
|
||||||
size = 0
|
|
||||||
while chunk := os.read(fd, 1024 * 1024):
|
|
||||||
size += len(chunk)
|
|
||||||
hasher.update(chunk)
|
|
||||||
if hasher.hexdigest() != digest or (expected_size is not None and size != expected_size):
|
|
||||||
raise BlobCorruptionError("blob content does not match receipt")
|
|
||||||
os.lseek(fd, 0, os.SEEK_SET)
|
|
||||||
return size
|
|
||||||
|
|
||||||
def put(
|
|
||||||
self, chunks: Iterable[bytes], *, max_bytes: int, expected_sha256: str | None = None
|
|
||||||
) -> tuple[str, int]:
|
|
||||||
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes <= 0:
|
|
||||||
raise BlobLimitError("blob limit must be positive")
|
|
||||||
if expected_sha256 is not None:
|
|
||||||
_digest(expected_sha256)
|
|
||||||
self._directory(self.root, create=True)
|
|
||||||
self._directory(self.blobs, create=True)
|
|
||||||
self._directory(self.temporary, create=True)
|
|
||||||
temporary = self.temporary / f".{secrets.token_hex(16)}.partial"
|
|
||||||
hasher = hashlib.sha256()
|
|
||||||
size = 0
|
|
||||||
try:
|
|
||||||
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
||||||
try:
|
|
||||||
for chunk in chunks:
|
|
||||||
if not isinstance(chunk, bytes) or not chunk:
|
|
||||||
raise BlobLimitError("blob chunks must be non-empty bytes")
|
|
||||||
size += len(chunk)
|
|
||||||
if size > max_bytes:
|
|
||||||
raise BlobLimitError("blob exceeds configured size limit")
|
|
||||||
hasher.update(chunk)
|
|
||||||
view = memoryview(chunk)
|
|
||||||
while view:
|
|
||||||
written = os.write(fd, view)
|
|
||||||
if written <= 0:
|
|
||||||
raise OSError("short blob write")
|
|
||||||
view = view[written:]
|
|
||||||
os.fsync(fd)
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
digest = hasher.hexdigest()
|
|
||||||
if size == 0 or (expected_sha256 is not None and digest != expected_sha256):
|
|
||||||
raise BlobCorruptionError("blob digest verification failed")
|
|
||||||
with self.operation_lock():
|
|
||||||
destination = self._path(digest)
|
|
||||||
new_prefix = not destination.parent.exists()
|
|
||||||
self._directory(destination.parent, create=True)
|
|
||||||
if new_prefix:
|
|
||||||
self._fsync_directory(self.blobs)
|
|
||||||
directory_fd = os.open(destination.parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
||||||
try:
|
|
||||||
os.fsync(directory_fd)
|
|
||||||
if destination.exists():
|
|
||||||
self.verify(digest, expected_size=size)
|
|
||||||
else:
|
|
||||||
os.replace(temporary, destination)
|
|
||||||
os.chmod(destination, 0o600)
|
|
||||||
os.fsync(directory_fd)
|
|
||||||
finally:
|
|
||||||
os.close(directory_fd)
|
|
||||||
self.verify(digest, expected_size=size)
|
|
||||||
return digest, size
|
|
||||||
finally:
|
|
||||||
if temporary.exists() and not temporary.is_symlink():
|
|
||||||
temporary.unlink()
|
|
||||||
|
|
||||||
def _open_verified(self, digest: str, expected_size: int | None = None) -> tuple[int, int]:
|
|
||||||
path = self._path(digest)
|
|
||||||
try:
|
|
||||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
||||||
except OSError as error:
|
|
||||||
raise BlobCorruptionError("blob is missing or unsafe") from error
|
|
||||||
try:
|
|
||||||
return fd, self._verify_fd(fd, digest, expected_size)
|
|
||||||
except Exception:
|
|
||||||
os.close(fd)
|
|
||||||
raise
|
|
||||||
|
|
||||||
def verify(self, digest: str, *, expected_size: int | None = None) -> int:
|
|
||||||
fd, size = self._open_verified(digest, expected_size)
|
|
||||||
os.close(fd)
|
|
||||||
return size
|
|
||||||
|
|
||||||
def read(
|
|
||||||
self, digest: str, *, max_bytes: int, expected_size: int | None = None
|
|
||||||
) -> Iterator[bytes]:
|
|
||||||
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes <= 0:
|
|
||||||
raise BlobLimitError("blob limit must be positive")
|
|
||||||
fd, size = self._open_verified(digest, expected_size)
|
|
||||||
if size > max_bytes:
|
|
||||||
os.close(fd)
|
|
||||||
raise BlobLimitError("blob exceeds read limit")
|
|
||||||
try:
|
|
||||||
while chunk := os.read(fd, 1024 * 1024):
|
|
||||||
yield chunk
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
|
|
||||||
def delete_verified(self, digest: str, *, expected_size: int) -> bool:
|
|
||||||
"""Unlink one verified immutable blob without following a replacement path."""
|
|
||||||
if (
|
|
||||||
isinstance(expected_size, bool)
|
|
||||||
or not isinstance(expected_size, int)
|
|
||||||
or expected_size <= 0
|
|
||||||
):
|
|
||||||
raise BlobLimitError("blob size is invalid")
|
|
||||||
fd, _ = self._open_verified(digest, expected_size)
|
|
||||||
path = self._path(digest)
|
|
||||||
try:
|
|
||||||
parent_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
||||||
try:
|
|
||||||
current = os.stat(path.name, dir_fd=parent_fd, follow_symlinks=False)
|
|
||||||
opened = os.fstat(fd)
|
|
||||||
if (current.st_dev, current.st_ino) != (opened.st_dev, opened.st_ino):
|
|
||||||
raise BlobCorruptionError("blob changed during deletion")
|
|
||||||
os.unlink(path.name, dir_fd=parent_fd)
|
|
||||||
os.fsync(parent_fd)
|
|
||||||
return True
|
|
||||||
finally:
|
|
||||||
os.close(parent_fd)
|
|
||||||
except OSError as error:
|
|
||||||
raise BlobCorruptionError("blob is missing or unsafe") from error
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
|
|
||||||
def reconcile(self) -> list[str]:
|
|
||||||
if not self.temporary.exists() or self.temporary.is_symlink():
|
|
||||||
return []
|
|
||||||
self._directory(self.temporary, create=False)
|
|
||||||
removed = []
|
|
||||||
for entry in self.temporary.iterdir():
|
|
||||||
if not entry.is_symlink() and entry.is_file() and entry.name.endswith(".partial"):
|
|
||||||
entry.unlink()
|
|
||||||
removed.append(entry.name)
|
|
||||||
return removed
|
|
||||||
|
|
||||||
def purge_dry_run(self, digests: set[str]) -> list[Path]:
|
|
||||||
if not self.blobs.exists() or self.blobs.is_symlink():
|
|
||||||
return []
|
|
||||||
self._directory(self.blobs, create=False)
|
|
||||||
candidates = []
|
|
||||||
for prefix in self.blobs.iterdir():
|
|
||||||
if prefix.is_symlink() or not prefix.is_dir():
|
|
||||||
continue
|
|
||||||
for entry in prefix.iterdir():
|
|
||||||
if (
|
|
||||||
not entry.is_symlink()
|
|
||||||
and entry.is_file()
|
|
||||||
and prefix.name + entry.name not in digests
|
|
||||||
):
|
|
||||||
candidates.append(entry.relative_to(self.root))
|
|
||||||
return candidates
|
|
||||||
@@ -1,499 +0,0 @@
|
|||||||
"""Durable, safe projections for the reviewed local capture corpus port."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sqlite3
|
|
||||||
from collections.abc import Mapping, Sequence
|
|
||||||
|
|
||||||
from game_data_capture.domain.validation import (
|
|
||||||
ContractValidationError,
|
|
||||||
validate_classification,
|
|
||||||
)
|
|
||||||
from game_data_capture.storage.blobs import (
|
|
||||||
BlobCorruptionError,
|
|
||||||
BlobLockError,
|
|
||||||
ContentAddressedBlobStore,
|
|
||||||
)
|
|
||||||
from game_data_capture.storage.database import StorageDatabase
|
|
||||||
from game_data_capture.storage.repository import StorageError, _now
|
|
||||||
from game_data_capture.web.app import CaptureRecord, MediaDescriptor, MutationOutcome
|
|
||||||
|
|
||||||
_CAPTURE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{7,63}$")
|
|
||||||
_MEDIA_TYPES = frozenset({"image/png", "image/webp", "image/avif"})
|
|
||||||
_CLASSIFICATION_KEYS = frozenset(
|
|
||||||
{"family", "view", "gear_slot", "record_types", "confidence", "abstention_reason"}
|
|
||||||
)
|
|
||||||
_MAX_MEDIA_BYTES = 64 * 1024 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
def _capture_id(value: str) -> bool:
|
|
||||||
return isinstance(value, str) and bool(_CAPTURE_ID.fullmatch(value))
|
|
||||||
|
|
||||||
|
|
||||||
def _classification(value: Mapping[str, object]) -> str:
|
|
||||||
if not isinstance(value, Mapping) or set(value) != _CLASSIFICATION_KEYS:
|
|
||||||
raise ValueError("classification does not match the frozen taxonomy shape")
|
|
||||||
payload = dict(value)
|
|
||||||
try:
|
|
||||||
validate_classification(payload)
|
|
||||||
confidence = payload["confidence"]
|
|
||||||
record_types = payload["record_types"]
|
|
||||||
if isinstance(confidence, bool) or not isinstance(confidence, int | float):
|
|
||||||
raise ValueError("classification confidence is invalid")
|
|
||||||
if not isinstance(record_types, list) or any(
|
|
||||||
not isinstance(record_type, str) for record_type in record_types
|
|
||||||
):
|
|
||||||
raise ValueError("classification record types are invalid")
|
|
||||||
canonical_confidence = 0.0 if confidence == 0 else float(confidence)
|
|
||||||
canonical = {
|
|
||||||
"family": payload["family"],
|
|
||||||
"view": payload["view"],
|
|
||||||
"gear_slot": payload["gear_slot"],
|
|
||||||
"record_types": sorted(record_types),
|
|
||||||
"confidence": canonical_confidence,
|
|
||||||
"abstention_reason": payload["abstention_reason"],
|
|
||||||
}
|
|
||||||
encoded = json.dumps(canonical, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
|
|
||||||
except (ContractValidationError, TypeError, ValueError) as error:
|
|
||||||
raise ValueError("classification does not match the frozen taxonomy shape") from error
|
|
||||||
if not 2 <= len(encoded) <= 4096:
|
|
||||||
raise ValueError("classification does not match the frozen taxonomy shape")
|
|
||||||
return encoded
|
|
||||||
|
|
||||||
|
|
||||||
def _stored_classification(value: object) -> Mapping[str, object]:
|
|
||||||
if not isinstance(value, str) or not 2 <= len(value) <= 4096:
|
|
||||||
raise StorageError("stored corpus record is invalid")
|
|
||||||
try:
|
|
||||||
decoded = json.loads(value)
|
|
||||||
except (TypeError, ValueError) as error:
|
|
||||||
raise StorageError("stored corpus record is invalid") from error
|
|
||||||
if not isinstance(decoded, dict) or set(decoded) != _CLASSIFICATION_KEYS:
|
|
||||||
raise StorageError("stored corpus record is invalid")
|
|
||||||
try:
|
|
||||||
validate_classification(decoded)
|
|
||||||
except ContractValidationError as error:
|
|
||||||
raise StorageError("stored corpus record is invalid") from error
|
|
||||||
return decoded
|
|
||||||
|
|
||||||
|
|
||||||
class CaptureCorpus:
|
|
||||||
"""SQLite-backed implementation of the reviewed ``CaptureCorpusPort``.
|
|
||||||
|
|
||||||
The corpus exposes only opaque capture identifiers. It serializes writes with
|
|
||||||
``BEGIN IMMEDIATE`` so repeated labels and rejections have deterministic
|
|
||||||
idempotence, while differing labels preserve the replaced value as evidence.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
database: StorageDatabase,
|
|
||||||
blobs: ContentAddressedBlobStore,
|
|
||||||
*,
|
|
||||||
max_media_bytes: int = _MAX_MEDIA_BYTES,
|
|
||||||
) -> None:
|
|
||||||
if (
|
|
||||||
isinstance(max_media_bytes, bool)
|
|
||||||
or not isinstance(max_media_bytes, int)
|
|
||||||
or not 1 <= max_media_bytes <= _MAX_MEDIA_BYTES
|
|
||||||
):
|
|
||||||
raise ValueError("corpus media limit is invalid")
|
|
||||||
self.database = database
|
|
||||||
self.blobs = blobs
|
|
||||||
self.max_media_bytes = max_media_bytes
|
|
||||||
|
|
||||||
def list_captures(self, *, limit: int) -> Sequence[CaptureRecord]:
|
|
||||||
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 256:
|
|
||||||
raise ValueError("capture list limit is invalid")
|
|
||||||
with self.database.connect() as connection:
|
|
||||||
rows = connection.execute(
|
|
||||||
"SELECT c.capture_id, c.state, c.received_at, l.classification_json "
|
|
||||||
"FROM captures AS c LEFT JOIN capture_labels AS l USING(capture_id) "
|
|
||||||
"ORDER BY c.received_at DESC, c.capture_id DESC LIMIT ?",
|
|
||||||
(limit,),
|
|
||||||
)
|
|
||||||
return [self._record(row) for row in rows]
|
|
||||||
|
|
||||||
def get_capture(self, capture_id: str) -> CaptureRecord | None:
|
|
||||||
if not _capture_id(capture_id):
|
|
||||||
return None
|
|
||||||
with self.database.connect() as connection:
|
|
||||||
row = connection.execute(
|
|
||||||
"SELECT c.capture_id, c.state, c.received_at, l.classification_json "
|
|
||||||
"FROM captures AS c LEFT JOIN capture_labels AS l USING(capture_id) "
|
|
||||||
"WHERE c.capture_id = ?",
|
|
||||||
(capture_id,),
|
|
||||||
).fetchone()
|
|
||||||
return None if row is None else self._record(row)
|
|
||||||
|
|
||||||
def open_media(self, capture_id: str) -> MediaDescriptor | None:
|
|
||||||
if not _capture_id(capture_id):
|
|
||||||
return None
|
|
||||||
with self.database.connect() as connection:
|
|
||||||
row = connection.execute(
|
|
||||||
"SELECT b.sha256, b.byte_size, b.media_type FROM captures AS c "
|
|
||||||
"JOIN blobs AS b ON b.sha256 = c.blob_sha256 WHERE c.capture_id = ?",
|
|
||||||
(capture_id,),
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
digest, size, media_type = row["sha256"], row["byte_size"], row["media_type"]
|
|
||||||
if (
|
|
||||||
not isinstance(digest, str)
|
|
||||||
or isinstance(size, bool)
|
|
||||||
or not isinstance(size, int)
|
|
||||||
or not 1 <= size <= self.max_media_bytes
|
|
||||||
or not isinstance(media_type, str)
|
|
||||||
or media_type not in _MEDIA_TYPES
|
|
||||||
):
|
|
||||||
raise StorageError("stored corpus media is invalid")
|
|
||||||
try:
|
|
||||||
chunks = self.blobs.read(
|
|
||||||
digest,
|
|
||||||
max_bytes=self.max_media_bytes,
|
|
||||||
expected_size=size,
|
|
||||||
)
|
|
||||||
except (BlobCorruptionError, ValueError) as error:
|
|
||||||
raise StorageError("stored corpus media is unavailable") from error
|
|
||||||
return MediaDescriptor(media_type=media_type, byte_size=size, chunks=chunks)
|
|
||||||
|
|
||||||
def label(self, capture_id: str, classification: Mapping[str, object]) -> MutationOutcome:
|
|
||||||
if not _capture_id(capture_id):
|
|
||||||
return "not_found"
|
|
||||||
encoded = _classification(classification)
|
|
||||||
with self.database.connect() as connection:
|
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
|
||||||
try:
|
|
||||||
if not self._exists(connection, capture_id):
|
|
||||||
connection.execute("COMMIT")
|
|
||||||
return "not_found"
|
|
||||||
if self._has_rejection(connection, capture_id):
|
|
||||||
connection.execute("COMMIT")
|
|
||||||
return "conflict"
|
|
||||||
current = connection.execute(
|
|
||||||
"SELECT classification_json, revision FROM capture_labels WHERE capture_id = ?",
|
|
||||||
(capture_id,),
|
|
||||||
).fetchone()
|
|
||||||
if current is None:
|
|
||||||
connection.execute(
|
|
||||||
"INSERT INTO capture_labels(capture_id, classification_json, revision, "
|
|
||||||
"labeled_at) VALUES (?, ?, 0, ?)",
|
|
||||||
(capture_id, encoded, _now()),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
current_json = current["classification_json"]
|
|
||||||
_stored_classification(current_json)
|
|
||||||
if current_json == encoded:
|
|
||||||
connection.execute("COMMIT")
|
|
||||||
return "updated"
|
|
||||||
revision = current["revision"]
|
|
||||||
revision_is_valid = (
|
|
||||||
not isinstance(revision, bool)
|
|
||||||
and isinstance(revision, int)
|
|
||||||
and revision >= 0
|
|
||||||
)
|
|
||||||
if not revision_is_valid:
|
|
||||||
raise StorageError("stored corpus label is invalid")
|
|
||||||
assert isinstance(revision, int) and not isinstance(revision, bool)
|
|
||||||
connection.execute(
|
|
||||||
"INSERT INTO capture_label_evidence(capture_id, replaced_revision, "
|
|
||||||
"classification_json, replaced_at) VALUES (?, ?, ?, ?)",
|
|
||||||
(capture_id, revision, current_json, _now()),
|
|
||||||
)
|
|
||||||
result = connection.execute(
|
|
||||||
"UPDATE capture_labels SET classification_json = ?, "
|
|
||||||
"revision = revision + 1, labeled_at = ? "
|
|
||||||
"WHERE capture_id = ? AND revision = ?",
|
|
||||||
(encoded, _now(), capture_id, revision),
|
|
||||||
)
|
|
||||||
if result.rowcount != 1:
|
|
||||||
connection.execute("ROLLBACK")
|
|
||||||
return "conflict"
|
|
||||||
connection.execute("COMMIT")
|
|
||||||
return "updated"
|
|
||||||
except Exception:
|
|
||||||
if connection.in_transaction:
|
|
||||||
connection.execute("ROLLBACK")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def reject_preserving_evidence(self, capture_id: str) -> MutationOutcome:
|
|
||||||
if not _capture_id(capture_id):
|
|
||||||
return "not_found"
|
|
||||||
with self.database.connect() as connection:
|
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
|
||||||
try:
|
|
||||||
if not self._exists(connection, capture_id):
|
|
||||||
connection.execute("COMMIT")
|
|
||||||
return "not_found"
|
|
||||||
if not self._has_rejection(connection, capture_id):
|
|
||||||
connection.execute(
|
|
||||||
"INSERT INTO capture_rejections(capture_id, rejected_at) VALUES (?, ?)",
|
|
||||||
(capture_id, _now()),
|
|
||||||
)
|
|
||||||
connection.execute("COMMIT")
|
|
||||||
return "updated"
|
|
||||||
except Exception:
|
|
||||||
if connection.in_transaction:
|
|
||||||
connection.execute("ROLLBACK")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def delete_with_retention(self, capture_id: str) -> MutationOutcome:
|
|
||||||
if not _capture_id(capture_id):
|
|
||||||
return "not_found"
|
|
||||||
try:
|
|
||||||
with self.blobs.operation_lock():
|
|
||||||
return self._delete_locked(capture_id)
|
|
||||||
except BlobLockError as error:
|
|
||||||
raise StorageError("blob operation is unavailable") from error
|
|
||||||
|
|
||||||
def _delete_locked(self, capture_id: str) -> MutationOutcome:
|
|
||||||
with self.database.connect() as connection:
|
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
|
||||||
try:
|
|
||||||
row = connection.execute(
|
|
||||||
"SELECT blob_sha256, state, received_at, receipt_token, "
|
|
||||||
"failure_reason, revision "
|
|
||||||
"FROM captures WHERE capture_id = ?",
|
|
||||||
(capture_id,),
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
connection.execute("COMMIT")
|
|
||||||
return "not_found"
|
|
||||||
if self._retained(connection, capture_id) or not self._deletable_capture(row):
|
|
||||||
connection.execute("COMMIT")
|
|
||||||
return "retained"
|
|
||||||
digest = row["blob_sha256"]
|
|
||||||
blob = connection.execute(
|
|
||||||
"SELECT byte_size FROM blobs WHERE sha256 = ?", (digest,)
|
|
||||||
).fetchone()
|
|
||||||
if (
|
|
||||||
not isinstance(digest, str)
|
|
||||||
or blob is None
|
|
||||||
or isinstance(blob["byte_size"], bool)
|
|
||||||
or not isinstance(blob["byte_size"], int)
|
|
||||||
or not 1 <= blob["byte_size"] <= self.max_media_bytes
|
|
||||||
):
|
|
||||||
connection.execute("COMMIT")
|
|
||||||
return "retained"
|
|
||||||
try:
|
|
||||||
self.blobs.verify(digest, expected_size=blob["byte_size"])
|
|
||||||
except (BlobCorruptionError, ValueError):
|
|
||||||
connection.execute("COMMIT")
|
|
||||||
return "retained"
|
|
||||||
references = connection.execute(
|
|
||||||
"SELECT count(*) FROM captures WHERE blob_sha256 = ?", (digest,)
|
|
||||||
).fetchone()[0]
|
|
||||||
if not isinstance(references, int) or references < 1:
|
|
||||||
connection.execute("ROLLBACK")
|
|
||||||
return "conflict"
|
|
||||||
if references == 1:
|
|
||||||
connection.execute(
|
|
||||||
"INSERT INTO capture_deletion_intents(blob_sha256, capture_id, byte_size, "
|
|
||||||
"capture_state, received_at, receipt_token, failure_reason, revision, "
|
|
||||||
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
(
|
|
||||||
digest,
|
|
||||||
capture_id,
|
|
||||||
blob["byte_size"],
|
|
||||||
row["state"],
|
|
||||||
row["received_at"],
|
|
||||||
row["receipt_token"],
|
|
||||||
row["failure_reason"],
|
|
||||||
row["revision"],
|
|
||||||
_now(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
result = connection.execute(
|
|
||||||
"DELETE FROM captures WHERE capture_id = ?", (capture_id,)
|
|
||||||
)
|
|
||||||
if result.rowcount != 1:
|
|
||||||
connection.execute("ROLLBACK")
|
|
||||||
return "conflict"
|
|
||||||
connection.execute("COMMIT")
|
|
||||||
if references > 1:
|
|
||||||
return "updated"
|
|
||||||
return (
|
|
||||||
"updated"
|
|
||||||
if self._reconcile_locked(limit=1, restore_on_failure=True)
|
|
||||||
else "retained"
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
if connection.in_transaction:
|
|
||||||
connection.execute("ROLLBACK")
|
|
||||||
raise
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _exists(connection: sqlite3.Connection, capture_id: str) -> bool:
|
|
||||||
return (
|
|
||||||
connection.execute(
|
|
||||||
"SELECT 1 FROM captures WHERE capture_id = ?", (capture_id,)
|
|
||||||
).fetchone()
|
|
||||||
is not None
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _has_rejection(connection: sqlite3.Connection, capture_id: str) -> bool:
|
|
||||||
return (
|
|
||||||
connection.execute(
|
|
||||||
"SELECT 1 FROM capture_rejections WHERE capture_id = ?", (capture_id,)
|
|
||||||
).fetchone()
|
|
||||||
is not None
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _retained(connection: sqlite3.Connection, capture_id: str) -> bool:
|
|
||||||
tables = (
|
|
||||||
"jobs",
|
|
||||||
"ocr_runs",
|
|
||||||
"capture_labels",
|
|
||||||
"capture_label_evidence",
|
|
||||||
"capture_rejections",
|
|
||||||
)
|
|
||||||
if any(
|
|
||||||
connection.execute(
|
|
||||||
f"SELECT 1 FROM {table} WHERE capture_id = ? LIMIT 1", (capture_id,)
|
|
||||||
).fetchone()
|
|
||||||
is not None
|
|
||||||
for table in tables
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
if (
|
|
||||||
connection.execute(
|
|
||||||
"SELECT 1 FROM runtime_failures WHERE subject_type = 'capture' "
|
|
||||||
"AND subject_id = ? LIMIT 1",
|
|
||||||
(capture_id,),
|
|
||||||
).fetchone()
|
|
||||||
is not None
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
return (
|
|
||||||
connection.execute(
|
|
||||||
"SELECT 1 FROM lineage WHERE child_id = ? OR parent_id = ? LIMIT 1",
|
|
||||||
(capture_id, capture_id),
|
|
||||||
).fetchone()
|
|
||||||
is not None
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _deletable_capture(row: sqlite3.Row) -> bool:
|
|
||||||
return row["state"] in {"stored", "deduplicated"} and row["failure_reason"] is None
|
|
||||||
|
|
||||||
def reconcile_deletions(self, *, limit: int = 64) -> int:
|
|
||||||
"""Finish bounded committed deletion intents after a crash or unlink failure."""
|
|
||||||
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 64:
|
|
||||||
raise ValueError("deletion reconciliation limit is invalid")
|
|
||||||
try:
|
|
||||||
with self.blobs.operation_lock():
|
|
||||||
return self._reconcile_locked(limit=limit, restore_on_failure=False)
|
|
||||||
except BlobLockError as error:
|
|
||||||
raise StorageError("blob operation is unavailable") from error
|
|
||||||
|
|
||||||
def _reconcile_locked(self, *, limit: int, restore_on_failure: bool) -> int:
|
|
||||||
with self.database.connect() as connection:
|
|
||||||
intents = list(
|
|
||||||
connection.execute(
|
|
||||||
"SELECT blob_sha256, byte_size, capture_id, capture_state, received_at, "
|
|
||||||
"receipt_token, failure_reason, revision FROM capture_deletion_intents "
|
|
||||||
"ORDER BY created_at, capture_id LIMIT ?",
|
|
||||||
(limit,),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
completed = 0
|
|
||||||
for intent in intents:
|
|
||||||
digest, size = intent["blob_sha256"], intent["byte_size"]
|
|
||||||
if not isinstance(digest, str) or isinstance(size, bool) or not isinstance(size, int):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
self.blobs.delete_verified(digest, expected_size=size)
|
|
||||||
except BlobCorruptionError:
|
|
||||||
try:
|
|
||||||
self.blobs._path(digest).lstat()
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
if restore_on_failure and self._restore_intent(intent):
|
|
||||||
continue
|
|
||||||
if restore_on_failure:
|
|
||||||
raise StorageError("blob deletion could not be completed") from None
|
|
||||||
continue
|
|
||||||
with self.database.connect() as connection:
|
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
|
||||||
try:
|
|
||||||
active = connection.execute(
|
|
||||||
"SELECT 1 FROM captures WHERE blob_sha256 = ?", (digest,)
|
|
||||||
).fetchone()
|
|
||||||
if active is not None:
|
|
||||||
connection.execute("ROLLBACK")
|
|
||||||
continue
|
|
||||||
result = connection.execute(
|
|
||||||
"DELETE FROM capture_deletion_intents WHERE blob_sha256 = ?", (digest,)
|
|
||||||
)
|
|
||||||
connection.execute("DELETE FROM blobs WHERE sha256 = ?", (digest,))
|
|
||||||
connection.execute("COMMIT")
|
|
||||||
completed += result.rowcount
|
|
||||||
except Exception:
|
|
||||||
if connection.in_transaction:
|
|
||||||
connection.execute("ROLLBACK")
|
|
||||||
raise
|
|
||||||
return completed
|
|
||||||
|
|
||||||
def _restore_intent(self, intent: sqlite3.Row) -> bool:
|
|
||||||
digest = intent["blob_sha256"]
|
|
||||||
size = intent["byte_size"]
|
|
||||||
if not isinstance(digest, str) or isinstance(size, bool) or not isinstance(size, int):
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
self.blobs.verify(digest, expected_size=size)
|
|
||||||
except (BlobCorruptionError, ValueError):
|
|
||||||
return False
|
|
||||||
with self.database.connect() as connection:
|
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
|
||||||
try:
|
|
||||||
connection.execute(
|
|
||||||
"INSERT INTO captures(capture_id, blob_sha256, state, received_at, "
|
|
||||||
"receipt_token, failure_reason, revision) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
(
|
|
||||||
intent["capture_id"],
|
|
||||||
digest,
|
|
||||||
intent["capture_state"],
|
|
||||||
intent["received_at"],
|
|
||||||
intent["receipt_token"],
|
|
||||||
intent["failure_reason"],
|
|
||||||
intent["revision"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
result = connection.execute(
|
|
||||||
"DELETE FROM capture_deletion_intents WHERE blob_sha256 = ?", (digest,)
|
|
||||||
)
|
|
||||||
connection.execute("COMMIT")
|
|
||||||
return result.rowcount == 1
|
|
||||||
except Exception:
|
|
||||||
if connection.in_transaction:
|
|
||||||
connection.execute("ROLLBACK")
|
|
||||||
raise
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _record(row: sqlite3.Row) -> CaptureRecord:
|
|
||||||
capture_id, state, received_at = (
|
|
||||||
row["capture_id"],
|
|
||||||
row["state"],
|
|
||||||
row["received_at"],
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
not _capture_id(capture_id)
|
|
||||||
or not isinstance(state, str)
|
|
||||||
or not isinstance(received_at, str)
|
|
||||||
or not 1 <= len(received_at) <= 40
|
|
||||||
or "\r" in received_at
|
|
||||||
or "\n" in received_at
|
|
||||||
):
|
|
||||||
raise StorageError("stored corpus record is invalid")
|
|
||||||
raw_label = row["classification_json"]
|
|
||||||
return CaptureRecord(
|
|
||||||
capture_id=capture_id,
|
|
||||||
state=state,
|
|
||||||
received_at=received_at,
|
|
||||||
classification=None if raw_label is None else _stored_classification(raw_label),
|
|
||||||
)
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
"""Private SQLite setup and immutable ordered utility migrations."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from stat import S_ISDIR, S_ISLNK
|
|
||||||
|
|
||||||
|
|
||||||
class MigrationError(RuntimeError):
|
|
||||||
"""The local migration history cannot safely be applied."""
|
|
||||||
|
|
||||||
|
|
||||||
def _private_directory(path: Path) -> None:
|
|
||||||
if path.is_symlink():
|
|
||||||
raise MigrationError("utility root must not be a symlink")
|
|
||||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
||||||
mode = path.lstat().st_mode
|
|
||||||
if S_ISLNK(mode) or not S_ISDIR(mode):
|
|
||||||
raise MigrationError("utility root must be a directory")
|
|
||||||
path.chmod(0o700)
|
|
||||||
|
|
||||||
|
|
||||||
class StorageDatabase:
|
|
||||||
def __init__(self, root: Path, migrations_dir: Path) -> None:
|
|
||||||
if not root.is_absolute() or not migrations_dir.is_absolute():
|
|
||||||
raise ValueError("storage paths must be absolute")
|
|
||||||
self.root = root
|
|
||||||
self.migrations_dir = migrations_dir
|
|
||||||
self.path = root / "capture.sqlite3"
|
|
||||||
|
|
||||||
def connect(self) -> sqlite3.Connection:
|
|
||||||
_private_directory(self.root)
|
|
||||||
if self.path.is_symlink():
|
|
||||||
raise MigrationError("database file must not be a symlink")
|
|
||||||
connection: sqlite3.Connection | None = None
|
|
||||||
try:
|
|
||||||
connection = sqlite3.connect(self.path, isolation_level=None, timeout=5.0)
|
|
||||||
connection.row_factory = sqlite3.Row
|
|
||||||
connection.execute("PRAGMA foreign_keys = ON")
|
|
||||||
journal = connection.execute("PRAGMA journal_mode = WAL").fetchone()[0]
|
|
||||||
connection.execute("PRAGMA busy_timeout = 5000")
|
|
||||||
connection.execute("PRAGMA synchronous = FULL")
|
|
||||||
checks = (
|
|
||||||
connection.execute("PRAGMA foreign_keys").fetchone()[0] == 1,
|
|
||||||
journal == "wal",
|
|
||||||
connection.execute("PRAGMA busy_timeout").fetchone()[0] == 5000,
|
|
||||||
connection.execute("PRAGMA synchronous").fetchone()[0] == 2,
|
|
||||||
)
|
|
||||||
if not all(checks):
|
|
||||||
raise MigrationError("SQLite connection invariants were not enabled")
|
|
||||||
for candidate in (
|
|
||||||
self.path,
|
|
||||||
self.path.with_name(self.path.name + "-wal"),
|
|
||||||
self.path.with_name(self.path.name + "-shm"),
|
|
||||||
):
|
|
||||||
if candidate.exists() and not candidate.is_symlink():
|
|
||||||
candidate.chmod(0o600)
|
|
||||||
return connection
|
|
||||||
except Exception:
|
|
||||||
if connection is not None:
|
|
||||||
connection.close()
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _migrations(self) -> list[tuple[int, Path, str]]:
|
|
||||||
found: list[tuple[int, Path, str]] = []
|
|
||||||
versions: set[int] = set()
|
|
||||||
for path in self.migrations_dir.glob("*.sql"):
|
|
||||||
if len(path.name) < 5 or not path.name[:3].isdigit() or path.name[3] != "_":
|
|
||||||
raise MigrationError("migration filename is invalid")
|
|
||||||
version = int(path.name[:3])
|
|
||||||
if version in versions:
|
|
||||||
raise MigrationError("migration versions must be unique")
|
|
||||||
versions.add(version)
|
|
||||||
checksum = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
||||||
found.append((version, path, checksum))
|
|
||||||
found.sort(key=lambda item: item[0])
|
|
||||||
if not found:
|
|
||||||
raise MigrationError("no storage migrations found")
|
|
||||||
return found
|
|
||||||
|
|
||||||
def migrate(self) -> None:
|
|
||||||
migrations = self._migrations()
|
|
||||||
with self.connect() as connection:
|
|
||||||
connection.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS schema_migrations ("
|
|
||||||
"version INTEGER PRIMARY KEY, filename TEXT NOT NULL UNIQUE, "
|
|
||||||
"checksum TEXT NOT NULL, applied_at TEXT NOT NULL)"
|
|
||||||
)
|
|
||||||
applied = list(
|
|
||||||
connection.execute(
|
|
||||||
"SELECT version, filename, checksum FROM schema_migrations ORDER BY version"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if len(applied) > len(migrations):
|
|
||||||
raise MigrationError("applied migration is no longer present")
|
|
||||||
expected_prefix = migrations[: len(applied)]
|
|
||||||
for row, expected in zip(applied, expected_prefix, strict=True):
|
|
||||||
version, path, checksum = expected
|
|
||||||
if (row["version"], row["filename"], row["checksum"]) != (
|
|
||||||
version,
|
|
||||||
path.name,
|
|
||||||
checksum,
|
|
||||||
):
|
|
||||||
raise MigrationError("migration history is not an exact ordered prefix")
|
|
||||||
for version, path, checksum in migrations[len(applied) :]:
|
|
||||||
sql = path.read_text(encoding="utf-8")
|
|
||||||
try:
|
|
||||||
applied_at = datetime.now(UTC).isoformat().replace("'", "''")
|
|
||||||
connection.executescript(
|
|
||||||
"BEGIN IMMEDIATE;\n"
|
|
||||||
+ sql
|
|
||||||
+ "\nINSERT INTO schema_migrations VALUES "
|
|
||||||
+ f"({version}, '{path.name}', '{checksum}', '{applied_at}');"
|
|
||||||
+ "\nCOMMIT;"
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
if connection.in_transaction:
|
|
||||||
connection.execute("ROLLBACK")
|
|
||||||
raise
|
|
||||||
os.chmod(self.path, 0o600)
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
"""Utility-local capture receipts and bounded insert-only provenance."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sqlite3
|
|
||||||
from collections.abc import Iterable
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
from game_data_capture.domain.state_machines import require_transition
|
|
||||||
from game_data_capture.storage.blobs import (
|
|
||||||
BlobCorruptionError,
|
|
||||||
BlobLockError,
|
|
||||||
ContentAddressedBlobStore,
|
|
||||||
)
|
|
||||||
from game_data_capture.storage.database import StorageDatabase
|
|
||||||
|
|
||||||
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
|
||||||
_MEDIA = re.compile(r"^(?:image/(?:png|webp|avif)|video/(?:mp4|webm|x-matroska))$")
|
|
||||||
_SECRET = re.compile(
|
|
||||||
r"(?i)(?:sk-[a-z0-9_-]{8,}|authorization\s*[:=]\s*bearer\s+\S+|"
|
|
||||||
r"(?:token|secret|password|api[_-]?key)\s*[:=]\s*\S+|bearer\s+\S+|/[^\s]+)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class StorageError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _now() -> str:
|
|
||||||
return datetime.now(UTC).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def _id(value: str) -> str:
|
|
||||||
if not isinstance(value, str) or not _ID.fullmatch(value):
|
|
||||||
raise ValueError("invalid local identifier")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _json(value: object, maximum: int) -> str:
|
|
||||||
encoded = json.dumps(value, separators=(",", ":"), ensure_ascii=False)
|
|
||||||
if len(encoded) > maximum:
|
|
||||||
raise ValueError("provenance payload exceeds its bound")
|
|
||||||
return encoded
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_failure(code: str, message: str) -> tuple[str, str]:
|
|
||||||
safe_code = re.sub(r"[^a-z0-9_]", "_", code.lower())[:64] or "storage_failure"
|
|
||||||
compact = " ".join(message.split())
|
|
||||||
compact = _SECRET.sub("[REDACTED]", compact)
|
|
||||||
if len(compact) > 512 or (len(compact) > 160 and " " not in compact[:160]):
|
|
||||||
compact = "operation failed [payload redacted]"
|
|
||||||
return safe_code, compact[:512] or "operation failed"
|
|
||||||
|
|
||||||
|
|
||||||
class CaptureRepository:
|
|
||||||
def __init__(self, database: StorageDatabase, blobs: ContentAddressedBlobStore) -> None:
|
|
||||||
self.database = database
|
|
||||||
self.blobs = blobs
|
|
||||||
|
|
||||||
def receive(
|
|
||||||
self,
|
|
||||||
capture_id: str,
|
|
||||||
receipt_token: str,
|
|
||||||
chunks: Iterable[bytes],
|
|
||||||
*,
|
|
||||||
media_type: str,
|
|
||||||
max_bytes: int,
|
|
||||||
) -> str:
|
|
||||||
_id(capture_id)
|
|
||||||
_id(receipt_token)
|
|
||||||
if not _MEDIA.fullmatch(media_type):
|
|
||||||
raise ValueError("unsupported media type")
|
|
||||||
try:
|
|
||||||
with self.blobs.operation_lock(), self.database.connect() as connection:
|
|
||||||
pending = connection.execute(
|
|
||||||
"SELECT 1 FROM capture_deletion_intents WHERE receipt_token = ?",
|
|
||||||
(receipt_token,),
|
|
||||||
).fetchone()
|
|
||||||
if pending is not None:
|
|
||||||
raise StorageError("blob deletion is pending")
|
|
||||||
existing = connection.execute(
|
|
||||||
"SELECT capture_id FROM captures WHERE receipt_token = ?", (receipt_token,)
|
|
||||||
).fetchone()
|
|
||||||
if existing is not None:
|
|
||||||
return str(existing["capture_id"])
|
|
||||||
except BlobLockError as error:
|
|
||||||
raise StorageError("blob operation is unavailable") from error
|
|
||||||
try:
|
|
||||||
digest, size = self.blobs.put(chunks, max_bytes=max_bytes)
|
|
||||||
except BlobLockError as error:
|
|
||||||
raise StorageError("blob operation is unavailable") from error
|
|
||||||
try:
|
|
||||||
with self.blobs.operation_lock():
|
|
||||||
self.blobs.verify(digest, expected_size=size)
|
|
||||||
with self.database.connect() as connection:
|
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
|
||||||
try:
|
|
||||||
pending = connection.execute(
|
|
||||||
"SELECT 1 FROM capture_deletion_intents WHERE blob_sha256 = ?",
|
|
||||||
(digest,),
|
|
||||||
).fetchone()
|
|
||||||
if pending is not None:
|
|
||||||
raise StorageError("blob deletion is pending")
|
|
||||||
existing = connection.execute(
|
|
||||||
"SELECT capture_id FROM captures WHERE receipt_token = ?",
|
|
||||||
(receipt_token,),
|
|
||||||
).fetchone()
|
|
||||||
if existing is not None:
|
|
||||||
connection.execute("COMMIT")
|
|
||||||
return str(existing["capture_id"])
|
|
||||||
connection.execute(
|
|
||||||
"INSERT OR IGNORE INTO blobs(sha256, byte_size, media_type, "
|
|
||||||
"created_at) "
|
|
||||||
"VALUES (?, ?, ?, ?)",
|
|
||||||
(digest, size, media_type, _now()),
|
|
||||||
)
|
|
||||||
duplicate = connection.execute(
|
|
||||||
"SELECT count(*) FROM captures WHERE blob_sha256 = ?", (digest,)
|
|
||||||
).fetchone()[0]
|
|
||||||
connection.execute(
|
|
||||||
"INSERT INTO captures(capture_id, blob_sha256, state, received_at, "
|
|
||||||
"receipt_token) VALUES (?, ?, ?, ?, ?)",
|
|
||||||
(
|
|
||||||
capture_id,
|
|
||||||
digest,
|
|
||||||
"deduplicated" if duplicate else "stored",
|
|
||||||
_now(),
|
|
||||||
receipt_token,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
connection.execute("COMMIT")
|
|
||||||
return capture_id
|
|
||||||
except Exception:
|
|
||||||
if connection.in_transaction:
|
|
||||||
connection.execute("ROLLBACK")
|
|
||||||
raise
|
|
||||||
except BlobLockError as error:
|
|
||||||
raise StorageError("blob operation is unavailable") from error
|
|
||||||
|
|
||||||
def transition(self, capture_id: str, target: str, expected_revision: int) -> int:
|
|
||||||
_id(capture_id)
|
|
||||||
with self.database.connect() as connection:
|
|
||||||
row = connection.execute(
|
|
||||||
"SELECT state FROM captures WHERE capture_id = ? AND revision = ?",
|
|
||||||
(capture_id, expected_revision),
|
|
||||||
).fetchone()
|
|
||||||
if row is None:
|
|
||||||
raise StorageError("stale or missing capture")
|
|
||||||
require_transition("capture", str(row["state"]), target)
|
|
||||||
result = connection.execute(
|
|
||||||
"UPDATE captures SET state = ?, revision = revision + 1 "
|
|
||||||
"WHERE capture_id = ? AND revision = ?",
|
|
||||||
(target, capture_id, expected_revision),
|
|
||||||
)
|
|
||||||
if result.rowcount != 1:
|
|
||||||
raise StorageError("stale capture transition")
|
|
||||||
return expected_revision + 1
|
|
||||||
|
|
||||||
def append_ocr_run(
|
|
||||||
self, record_id: str, capture_id: str, engine_id: str, engine_version: str, payload: object
|
|
||||||
) -> None:
|
|
||||||
if not 0 < len(engine_id) <= 100 or not 0 < len(engine_version) <= 100:
|
|
||||||
raise ValueError("OCR engine identity is invalid")
|
|
||||||
self._insert(
|
|
||||||
"ocr_runs",
|
|
||||||
(
|
|
||||||
record_id,
|
|
||||||
capture_id,
|
|
||||||
engine_id,
|
|
||||||
engine_version,
|
|
||||||
_now(),
|
|
||||||
"completed",
|
|
||||||
_json(payload, 1_000_000),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def append_region(self, record_id: str, run_id: str, payload: object) -> None:
|
|
||||||
self._insert("regions", (record_id, run_id, _json(payload, 10_000), _now()))
|
|
||||||
|
|
||||||
def append_classification(self, record_id: str, run_id: str, payload: object) -> None:
|
|
||||||
self._insert("classifications", (record_id, run_id, _json(payload, 10_000), _now()))
|
|
||||||
|
|
||||||
def append_candidate(self, record_id: str, run_id: str, payload: object) -> None:
|
|
||||||
self._insert(
|
|
||||||
"extraction_candidates", (record_id, run_id, _json(payload, 1_000_000), _now())
|
|
||||||
)
|
|
||||||
|
|
||||||
def append_routing(self, record_id: str, candidate_id: str, payload: object) -> None:
|
|
||||||
self._insert("routing_decisions", (record_id, candidate_id, _json(payload, 10_000), _now()))
|
|
||||||
|
|
||||||
def append_review(self, record_id: str, candidate_id: str, payload: object) -> None:
|
|
||||||
self._insert("review_events", (record_id, candidate_id, _json(payload, 10_000), _now()))
|
|
||||||
|
|
||||||
def append_lineage(self, record_id: str, child_id: str, parent_id: str, relation: str) -> None:
|
|
||||||
if not 0 < len(relation) <= 100:
|
|
||||||
raise ValueError("lineage relation is invalid")
|
|
||||||
self._insert("lineage", (record_id, child_id, parent_id, relation, _now()))
|
|
||||||
|
|
||||||
def append_export(self, record_id: str, state: str, manifest: object) -> None:
|
|
||||||
if not state or len(state) > 100:
|
|
||||||
raise ValueError("export state is invalid")
|
|
||||||
self._insert("exports", (record_id, state, _json(manifest, 1_000_000), _now()))
|
|
||||||
|
|
||||||
def _insert(self, table: str, values: tuple[object, ...]) -> None:
|
|
||||||
specs = {
|
|
||||||
"ocr_runs": (
|
|
||||||
"run_id,capture_id,engine_id,engine_version,created_at,state,payload_json",
|
|
||||||
(0, 1),
|
|
||||||
),
|
|
||||||
"regions": ("region_id,run_id,region_json,created_at", (0, 1)),
|
|
||||||
"classifications": ("classification_id,run_id,classification_json,created_at", (0, 1)),
|
|
||||||
"extraction_candidates": ("candidate_id,run_id,candidate_json,created_at", (0, 1)),
|
|
||||||
"routing_decisions": ("decision_id,candidate_id,decision_json,created_at", (0, 1)),
|
|
||||||
"review_events": ("event_id,candidate_id,event_json,created_at", (0, 1)),
|
|
||||||
"lineage": ("lineage_id,child_id,parent_id,relation,created_at", (0, 1, 2)),
|
|
||||||
"exports": ("export_id,state,manifest_json,created_at", (0,)),
|
|
||||||
}
|
|
||||||
columns, positions = specs[table]
|
|
||||||
if len(values) != len(columns.split(",")):
|
|
||||||
raise ValueError("provenance record arity is invalid")
|
|
||||||
for position in positions:
|
|
||||||
_id(str(values[position]))
|
|
||||||
placeholders = ", ".join("?" for _ in values)
|
|
||||||
with self.database.connect() as connection:
|
|
||||||
connection.execute(f"INSERT INTO {table} ({columns}) VALUES ({placeholders})", values)
|
|
||||||
|
|
||||||
def report(self) -> dict[str, int]:
|
|
||||||
tables = ("blobs", "captures", "jobs", "ocr_runs", "runtime_failures")
|
|
||||||
with self.database.connect() as connection:
|
|
||||||
return {
|
|
||||||
table: int(connection.execute(f"SELECT count(*) FROM {table}").fetchone()[0])
|
|
||||||
for table in tables
|
|
||||||
}
|
|
||||||
|
|
||||||
def verify(self) -> list[str]:
|
|
||||||
with self.database.connect() as connection:
|
|
||||||
return [
|
|
||||||
str(row["sha256"])
|
|
||||||
for row in connection.execute("SELECT sha256, byte_size FROM blobs")
|
|
||||||
if self._invalid_blob(row)
|
|
||||||
]
|
|
||||||
|
|
||||||
def _invalid_blob(self, row: sqlite3.Row) -> bool:
|
|
||||||
try:
|
|
||||||
digest = row["sha256"]
|
|
||||||
size = row["byte_size"]
|
|
||||||
if not isinstance(digest, str) or isinstance(size, bool) or not isinstance(size, int):
|
|
||||||
return True
|
|
||||||
self.blobs.verify(digest, expected_size=size)
|
|
||||||
except BlobCorruptionError:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def record_failure(
|
|
||||||
self, failure_id: str, subject_type: str, subject_id: str, code: str, message: str
|
|
||||||
) -> None:
|
|
||||||
if not re.fullmatch(r"[a-z_]{1,64}", subject_type):
|
|
||||||
raise ValueError("failure subject type is invalid")
|
|
||||||
code, message = sanitize_failure(code, message)
|
|
||||||
with self.database.connect() as connection:
|
|
||||||
connection.execute(
|
|
||||||
"INSERT INTO runtime_failures VALUES (?, ?, ?, ?, ?, ?)",
|
|
||||||
(_id(failure_id), subject_type, _id(subject_id), code, message, _now()),
|
|
||||||
)
|
|
||||||
@@ -1,567 +0,0 @@
|
|||||||
"""FastAPI factory for the local capture-and-labeling boundary.
|
|
||||||
|
|
||||||
Construction only retains injected ports and process-local session state. It does
|
|
||||||
not bind a listener, initialize runtime paths, open SQLite, arm capture, read a
|
|
||||||
clipboard, or create a child process.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Literal, Protocol, TypeVar
|
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
|
||||||
from fastapi.responses import JSONResponse, Response
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from starlette.staticfiles import StaticFiles
|
|
||||||
|
|
||||||
from game_data_capture.config import ServerConfig
|
|
||||||
from game_data_capture.domain.state_machines import CaptureState
|
|
||||||
from game_data_capture.domain.validation import ContractValidationError, validate_classification
|
|
||||||
from game_data_capture.web.security import (
|
|
||||||
AuthenticatedSession,
|
|
||||||
AuthenticationError,
|
|
||||||
BodyLimitExceeded,
|
|
||||||
ClientDisconnected,
|
|
||||||
LocalAuthority,
|
|
||||||
LocalRequestLimitsMiddleware,
|
|
||||||
RequestLimits,
|
|
||||||
SecurityError,
|
|
||||||
SessionStore,
|
|
||||||
response_security_headers,
|
|
||||||
set_session_cookie,
|
|
||||||
single_header,
|
|
||||||
validate_origin,
|
|
||||||
)
|
|
||||||
|
|
||||||
_CAPTURE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{7,63}$")
|
|
||||||
_STATE_VALUES = frozenset(state.value for state in CaptureState)
|
|
||||||
_IMAGE_MEDIA_TYPES = frozenset({"image/png", "image/webp", "image/avif"})
|
|
||||||
_OUTCOME_VALUES = frozenset({"updated", "not_found", "conflict", "retained"})
|
|
||||||
_PortResult = TypeVar("_PortResult")
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceUnavailable(RuntimeError):
|
|
||||||
"""An injected port cannot safely complete this local request."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class WebConfig:
|
|
||||||
"""Static local service settings supplied by runtime integration."""
|
|
||||||
|
|
||||||
server: ServerConfig
|
|
||||||
request_limits: RequestLimits = field(default_factory=RequestLimits)
|
|
||||||
max_capture_items: int = 64
|
|
||||||
max_image_bytes: int = 64 * 1024 * 1024
|
|
||||||
bootstrap_ttl_seconds: int = 600
|
|
||||||
session_ttl_seconds: int = 900
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
for value, maximum in (
|
|
||||||
(self.max_capture_items, 256),
|
|
||||||
(self.max_image_bytes, 64 * 1024 * 1024),
|
|
||||||
(self.bootstrap_ttl_seconds, 3600),
|
|
||||||
(self.session_ttl_seconds, 3600),
|
|
||||||
):
|
|
||||||
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= maximum:
|
|
||||||
raise ValueError("web service configuration is invalid")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def authority(self) -> LocalAuthority:
|
|
||||||
return LocalAuthority(self.server.host, self.server.port)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CaptureSessionStatus:
|
|
||||||
armed: bool
|
|
||||||
queue_depth: int
|
|
||||||
queue_capacity: int
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CaptureRecord:
|
|
||||||
"""A safe browser projection; ports must never place paths or OCR text here."""
|
|
||||||
|
|
||||||
capture_id: str
|
|
||||||
state: str
|
|
||||||
received_at: str
|
|
||||||
classification: Mapping[str, object] | None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class MediaDescriptor:
|
|
||||||
"""An opaque capture-media stream supplied by a corpus port, never a path."""
|
|
||||||
|
|
||||||
media_type: str
|
|
||||||
byte_size: int
|
|
||||||
chunks: Iterable[bytes]
|
|
||||||
|
|
||||||
|
|
||||||
MutationOutcome = Literal["updated", "not_found", "conflict", "retained"]
|
|
||||||
ControlOutcome = Literal["started", "already_armed", "stopped", "already_stopped", "conflict"]
|
|
||||||
|
|
||||||
|
|
||||||
class CaptureControlPort(Protocol):
|
|
||||||
"""Narrow control boundary implemented by later runtime integration."""
|
|
||||||
|
|
||||||
def status(self) -> CaptureSessionStatus: ...
|
|
||||||
|
|
||||||
def start_session(self) -> ControlOutcome: ...
|
|
||||||
|
|
||||||
def stop_session(self) -> ControlOutcome: ...
|
|
||||||
|
|
||||||
|
|
||||||
class CaptureCorpusPort(Protocol):
|
|
||||||
"""Narrow durable corpus boundary; routes never access SQLite or paths."""
|
|
||||||
|
|
||||||
def list_captures(self, *, limit: int) -> Sequence[CaptureRecord]: ...
|
|
||||||
|
|
||||||
def get_capture(self, capture_id: str) -> CaptureRecord | None: ...
|
|
||||||
|
|
||||||
def open_media(self, capture_id: str) -> MediaDescriptor | None: ...
|
|
||||||
|
|
||||||
def label(self, capture_id: str, classification: Mapping[str, object]) -> MutationOutcome: ...
|
|
||||||
|
|
||||||
def reject_preserving_evidence(self, capture_id: str) -> MutationOutcome: ...
|
|
||||||
|
|
||||||
def delete_with_retention(self, capture_id: str) -> MutationOutcome: ...
|
|
||||||
|
|
||||||
|
|
||||||
def _response(payload: Mapping[str, object], *, status_code: int = 200) -> JSONResponse:
|
|
||||||
return JSONResponse(
|
|
||||||
payload,
|
|
||||||
status_code=status_code,
|
|
||||||
headers={"Cache-Control": "no-store", **response_security_headers()},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _error(status_code: int, code: str) -> JSONResponse:
|
|
||||||
return _response({"error": code}, status_code=status_code)
|
|
||||||
|
|
||||||
|
|
||||||
def _call_port(operation: Callable[[], _PortResult]) -> _PortResult:
|
|
||||||
"""Translate every injected-port failure without retaining its raw message."""
|
|
||||||
|
|
||||||
try:
|
|
||||||
return operation()
|
|
||||||
except Exception as error:
|
|
||||||
raise ServiceUnavailable("local service operation failed") from error
|
|
||||||
|
|
||||||
|
|
||||||
def _valid_capture_id(capture_id: str) -> bool:
|
|
||||||
return bool(_CAPTURE_ID.fullmatch(capture_id))
|
|
||||||
|
|
||||||
|
|
||||||
def _status_projection(status: CaptureSessionStatus) -> dict[str, object]:
|
|
||||||
if (
|
|
||||||
not isinstance(status.armed, bool)
|
|
||||||
or isinstance(status.queue_depth, bool)
|
|
||||||
or isinstance(status.queue_capacity, bool)
|
|
||||||
or not isinstance(status.queue_depth, int)
|
|
||||||
or not isinstance(status.queue_capacity, int)
|
|
||||||
or not 0 <= status.queue_depth <= status.queue_capacity <= 256
|
|
||||||
):
|
|
||||||
raise ServiceUnavailable("capture control returned an invalid status")
|
|
||||||
return {
|
|
||||||
"armed": status.armed,
|
|
||||||
"queue_depth": status.queue_depth,
|
|
||||||
"queue_capacity": status.queue_capacity,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _classification_projection(classification: Mapping[str, object]) -> dict[str, object]:
|
|
||||||
# Reconstruct only the frozen contract keys before returning port data to a browser.
|
|
||||||
if not isinstance(classification, Mapping) or set(classification) != {
|
|
||||||
"family",
|
|
||||||
"view",
|
|
||||||
"gear_slot",
|
|
||||||
"record_types",
|
|
||||||
"confidence",
|
|
||||||
"abstention_reason",
|
|
||||||
}:
|
|
||||||
raise ServiceUnavailable("corpus returned an invalid classification")
|
|
||||||
payload = dict(classification)
|
|
||||||
try:
|
|
||||||
validate_classification(payload)
|
|
||||||
except ContractValidationError as error:
|
|
||||||
raise ServiceUnavailable("corpus returned an invalid classification") from error
|
|
||||||
family = payload["family"]
|
|
||||||
view = payload["view"]
|
|
||||||
gear_slot = payload["gear_slot"]
|
|
||||||
record_types = payload["record_types"]
|
|
||||||
confidence = payload["confidence"]
|
|
||||||
reason = payload["abstention_reason"]
|
|
||||||
if (
|
|
||||||
not isinstance(family, str)
|
|
||||||
or not isinstance(view, str)
|
|
||||||
or (gear_slot is not None and not isinstance(gear_slot, str))
|
|
||||||
or not isinstance(record_types, list)
|
|
||||||
or any(not isinstance(record_type, str) for record_type in record_types)
|
|
||||||
or isinstance(confidence, bool)
|
|
||||||
or not isinstance(confidence, int | float)
|
|
||||||
or (reason is not None and not isinstance(reason, str))
|
|
||||||
):
|
|
||||||
raise ServiceUnavailable("corpus returned an invalid classification")
|
|
||||||
return {
|
|
||||||
"family": family,
|
|
||||||
"view": view,
|
|
||||||
"gear_slot": gear_slot,
|
|
||||||
"record_types": record_types,
|
|
||||||
"confidence": confidence,
|
|
||||||
"abstention_reason": reason,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _record_projection(record: CaptureRecord) -> dict[str, object]:
|
|
||||||
if (
|
|
||||||
not isinstance(record.capture_id, str)
|
|
||||||
or not _valid_capture_id(record.capture_id)
|
|
||||||
or not isinstance(record.state, str)
|
|
||||||
or record.state not in _STATE_VALUES
|
|
||||||
or not isinstance(record.received_at, str)
|
|
||||||
or not 1 <= len(record.received_at) <= 40
|
|
||||||
or any(character in record.received_at for character in "\r\n")
|
|
||||||
):
|
|
||||||
raise ServiceUnavailable("corpus returned an invalid capture")
|
|
||||||
payload: dict[str, object] = {
|
|
||||||
"capture_id": record.capture_id,
|
|
||||||
"state": record.state,
|
|
||||||
"received_at": record.received_at,
|
|
||||||
"classification": None,
|
|
||||||
}
|
|
||||||
if record.classification is not None:
|
|
||||||
payload["classification"] = _classification_projection(record.classification)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _json_pairs(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
||||||
result: dict[str, object] = {}
|
|
||||||
for key, value in pairs:
|
|
||||||
if key in result:
|
|
||||||
raise ValueError("duplicate JSON key")
|
|
||||||
result[key] = value
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _reject_json_constant(value: str) -> object:
|
|
||||||
raise ValueError(f"unsupported JSON constant {value}")
|
|
||||||
|
|
||||||
|
|
||||||
async def _read_json(request: Request, *, maximum: int) -> dict[str, object]:
|
|
||||||
content_type = single_header(request, b"content-type", required=True)
|
|
||||||
if content_type is None:
|
|
||||||
raise SecurityError("unsupported request content type")
|
|
||||||
try:
|
|
||||||
media_type = content_type.decode("ascii")
|
|
||||||
except UnicodeDecodeError as error:
|
|
||||||
raise SecurityError("unsupported request content type") from error
|
|
||||||
if media_type.split(";", 1)[0].strip().lower() != "application/json":
|
|
||||||
raise SecurityError("unsupported request content type")
|
|
||||||
body = await request.body()
|
|
||||||
if not 2 <= len(body) <= maximum:
|
|
||||||
raise SecurityError("request JSON size is invalid")
|
|
||||||
try:
|
|
||||||
value = json.loads(
|
|
||||||
body.decode("utf-8"),
|
|
||||||
object_pairs_hook=_json_pairs,
|
|
||||||
parse_constant=_reject_json_constant,
|
|
||||||
)
|
|
||||||
except ValueError as error:
|
|
||||||
raise SecurityError("request JSON is invalid") from error
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise SecurityError("request JSON must be an object")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _bootstrap_payload(value: Mapping[str, object]) -> str:
|
|
||||||
if set(value) != {"token"}:
|
|
||||||
raise SecurityError("bootstrap payload is invalid")
|
|
||||||
token = value.get("token")
|
|
||||||
if not isinstance(token, str) or not 32 <= len(token) <= 128:
|
|
||||||
raise SecurityError("bootstrap payload is invalid")
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
|
||||||
def _confirmation_payload(value: Mapping[str, object], confirmation: str) -> None:
|
|
||||||
if set(value) != {"confirmation"} or value.get("confirmation") != confirmation:
|
|
||||||
raise SecurityError("confirmation payload is invalid")
|
|
||||||
|
|
||||||
|
|
||||||
def _label_payload(value: Mapping[str, object]) -> dict[str, object]:
|
|
||||||
expected = {
|
|
||||||
"family",
|
|
||||||
"view",
|
|
||||||
"gear_slot",
|
|
||||||
"record_types",
|
|
||||||
"confidence",
|
|
||||||
"abstention_reason",
|
|
||||||
}
|
|
||||||
if set(value) != expected:
|
|
||||||
raise SecurityError("label payload is invalid")
|
|
||||||
family = value.get("family")
|
|
||||||
view = value.get("view")
|
|
||||||
record_types = value.get("record_types")
|
|
||||||
reason = value.get("abstention_reason")
|
|
||||||
if (
|
|
||||||
not isinstance(family, str)
|
|
||||||
or not 1 <= len(family) <= 32
|
|
||||||
or not isinstance(view, str)
|
|
||||||
or not 1 <= len(view) <= 32
|
|
||||||
or not isinstance(record_types, list)
|
|
||||||
or not 0 <= len(record_types) <= 8
|
|
||||||
or any(
|
|
||||||
not isinstance(record_type, str) or not 1 <= len(record_type) <= 64
|
|
||||||
for record_type in record_types
|
|
||||||
)
|
|
||||||
or (reason is not None and (not isinstance(reason, str) or not 1 <= len(reason) <= 64))
|
|
||||||
):
|
|
||||||
raise SecurityError("label payload is invalid")
|
|
||||||
payload = dict(value)
|
|
||||||
try:
|
|
||||||
validate_classification(payload)
|
|
||||||
except ContractValidationError as error:
|
|
||||||
raise SecurityError("label payload is invalid") from error
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _outcome_response(outcome: MutationOutcome) -> tuple[int, str]:
|
|
||||||
if outcome not in _OUTCOME_VALUES:
|
|
||||||
raise ServiceUnavailable("corpus returned an invalid mutation outcome")
|
|
||||||
if outcome == "updated":
|
|
||||||
return 200, "updated"
|
|
||||||
if outcome == "not_found":
|
|
||||||
return 404, "not_found"
|
|
||||||
if outcome == "conflict":
|
|
||||||
return 409, "conflict"
|
|
||||||
return 409, "retained"
|
|
||||||
|
|
||||||
|
|
||||||
def _media_bytes(media: MediaDescriptor, maximum: int) -> tuple[str, bytes]:
|
|
||||||
"""Materialize and validate bounded port media before response headers exist."""
|
|
||||||
|
|
||||||
try:
|
|
||||||
if (
|
|
||||||
media.media_type not in _IMAGE_MEDIA_TYPES
|
|
||||||
or isinstance(media.byte_size, bool)
|
|
||||||
or not isinstance(media.byte_size, int)
|
|
||||||
or not 1 <= media.byte_size <= maximum
|
|
||||||
):
|
|
||||||
raise ServiceUnavailable("corpus returned unsafe media")
|
|
||||||
materialized = bytearray()
|
|
||||||
for chunk in media.chunks:
|
|
||||||
if not isinstance(chunk, bytes) or not chunk:
|
|
||||||
raise ServiceUnavailable("corpus returned unsafe media")
|
|
||||||
if (
|
|
||||||
len(materialized) + len(chunk) > media.byte_size
|
|
||||||
or len(materialized) + len(chunk) > maximum
|
|
||||||
):
|
|
||||||
raise ServiceUnavailable("corpus returned unsafe media")
|
|
||||||
materialized.extend(chunk)
|
|
||||||
if len(materialized) != media.byte_size:
|
|
||||||
raise ServiceUnavailable("corpus returned unsafe media")
|
|
||||||
return media.media_type, bytes(materialized)
|
|
||||||
except ServiceUnavailable:
|
|
||||||
raise
|
|
||||||
except Exception as error:
|
|
||||||
raise ServiceUnavailable("corpus media stream failed") from error
|
|
||||||
|
|
||||||
|
|
||||||
def _session_response(
|
|
||||||
payload: dict[str, object],
|
|
||||||
session_store: SessionStore,
|
|
||||||
session: AuthenticatedSession,
|
|
||||||
config: WebConfig,
|
|
||||||
) -> JSONResponse:
|
|
||||||
rotated = session_store.rotate(session)
|
|
||||||
payload["csrf_token"] = rotated.csrf_token
|
|
||||||
response = _response(payload)
|
|
||||||
set_session_cookie(response, rotated, max_age=config.session_ttl_seconds)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def create_app(
|
|
||||||
*,
|
|
||||||
config: WebConfig,
|
|
||||||
capture_control: CaptureControlPort,
|
|
||||||
corpus: CaptureCorpusPort,
|
|
||||||
bootstrap_token: str,
|
|
||||||
now: Callable[[], float] = time.monotonic,
|
|
||||||
) -> FastAPI:
|
|
||||||
"""Create an unbound app using only narrow, caller-injected local ports."""
|
|
||||||
|
|
||||||
authority = config.authority
|
|
||||||
sessions = SessionStore(
|
|
||||||
bootstrap_token,
|
|
||||||
now=now,
|
|
||||||
bootstrap_ttl_seconds=config.bootstrap_ttl_seconds,
|
|
||||||
session_ttl_seconds=config.session_ttl_seconds,
|
|
||||||
)
|
|
||||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
|
||||||
app.add_middleware(
|
|
||||||
LocalRequestLimitsMiddleware, authority=authority, limits=config.request_limits
|
|
||||||
)
|
|
||||||
app.mount(
|
|
||||||
"/static",
|
|
||||||
StaticFiles(
|
|
||||||
directory=Path(__file__).with_name("static"),
|
|
||||||
check_dir=False,
|
|
||||||
follow_symlink=False,
|
|
||||||
),
|
|
||||||
name="static",
|
|
||||||
)
|
|
||||||
templates = Jinja2Templates(directory=str(Path(__file__).with_name("templates")))
|
|
||||||
|
|
||||||
@app.exception_handler(AuthenticationError)
|
|
||||||
async def authentication_error(_: Request, __: AuthenticationError) -> JSONResponse:
|
|
||||||
return _error(401, "authentication_required")
|
|
||||||
|
|
||||||
@app.exception_handler(BodyLimitExceeded)
|
|
||||||
async def body_limit_error(_: Request, __: BodyLimitExceeded) -> JSONResponse:
|
|
||||||
return _error(413, "request_too_large")
|
|
||||||
|
|
||||||
@app.exception_handler(ClientDisconnected)
|
|
||||||
async def disconnect_error(_: Request, __: ClientDisconnected) -> JSONResponse:
|
|
||||||
return _error(400, "request_rejected")
|
|
||||||
|
|
||||||
@app.exception_handler(SecurityError)
|
|
||||||
async def security_error(_: Request, __: SecurityError) -> JSONResponse:
|
|
||||||
return _error(403, "request_forbidden")
|
|
||||||
|
|
||||||
@app.exception_handler(ServiceUnavailable)
|
|
||||||
async def service_error(_: Request, __: ServiceUnavailable) -> JSONResponse:
|
|
||||||
return _error(503, "service_unavailable")
|
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
|
||||||
async def unexpected_error(_: Request, __: Exception) -> JSONResponse:
|
|
||||||
return _error(503, "service_unavailable")
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
async def health() -> JSONResponse:
|
|
||||||
return _response({"status": "ok"})
|
|
||||||
|
|
||||||
@app.get("/", response_class=Response)
|
|
||||||
async def review_page(request: Request) -> Response:
|
|
||||||
return templates.TemplateResponse(request=request, name="index.html", context={})
|
|
||||||
|
|
||||||
@app.post("/api/bootstrap")
|
|
||||||
async def bootstrap(request: Request) -> JSONResponse:
|
|
||||||
validate_origin(request, authority)
|
|
||||||
session = sessions.bootstrap(_bootstrap_payload(await _read_json(request, maximum=256)))
|
|
||||||
response = _response({"csrf_token": session.csrf_token})
|
|
||||||
set_session_cookie(response, session, max_age=config.session_ttl_seconds)
|
|
||||||
return response
|
|
||||||
|
|
||||||
@app.get("/api/session")
|
|
||||||
async def session_status(request: Request) -> JSONResponse:
|
|
||||||
session = sessions.authenticate(request)
|
|
||||||
payload = _status_projection(_call_port(capture_control.status))
|
|
||||||
payload["csrf_token"] = session.csrf_token
|
|
||||||
return _response(payload)
|
|
||||||
|
|
||||||
@app.post("/api/session/start")
|
|
||||||
async def start_session(request: Request) -> JSONResponse:
|
|
||||||
session = sessions.require_mutation(request, authority)
|
|
||||||
_confirmation_payload(await _read_json(request, maximum=64), "START")
|
|
||||||
outcome = _call_port(capture_control.start_session)
|
|
||||||
if outcome not in {"started", "already_armed", "conflict"}:
|
|
||||||
raise ServiceUnavailable("capture control returned an invalid start outcome")
|
|
||||||
if outcome == "conflict":
|
|
||||||
return _error(409, "conflict")
|
|
||||||
payload = _status_projection(_call_port(capture_control.status))
|
|
||||||
payload["result"] = outcome
|
|
||||||
return _session_response(payload, sessions, session, config)
|
|
||||||
|
|
||||||
@app.post("/api/session/stop")
|
|
||||||
async def stop_session(request: Request) -> JSONResponse:
|
|
||||||
session = sessions.require_mutation(request, authority)
|
|
||||||
_confirmation_payload(await _read_json(request, maximum=64), "STOP")
|
|
||||||
outcome = _call_port(capture_control.stop_session)
|
|
||||||
if outcome not in {"stopped", "already_stopped", "conflict"}:
|
|
||||||
raise ServiceUnavailable("capture control returned an invalid stop outcome")
|
|
||||||
if outcome == "conflict":
|
|
||||||
return _error(409, "conflict")
|
|
||||||
payload = _status_projection(_call_port(capture_control.status))
|
|
||||||
payload["result"] = outcome
|
|
||||||
return _session_response(payload, sessions, session, config)
|
|
||||||
|
|
||||||
@app.get("/api/captures")
|
|
||||||
async def list_captures(request: Request) -> JSONResponse:
|
|
||||||
sessions.authenticate(request)
|
|
||||||
|
|
||||||
def list_projection() -> list[dict[str, object]]:
|
|
||||||
records = corpus.list_captures(limit=config.max_capture_items)
|
|
||||||
if len(records) > config.max_capture_items:
|
|
||||||
raise ValueError("corpus returned too many captures")
|
|
||||||
return [_record_projection(record) for record in records]
|
|
||||||
|
|
||||||
return _response({"captures": _call_port(list_projection)})
|
|
||||||
|
|
||||||
@app.get("/api/captures/{capture_id}")
|
|
||||||
async def get_capture(capture_id: str, request: Request) -> JSONResponse:
|
|
||||||
sessions.authenticate(request)
|
|
||||||
if not _valid_capture_id(capture_id):
|
|
||||||
return _error(404, "not_found")
|
|
||||||
record = _call_port(lambda: corpus.get_capture(capture_id))
|
|
||||||
if record is None:
|
|
||||||
return _error(404, "not_found")
|
|
||||||
return _response({"capture": _call_port(lambda: _record_projection(record))})
|
|
||||||
|
|
||||||
@app.get("/api/captures/{capture_id}/media")
|
|
||||||
async def capture_media(capture_id: str, request: Request) -> Response:
|
|
||||||
sessions.authenticate(request)
|
|
||||||
if not _valid_capture_id(capture_id):
|
|
||||||
return _error(404, "not_found")
|
|
||||||
media = _call_port(lambda: corpus.open_media(capture_id))
|
|
||||||
if media is None:
|
|
||||||
return _error(404, "not_found")
|
|
||||||
media_type, content = _call_port(lambda: _media_bytes(media, config.max_image_bytes))
|
|
||||||
return Response(
|
|
||||||
content=content,
|
|
||||||
media_type=media_type,
|
|
||||||
headers={
|
|
||||||
"Cache-Control": "private, no-store",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.post("/api/captures/{capture_id}/label")
|
|
||||||
async def label_capture(capture_id: str, request: Request) -> JSONResponse:
|
|
||||||
session = sessions.require_mutation(request, authority)
|
|
||||||
if not _valid_capture_id(capture_id):
|
|
||||||
return _error(404, "not_found")
|
|
||||||
classification = _label_payload(await _read_json(request, maximum=4096))
|
|
||||||
outcome = _call_port(lambda: corpus.label(capture_id, classification))
|
|
||||||
status, result = _outcome_response(outcome)
|
|
||||||
if status != 200:
|
|
||||||
return _error(status, result)
|
|
||||||
return _session_response({"result": result}, sessions, session, config)
|
|
||||||
|
|
||||||
@app.post("/api/captures/{capture_id}/reject")
|
|
||||||
async def reject_capture(capture_id: str, request: Request) -> JSONResponse:
|
|
||||||
session = sessions.require_mutation(request, authority)
|
|
||||||
if not _valid_capture_id(capture_id):
|
|
||||||
return _error(404, "not_found")
|
|
||||||
_confirmation_payload(await _read_json(request, maximum=64), "REJECT")
|
|
||||||
outcome = _call_port(lambda: corpus.reject_preserving_evidence(capture_id))
|
|
||||||
status, result = _outcome_response(outcome)
|
|
||||||
if status != 200:
|
|
||||||
return _error(status, result)
|
|
||||||
return _session_response({"result": result}, sessions, session, config)
|
|
||||||
|
|
||||||
@app.post("/api/captures/{capture_id}/delete")
|
|
||||||
async def delete_capture(capture_id: str, request: Request) -> JSONResponse:
|
|
||||||
session = sessions.require_mutation(request, authority)
|
|
||||||
if not _valid_capture_id(capture_id):
|
|
||||||
return _error(404, "not_found")
|
|
||||||
_confirmation_payload(await _read_json(request, maximum=64), "DELETE")
|
|
||||||
outcome = _call_port(lambda: corpus.delete_with_retention(capture_id))
|
|
||||||
status, result = _outcome_response(outcome)
|
|
||||||
if status != 200:
|
|
||||||
return _error(status, result)
|
|
||||||
return _session_response({"result": result}, sessions, session, config)
|
|
||||||
|
|
||||||
return app
|
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
"""Local-only request guards and process-local session state.
|
|
||||||
|
|
||||||
This module intentionally has no runtime, storage, filesystem, or server imports.
|
|
||||||
The runtime integration layer owns listener construction and must disable Uvicorn
|
|
||||||
proxy headers so forwarded request headers never affect this boundary.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ipaddress
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import secrets
|
|
||||||
import time
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import Request
|
|
||||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
||||||
|
|
||||||
_SECRET = re.compile(r"^[A-Za-z0-9_-]{32,128}$")
|
|
||||||
_COOKIE_NAME = "capture_session"
|
|
||||||
_RESPONSE_SECURITY_HEADERS: tuple[tuple[bytes, bytes], ...] = (
|
|
||||||
(
|
|
||||||
b"content-security-policy",
|
|
||||||
b"default-src 'self'; base-uri 'none'; object-src 'none'; frame-src 'none'; "
|
|
||||||
b"frame-ancestors 'none'; "
|
|
||||||
b"script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self'; "
|
|
||||||
b"media-src 'self'; connect-src 'self'; form-action 'self'",
|
|
||||||
),
|
|
||||||
(b"referrer-policy", b"no-referrer"),
|
|
||||||
(b"x-content-type-options", b"nosniff"),
|
|
||||||
(b"x-frame-options", b"DENY"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def response_security_headers() -> dict[str, str]:
|
|
||||||
"""Return response headers for errors generated outside the ASGI wrapper."""
|
|
||||||
|
|
||||||
return {
|
|
||||||
name.decode("ascii"): value.decode("ascii") for name, value in _RESPONSE_SECURITY_HEADERS
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class SecurityError(ValueError):
|
|
||||||
"""A request failed a security boundary without exposing its input."""
|
|
||||||
|
|
||||||
|
|
||||||
class AuthenticationError(SecurityError):
|
|
||||||
"""The process-local authenticated session is absent or expired."""
|
|
||||||
|
|
||||||
|
|
||||||
class BodyLimitExceeded(SecurityError):
|
|
||||||
"""The declared or streamed request body exceeded the configured limit."""
|
|
||||||
|
|
||||||
|
|
||||||
class ClientDisconnected(SecurityError):
|
|
||||||
"""The client disconnected before a route obtained a complete request body."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class LocalAuthority:
|
|
||||||
"""The single exact loopback authority accepted by the HTTP boundary."""
|
|
||||||
|
|
||||||
host: str
|
|
||||||
port: int
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
try:
|
|
||||||
address = ipaddress.ip_address(self.host)
|
|
||||||
except ValueError as error:
|
|
||||||
raise ValueError("web host must be a loopback IP literal") from error
|
|
||||||
if not address.is_loopback or not 1024 <= self.port <= 65535:
|
|
||||||
raise ValueError("web authority is invalid")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def authority(self) -> str:
|
|
||||||
return f"[{self.host}]:{self.port}" if ":" in self.host else f"{self.host}:{self.port}"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def origin(self) -> str:
|
|
||||||
return f"http://{self.authority}"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class RequestLimits:
|
|
||||||
max_header_bytes: int = 16 * 1024
|
|
||||||
max_headers: int = 64
|
|
||||||
max_body_bytes: int = 16 * 1024
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
for value in (self.max_header_bytes, self.max_headers, self.max_body_bytes):
|
|
||||||
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
||||||
raise ValueError("request limits must be positive integers")
|
|
||||||
|
|
||||||
|
|
||||||
def new_bootstrap_token() -> str:
|
|
||||||
"""Create a fragment-safe, one-time token for the local bootstrap mutation."""
|
|
||||||
|
|
||||||
return secrets.token_urlsafe(32)
|
|
||||||
|
|
||||||
|
|
||||||
def _header_values(scope: Scope, name: bytes) -> list[bytes]:
|
|
||||||
return [value for key, value in scope.get("headers", []) if key.lower() == name]
|
|
||||||
|
|
||||||
|
|
||||||
def _single_header(scope: Scope, name: bytes, *, required: bool) -> bytes | None:
|
|
||||||
values = _header_values(scope, name)
|
|
||||||
if len(values) != 1:
|
|
||||||
if not values and not required:
|
|
||||||
return None
|
|
||||||
raise SecurityError("ambiguous request header")
|
|
||||||
return values[0]
|
|
||||||
|
|
||||||
|
|
||||||
def single_header(request: Request, name: bytes, *, required: bool) -> bytes | None:
|
|
||||||
"""Return one unambiguous request header without trusting framework merging."""
|
|
||||||
|
|
||||||
return _single_header(request.scope, name, required=required)
|
|
||||||
|
|
||||||
|
|
||||||
def _ascii(value: bytes) -> str:
|
|
||||||
try:
|
|
||||||
return value.decode("ascii")
|
|
||||||
except UnicodeDecodeError as error:
|
|
||||||
raise SecurityError("invalid request header") from error
|
|
||||||
|
|
||||||
|
|
||||||
def validate_host(scope: Scope, authority: LocalAuthority) -> None:
|
|
||||||
value = _single_header(scope, b"host", required=True)
|
|
||||||
assert value is not None
|
|
||||||
if not secrets.compare_digest(_ascii(value), authority.authority):
|
|
||||||
raise SecurityError("unexpected request authority")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_origin(request: Request, authority: LocalAuthority) -> None:
|
|
||||||
value = _single_header(request.scope, b"origin", required=True)
|
|
||||||
assert value is not None
|
|
||||||
if not secrets.compare_digest(_ascii(value), authority.origin):
|
|
||||||
raise SecurityError("unexpected request origin")
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_content_length(scope: Scope, maximum: int) -> None:
|
|
||||||
value = _single_header(scope, b"content-length", required=False)
|
|
||||||
if value is None:
|
|
||||||
return
|
|
||||||
raw = _ascii(value)
|
|
||||||
if not raw.isascii() or not raw.isdecimal():
|
|
||||||
raise SecurityError("invalid content length")
|
|
||||||
if int(raw) > maximum:
|
|
||||||
raise BodyLimitExceeded("request body is too large")
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_rejection(send: Send, status: int, code: str) -> None:
|
|
||||||
body = json.dumps({"error": code}, separators=(",", ":")).encode("ascii")
|
|
||||||
await send(
|
|
||||||
{
|
|
||||||
"type": "http.response.start",
|
|
||||||
"status": status,
|
|
||||||
"headers": [
|
|
||||||
(b"content-type", b"application/json"),
|
|
||||||
(b"cache-control", b"no-store"),
|
|
||||||
(b"content-length", str(len(body)).encode("ascii")),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
await send({"type": "http.response.body", "body": body})
|
|
||||||
|
|
||||||
|
|
||||||
class LocalRequestLimitsMiddleware:
|
|
||||||
"""Validate headers and cap all ASGI body chunks before application parsing."""
|
|
||||||
|
|
||||||
def __init__(self, app: ASGIApp, *, authority: LocalAuthority, limits: RequestLimits) -> None:
|
|
||||||
self.app = app
|
|
||||||
self.authority = authority
|
|
||||||
self.limits = limits
|
|
||||||
|
|
||||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
||||||
if scope["type"] != "http":
|
|
||||||
await self.app(scope, receive, send)
|
|
||||||
return
|
|
||||||
|
|
||||||
async def secured_send(message: Message) -> None:
|
|
||||||
if message["type"] != "http.response.start":
|
|
||||||
await send(message)
|
|
||||||
return
|
|
||||||
headers = list(message.get("headers", []))
|
|
||||||
present = {name.lower() for name, _ in headers}
|
|
||||||
headers.extend(
|
|
||||||
(name, value) for name, value in _RESPONSE_SECURITY_HEADERS if name not in present
|
|
||||||
)
|
|
||||||
protected = dict(message)
|
|
||||||
protected["headers"] = headers
|
|
||||||
await send(protected)
|
|
||||||
|
|
||||||
headers = scope.get("headers", [])
|
|
||||||
if len(headers) > self.limits.max_headers or any(
|
|
||||||
len(name) + len(value) > self.limits.max_header_bytes for name, value in headers
|
|
||||||
):
|
|
||||||
await _send_rejection(secured_send, 431, "request_rejected")
|
|
||||||
return
|
|
||||||
if sum(len(name) + len(value) for name, value in headers) > self.limits.max_header_bytes:
|
|
||||||
await _send_rejection(secured_send, 431, "request_rejected")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
validate_host(scope, self.authority)
|
|
||||||
_validate_content_length(scope, self.limits.max_body_bytes)
|
|
||||||
except BodyLimitExceeded:
|
|
||||||
await _send_rejection(secured_send, 413, "request_too_large")
|
|
||||||
return
|
|
||||||
except SecurityError:
|
|
||||||
await _send_rejection(secured_send, 400, "request_rejected")
|
|
||||||
return
|
|
||||||
|
|
||||||
received = 0
|
|
||||||
request_complete = False
|
|
||||||
|
|
||||||
async def limited_receive() -> Message:
|
|
||||||
nonlocal received, request_complete
|
|
||||||
message = await receive()
|
|
||||||
if message["type"] == "http.disconnect":
|
|
||||||
if not request_complete:
|
|
||||||
raise ClientDisconnected("client disconnected")
|
|
||||||
return message
|
|
||||||
if message["type"] != "http.request":
|
|
||||||
return message
|
|
||||||
body = message.get("body", b"")
|
|
||||||
if not isinstance(body, bytes):
|
|
||||||
raise SecurityError("invalid request body")
|
|
||||||
received += len(body)
|
|
||||||
if received > self.limits.max_body_bytes:
|
|
||||||
raise BodyLimitExceeded("request body is too large")
|
|
||||||
if not message.get("more_body", False):
|
|
||||||
request_complete = True
|
|
||||||
return message
|
|
||||||
|
|
||||||
await self.app(scope, limited_receive, secured_send)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class AuthenticatedSession:
|
|
||||||
token: str
|
|
||||||
csrf_token: str
|
|
||||||
expires_at: float
|
|
||||||
|
|
||||||
|
|
||||||
class SessionStore:
|
|
||||||
"""An intentionally non-durable session store invalidated on process restart."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
bootstrap_token: str,
|
|
||||||
*,
|
|
||||||
now: Callable[[], float] = time.monotonic,
|
|
||||||
bootstrap_ttl_seconds: int = 600,
|
|
||||||
session_ttl_seconds: int = 900,
|
|
||||||
) -> None:
|
|
||||||
if not isinstance(bootstrap_token, str) or not _SECRET.fullmatch(bootstrap_token):
|
|
||||||
raise ValueError("bootstrap token is invalid")
|
|
||||||
if (
|
|
||||||
isinstance(bootstrap_ttl_seconds, bool)
|
|
||||||
or not isinstance(bootstrap_ttl_seconds, int)
|
|
||||||
or isinstance(session_ttl_seconds, bool)
|
|
||||||
or not isinstance(session_ttl_seconds, int)
|
|
||||||
or not 1 <= bootstrap_ttl_seconds <= 3600
|
|
||||||
or not 1 <= session_ttl_seconds <= 3600
|
|
||||||
):
|
|
||||||
raise ValueError("session lifetime is invalid")
|
|
||||||
self._bootstrap_token = bootstrap_token.encode("ascii")
|
|
||||||
self._bootstrap_expires_at = now() + bootstrap_ttl_seconds
|
|
||||||
self._bootstrap_used = False
|
|
||||||
self._now = now
|
|
||||||
self._session_ttl_seconds = session_ttl_seconds
|
|
||||||
self._sessions: dict[str, AuthenticatedSession] = {}
|
|
||||||
|
|
||||||
def bootstrap(self, submitted: str) -> AuthenticatedSession:
|
|
||||||
if (
|
|
||||||
self._bootstrap_used
|
|
||||||
or self._now() > self._bootstrap_expires_at
|
|
||||||
or not _SECRET.fullmatch(submitted)
|
|
||||||
or not secrets.compare_digest(submitted.encode("ascii"), self._bootstrap_token)
|
|
||||||
):
|
|
||||||
raise AuthenticationError("bootstrap authentication failed")
|
|
||||||
self._bootstrap_used = True
|
|
||||||
return self._new_session()
|
|
||||||
|
|
||||||
def authenticate(self, request: Request) -> AuthenticatedSession:
|
|
||||||
token = _session_cookie(request)
|
|
||||||
session = self._sessions.get(token)
|
|
||||||
if session is None or self._now() > session.expires_at:
|
|
||||||
self._sessions.pop(token, None)
|
|
||||||
raise AuthenticationError("session authentication failed")
|
|
||||||
return session
|
|
||||||
|
|
||||||
def require_mutation(self, request: Request, authority: LocalAuthority) -> AuthenticatedSession:
|
|
||||||
session = self.authenticate(request)
|
|
||||||
validate_origin(request, authority)
|
|
||||||
csrf = _single_header(request.scope, b"x-csrf-token", required=True)
|
|
||||||
assert csrf is not None
|
|
||||||
submitted = _ascii(csrf)
|
|
||||||
if not _SECRET.fullmatch(submitted) or not secrets.compare_digest(
|
|
||||||
submitted.encode("ascii"), session.csrf_token.encode("ascii")
|
|
||||||
):
|
|
||||||
raise SecurityError("csrf validation failed")
|
|
||||||
return session
|
|
||||||
|
|
||||||
def rotate(self, session: AuthenticatedSession) -> AuthenticatedSession:
|
|
||||||
current = self._sessions.get(session.token)
|
|
||||||
if current != session or self._now() > session.expires_at:
|
|
||||||
self._sessions.pop(session.token, None)
|
|
||||||
raise AuthenticationError("session authentication failed")
|
|
||||||
del self._sessions[session.token]
|
|
||||||
return self._new_session()
|
|
||||||
|
|
||||||
def _new_session(self) -> AuthenticatedSession:
|
|
||||||
session = AuthenticatedSession(
|
|
||||||
token=secrets.token_urlsafe(32),
|
|
||||||
csrf_token=secrets.token_urlsafe(32),
|
|
||||||
expires_at=self._now() + self._session_ttl_seconds,
|
|
||||||
)
|
|
||||||
self._sessions[session.token] = session
|
|
||||||
return session
|
|
||||||
|
|
||||||
|
|
||||||
def _session_cookie(request: Request) -> str:
|
|
||||||
raw = _single_header(request.scope, b"cookie", required=True)
|
|
||||||
assert raw is not None
|
|
||||||
pairs: dict[str, str] = {}
|
|
||||||
for segment in _ascii(raw).split(";"):
|
|
||||||
name, separator, value = segment.strip().partition("=")
|
|
||||||
if not separator or not name or name in pairs:
|
|
||||||
raise AuthenticationError("session authentication failed")
|
|
||||||
pairs[name] = value
|
|
||||||
token = pairs.get(_COOKIE_NAME)
|
|
||||||
if token is None or not _SECRET.fullmatch(token):
|
|
||||||
raise AuthenticationError("session authentication failed")
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
|
||||||
def set_session_cookie(response: Any, session: AuthenticatedSession, *, max_age: int) -> None:
|
|
||||||
"""Set a host-only HTTP-only local session cookie without a Domain attribute."""
|
|
||||||
|
|
||||||
response.set_cookie(
|
|
||||||
key=_COOKIE_NAME,
|
|
||||||
value=session.token,
|
|
||||||
max_age=max_age,
|
|
||||||
path="/",
|
|
||||||
secure=False,
|
|
||||||
httponly=True,
|
|
||||||
samesite="strict",
|
|
||||||
)
|
|
||||||
@@ -1,486 +0,0 @@
|
|||||||
:root {
|
|
||||||
--canvas: #0a0c0d;
|
|
||||||
--surface: #111415;
|
|
||||||
--surface-raised: #141718;
|
|
||||||
--well: #0d0f10;
|
|
||||||
--line: #303536;
|
|
||||||
--line-strong: #50595b;
|
|
||||||
--text: #ecebe5;
|
|
||||||
--muted: #a8afb1;
|
|
||||||
--dim: #7e8789;
|
|
||||||
--orange: #f6a821;
|
|
||||||
--orange-hover: #ffc45d;
|
|
||||||
--success: #8bd2a0;
|
|
||||||
--danger: #f09a86;
|
|
||||||
--info: #8cc8df;
|
|
||||||
--focus: #ffc45d;
|
|
||||||
--space: 1rem;
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--canvas);
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
background: var(--canvas);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
button,
|
|
||||||
input {
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
button,
|
|
||||||
input[type='radio'],
|
|
||||||
input[type='checkbox'] {
|
|
||||||
accent-color: var(--orange);
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
min-height: 44px;
|
|
||||||
border: 1px solid var(--line-strong);
|
|
||||||
border-radius: 0;
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.65rem 0.9rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
button:hover:not(:disabled) {
|
|
||||||
background: #1b2021;
|
|
||||||
border-color: var(--text);
|
|
||||||
}
|
|
||||||
button:active:not(:disabled) {
|
|
||||||
transform: translateY(1px);
|
|
||||||
}
|
|
||||||
button:disabled {
|
|
||||||
color: var(--dim);
|
|
||||||
cursor: not-allowed;
|
|
||||||
border-color: var(--line);
|
|
||||||
background: #101213;
|
|
||||||
}
|
|
||||||
.button-primary {
|
|
||||||
background: var(--orange);
|
|
||||||
color: #12100b;
|
|
||||||
border-color: var(--orange);
|
|
||||||
}
|
|
||||||
.button-primary:hover:not(:disabled) {
|
|
||||||
background: var(--orange-hover);
|
|
||||||
border-color: var(--orange-hover);
|
|
||||||
}
|
|
||||||
.button-danger {
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
.quiet-danger {
|
|
||||||
border-color: #6f4941;
|
|
||||||
}
|
|
||||||
|
|
||||||
:focus-visible {
|
|
||||||
outline: 3px solid var(--focus);
|
|
||||||
outline-offset: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skip-link {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 2;
|
|
||||||
top: 0;
|
|
||||||
left: 0.5rem;
|
|
||||||
transform: translateY(-120%);
|
|
||||||
background: var(--orange);
|
|
||||||
color: #12100b;
|
|
||||||
padding: 0.65rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.skip-link:focus {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shell {
|
|
||||||
width: min(100% - 2rem, 100rem);
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 1.5rem 0 3rem;
|
|
||||||
}
|
|
||||||
.command-header,
|
|
||||||
.control-strip,
|
|
||||||
.panel-heading {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space);
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.command-header {
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
padding: 0 0 1.25rem;
|
|
||||||
}
|
|
||||||
.kicker {
|
|
||||||
margin: 0 0 0.35rem;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.74rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
p {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
margin-bottom: 0;
|
|
||||||
font-size: clamp(1.65rem, 3vw, 2.35rem);
|
|
||||||
letter-spacing: -0.025em;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
margin-bottom: 0;
|
|
||||||
font-size: 1.15rem;
|
|
||||||
}
|
|
||||||
.header-state {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
.header-state strong,
|
|
||||||
#capture-state,
|
|
||||||
#review-state {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.88rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.header-state span,
|
|
||||||
#queue-state,
|
|
||||||
#capture-count,
|
|
||||||
.field-help,
|
|
||||||
.action-help,
|
|
||||||
figcaption {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.88rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notice {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-left: 2px solid var(--info);
|
|
||||||
margin: 1rem 0;
|
|
||||||
padding: 0.8rem 1rem;
|
|
||||||
background: var(--well);
|
|
||||||
}
|
|
||||||
.notice strong {
|
|
||||||
color: var(--info);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
}
|
|
||||||
.notice[data-kind='success'] {
|
|
||||||
border-left-color: var(--success);
|
|
||||||
}
|
|
||||||
.notice[data-kind='success'] strong {
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
.notice[data-kind='error'],
|
|
||||||
.notice[data-kind='conflict'] {
|
|
||||||
border-left-color: var(--danger);
|
|
||||||
}
|
|
||||||
.notice[data-kind='error'] strong,
|
|
||||||
.notice[data-kind='conflict'] strong {
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
.notice[data-kind='retained'] {
|
|
||||||
border-left-color: var(--info);
|
|
||||||
}
|
|
||||||
.notice[data-kind='retained'] strong {
|
|
||||||
color: var(--info);
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-strip {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: var(--surface-raised);
|
|
||||||
padding: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.control-strip p:last-child {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.control-actions,
|
|
||||||
.form-actions,
|
|
||||||
.dialog-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(17rem, 24rem) minmax(0, 1fr);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
.capture-panel {
|
|
||||||
border-right: 1px solid var(--line);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: min(42rem, calc(100vh - 10rem));
|
|
||||||
min-height: 24rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.capture-panel,
|
|
||||||
.review-panel {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
.panel-heading {
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
padding-bottom: 0.85rem;
|
|
||||||
}
|
|
||||||
.queue-filter {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.55rem;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 44px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.88rem;
|
|
||||||
}
|
|
||||||
.queue-filter input {
|
|
||||||
inline-size: 1.1rem;
|
|
||||||
block-size: 1.1rem;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
.capture-scroll {
|
|
||||||
min-height: 0;
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: auto;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
padding-right: 0.25rem;
|
|
||||||
scrollbar-gutter: stable;
|
|
||||||
}
|
|
||||||
.empty-copy,
|
|
||||||
.review-empty {
|
|
||||||
color: var(--muted);
|
|
||||||
padding: 1rem 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.capture-list {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
.capture-list button {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
gap: 0.25rem 0.6rem;
|
|
||||||
margin-top: 0.55rem;
|
|
||||||
text-align: left;
|
|
||||||
background: var(--well);
|
|
||||||
}
|
|
||||||
.capture-list button[aria-current='true'] {
|
|
||||||
border-color: var(--orange);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.capture-list .capture-id {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.capture-list .capture-meta {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.15rem;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
line-height: 1.2;
|
|
||||||
text-align: right;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.capture-list .capture-review {
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.capture-list .capture-time {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.review-content {
|
|
||||||
padding-top: 1rem;
|
|
||||||
}
|
|
||||||
.evidence-frame {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: var(--well);
|
|
||||||
padding: 0.65rem;
|
|
||||||
}
|
|
||||||
.evidence-frame img {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
max-height: 31rem;
|
|
||||||
object-fit: contain;
|
|
||||||
background: #000;
|
|
||||||
}
|
|
||||||
.evidence-frame figcaption {
|
|
||||||
padding-top: 0.65rem;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
fieldset {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
fieldset:disabled {
|
|
||||||
opacity: 0.65;
|
|
||||||
}
|
|
||||||
legend {
|
|
||||||
padding: 0 0.35rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.choice-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.45rem;
|
|
||||||
}
|
|
||||||
.choice {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
min-height: 44px;
|
|
||||||
align-items: center;
|
|
||||||
border: 1px solid var(--line-strong);
|
|
||||||
background: var(--well);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.choice:hover {
|
|
||||||
border-color: var(--text);
|
|
||||||
}
|
|
||||||
.choice input {
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0;
|
|
||||||
inset: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.choice span {
|
|
||||||
padding: 0.65rem 0.75rem;
|
|
||||||
}
|
|
||||||
.choice input:checked + span {
|
|
||||||
background: #3c2a0c;
|
|
||||||
color: var(--text);
|
|
||||||
box-shadow: inset 0 -2px var(--orange);
|
|
||||||
}
|
|
||||||
.choice input:focus-visible + span {
|
|
||||||
outline: 3px solid var(--focus);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
.validation-message {
|
|
||||||
min-height: 1.5rem;
|
|
||||||
margin: -0.35rem 0 0.75rem;
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
.action-help {
|
|
||||||
max-width: 70ch;
|
|
||||||
margin: 0.8rem 0 0;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog {
|
|
||||||
width: min(calc(100% - 2rem), 34rem);
|
|
||||||
border: 1px solid var(--orange);
|
|
||||||
border-radius: 0;
|
|
||||||
padding: 1.3rem;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--surface-raised);
|
|
||||||
box-shadow: 0 18px 60px #000;
|
|
||||||
}
|
|
||||||
dialog::backdrop {
|
|
||||||
background: rgb(0 0 0 / 72%);
|
|
||||||
}
|
|
||||||
dialog p {
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 52rem) {
|
|
||||||
.shell {
|
|
||||||
width: min(100% - 1rem, 100rem);
|
|
||||||
padding-top: 1rem;
|
|
||||||
}
|
|
||||||
.command-header,
|
|
||||||
.control-strip {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.header-state {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.workspace-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.capture-panel {
|
|
||||||
height: auto;
|
|
||||||
min-height: 0;
|
|
||||||
border-right: 0;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
.capture-scroll {
|
|
||||||
overflow: visible;
|
|
||||||
padding-right: 0;
|
|
||||||
scrollbar-gutter: auto;
|
|
||||||
}
|
|
||||||
.capture-list {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
|
||||||
gap: 0.55rem;
|
|
||||||
}
|
|
||||||
.capture-list button {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 33rem) {
|
|
||||||
.control-actions,
|
|
||||||
.form-actions,
|
|
||||||
.dialog-actions {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.control-actions button,
|
|
||||||
.form-actions button,
|
|
||||||
.dialog-actions button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.notice {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
scroll-behavior: auto !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
animation-iteration-count: 1 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (forced-colors: active) {
|
|
||||||
button,
|
|
||||||
fieldset,
|
|
||||||
.choice,
|
|
||||||
.notice,
|
|
||||||
.workspace-grid,
|
|
||||||
.evidence-frame {
|
|
||||||
border: 1px solid CanvasText;
|
|
||||||
}
|
|
||||||
.button-primary,
|
|
||||||
.choice input:checked + span {
|
|
||||||
forced-color-adjust: none;
|
|
||||||
background: Highlight;
|
|
||||||
color: HighlightText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user