commit 7c794d8bab4aaaca86e5e5693a9e05124306290a Author: Carl Niklas Rydberg Date: Sat Feb 7 20:11:15 2026 +0100 Implement amduatd v2 vertical slice and integration harness diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6271d23 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Local daemon/store runtime state +.amduat-asl/ +amduatd.sock +.cursor + +# Local configuration overrides +config/env.local + +# Logs and temp files +*.log +*.tmp +*.swp +*~ + +# Test/runtime artifacts +/tmp/ + +# OS/editor metadata +.DS_Store +Thumbs.db +.vscode/ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..dfd6edb --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# amduat-app-next (Scaffold) + +Starter project scaffold for building an app against `amduatd` v2. + +## Included handoff assets + +- `docs/v2-app-developer-guide.md` (compact app guide) +- `contracts/amduatd-api-contract.v2.json` (machine contract) +- `scripts/graph_client_helpers.sh` (reusable shell helpers) + +## Quick start + +1. Configure environment: + +```sh +cp config/env.example config/env.local +``` + +2. Run startup checks against a running `amduatd` socket: + +```sh +./scripts/bootstrap_check.sh +``` + +3. Run sample idempotent batch ingest: + +```sh +./scripts/ingest_example.sh +``` + +4. Run sample changes sync loop: + +```sh +./scripts/sync_loop.sh +``` + +## v2 Vertical Slice CLI + +Use the integrated v2 app flow wrapper: + +```sh +./scripts/v2_app.sh startup-check +./scripts/v2_app.sh ingest '{"idempotency_key":"k1","mode":"continue_on_error","nodes":[{"name":"doc:1"}]}' +./scripts/v2_app.sh sync-once +./scripts/v2_app.sh retrieve 'doc:1' 'ms.within_domain' +./scripts/v2_app.sh tombstone '' +``` + +Run integration coverage (requires running `amduatd` + `jq`): + +```sh +./tests/integration_v2.sh +``` + +## Notes + +- This scaffold assumes local Unix-socket access to `amduatd`. +- Graph cursors are opaque and must be persisted exactly as returned. +- Keep `contracts/amduatd-api-contract.v2.json` in sync with upstream when you pull updates. diff --git a/config/env.example b/config/env.example new file mode 100644 index 0000000..2c745e8 --- /dev/null +++ b/config/env.example @@ -0,0 +1,18 @@ +# Copy to config/env.local and edit as needed +SOCK="../amduatd.sock" +BASE="http://localhost" +SPACE="app1" + +# Incremental sync configuration +SYNC_LIMIT="200" +SYNC_WAIT_MS="15000" +CURSOR_FILE=".cursor" + +# Retry policy (applies to retryable server/internal failures) +RETRY_MAX_ATTEMPTS="4" +RETRY_INITIAL_MS="200" +RETRY_MAX_MS="2000" + +# Curl timeouts +CURL_CONNECT_TIMEOUT_SECONDS="2" +CURL_MAX_TIME_SECONDS="30" diff --git a/contracts/amduatd-api-contract.v2.json b/contracts/amduatd-api-contract.v2.json new file mode 100644 index 0000000..f99f9b3 --- /dev/null +++ b/contracts/amduatd-api-contract.v2.json @@ -0,0 +1,820 @@ +{ + "contract": "AMDUATD/API/2", + "base_path": "/v2", + "notes": "Draft v2: PEL-only write surface. Direct artifact write endpoint removed.", + "endpoints": [ + {"method": "GET", "path": "/v2/meta"}, + {"method": "HEAD", "path": "/v2/meta"}, + {"method": "GET", "path": "/v2/contract"}, + {"method": "GET", "path": "/v2/healthz"}, + {"method": "GET", "path": "/v2/readyz"}, + {"method": "GET", "path": "/v2/metrics"}, + {"method": "GET", "path": "/v2/artifacts/{ref}"}, + {"method": "HEAD", "path": "/v2/artifacts/{ref}"}, + {"method": "GET", "path": "/v2/artifacts/{ref}?format=info"}, + {"method": "POST", "path": "/v2/pel/execute"}, + {"method": "POST", "path": "/v2/ops/put"}, + {"method": "POST", "path": "/v2/ops/concat"}, + {"method": "POST", "path": "/v2/ops/slice"}, + {"method": "GET", "path": "/v2/jobs/{id}"}, + {"method": "GET", "path": "/v2/get/{ref}"}, + {"method": "POST", "path": "/v2/graph/nodes"}, + {"method": "POST", "path": "/v2/graph/nodes/{name}/versions"}, + {"method": "POST", "path": "/v2/graph/nodes/{name}/versions/tombstone"}, + {"method": "GET", "path": "/v2/graph/nodes/{name}/versions"}, + {"method": "GET", "path": "/v2/graph/nodes/{name}/neighbors"}, + {"method": "GET", "path": "/v2/graph/search"}, + {"method": "GET", "path": "/v2/graph/paths"}, + {"method": "GET", "path": "/v2/graph/subgraph"}, + {"method": "POST", "path": "/v2/graph/edges"}, + {"method": "POST", "path": "/v2/graph/edges/tombstone"}, + {"method": "POST", "path": "/v2/graph/batch"}, + {"method": "POST", "path": "/v2/graph/query"}, + {"method": "POST", "path": "/v2/graph/retrieve"}, + {"method": "POST", "path": "/v2/graph/export"}, + {"method": "POST", "path": "/v2/graph/import"}, + {"method": "GET", "path": "/v2/graph/schema/predicates"}, + {"method": "POST", "path": "/v2/graph/schema/predicates"}, + {"method": "GET", "path": "/v2/graph/stats"}, + {"method": "GET", "path": "/v2/graph/capabilities"}, + {"method": "GET", "path": "/v2/graph/changes"}, + {"method": "GET", "path": "/v2/graph/edges"}, + {"method": "GET", "path": "/v2/graph/nodes/{name}"}, + {"method": "GET", "path": "/v2/graph/history/{name}"} + ], + "schemas": { + "job_enqueue_response": { + "type": "object", + "required": ["job_id", "status"], + "properties": { + "job_id": {"type": "integer"}, + "status": {"type": "string"} + } + }, + "job_status_response": { + "type": "object", + "required": ["job_id", "kind", "status", "created_at_ms"], + "properties": { + "job_id": {"type": "integer"}, + "kind": {"type": "string"}, + "status": {"type": "string"}, + "created_at_ms": {"type": "integer"}, + "started_at_ms": {"type": ["integer", "null"]}, + "completed_at_ms": {"type": ["integer", "null"]}, + "result_ref": {"type": ["string", "null"]}, + "error": {"type": ["string", "null"]} + } + }, + "healthz_response": { + "type": "object", + "required": ["ok", "status", "time_ms"], + "properties": { + "ok": {"type": "boolean"}, + "status": {"type": "string"}, + "time_ms": {"type": "integer"} + } + }, + "readyz_response": { + "type": "object", + "required": ["ok", "status", "components"], + "properties": { + "ok": {"type": "boolean"}, + "status": {"type": "string"}, + "components": { + "type": "object", + "required": ["graph_index", "federation"], + "properties": { + "graph_index": {"type": "boolean"}, + "federation": {"type": "boolean"} + } + } + } + }, + "put_request": { + "type": "object", + "required": ["body_hex"], + "properties": { + "body_hex": {"type": "string"}, + "type_tag": {"type": "string"} + } + }, + "concat_request": { + "type": "object", + "required": ["left_ref", "right_ref"], + "properties": { + "left_ref": {"type": "string"}, + "right_ref": {"type": "string"} + } + }, + "slice_request": { + "type": "object", + "required": ["ref", "offset", "length"], + "properties": { + "ref": {"type": "string"}, + "offset": {"type": "integer"}, + "length": {"type": "integer"} + } + }, + "graph_node_create_request": { + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"}, + "ref": {"type": "string", "description": "optional initial published ref"} + } + }, + "graph_node_create_response": { + "type": "object", + "required": ["name", "concept_ref"], + "properties": { + "name": {"type": "string"}, + "concept_ref": {"type": "string"} + } + }, + "graph_provenance": { + "type": "object", + "required": ["source_uri", "extractor", "observed_at", "ingested_at", "trace_id"], + "properties": { + "source_uri": {"type": "string"}, + "extractor": {"type": "string"}, + "confidence": {"type": ["string", "number", "integer"]}, + "observed_at": {"type": "integer"}, + "ingested_at": {"type": "integer"}, + "license": {"type": "string"}, + "trace_id": {"type": "string"} + } + }, + "graph_edge_create_request": { + "type": "object", + "required": ["subject", "predicate", "object"], + "properties": { + "subject": {"type": "string", "description": "concept name or hex ref"}, + "predicate": {"type": "string", "description": "relation alias/name or hex ref"}, + "object": {"type": "string", "description": "concept name or hex ref"}, + "metadata_ref": {"type": "string", "description": "optional artifact ref"}, + "provenance": {"$ref": "#/schemas/graph_provenance"} + } + }, + "graph_edge_create_response": { + "type": "object", + "required": ["subject_ref", "predicate_ref", "object_ref", "edge_ref"], + "properties": { + "subject_ref": {"type": "string"}, + "predicate_ref": {"type": "string"}, + "object_ref": {"type": "string"}, + "edge_ref": {"type": "string"}, + "metadata_ref": {"type": "string"} + } + }, + "graph_edge_tombstone_request": { + "type": "object", + "required": ["edge_ref"], + "properties": { + "edge_ref": {"type": "string"}, + "metadata_ref": {"type": "string"}, + "provenance": {"$ref": "#/schemas/graph_provenance"} + } + }, + "graph_edge_tombstone_response": { + "type": "object", + "required": ["ok", "target_edge_ref", "tombstone_edge_ref"], + "properties": { + "ok": {"type": "boolean"}, + "target_edge_ref": {"type": "string"}, + "tombstone_edge_ref": {"type": "string"}, + "metadata_ref": {"type": "string"} + } + }, + "graph_node_version_tombstone_request": { + "type": "object", + "required": ["ref"], + "properties": { + "ref": {"type": "string"}, + "metadata_ref": {"type": "string"}, + "provenance": {"$ref": "#/schemas/graph_provenance"} + } + }, + "graph_node_version_tombstone_response": { + "type": "object", + "required": ["ok", "name", "ref", "target_edge_ref", "tombstone_edge_ref"], + "properties": { + "ok": {"type": "boolean"}, + "name": {"type": "string"}, + "ref": {"type": "string"}, + "target_edge_ref": {"type": "string"}, + "tombstone_edge_ref": {"type": "string"}, + "metadata_ref": {"type": "string"} + } + }, + "graph_batch_request": { + "type": "object", + "properties": { + "idempotency_key": {"type": "string"}, + "mode": {"type": "string", "enum": ["fail_fast", "continue_on_error"]}, + "nodes": { + "type": "array", + "items": { + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"}, + "ref": {"type": "string"} + } + } + }, + "versions": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "ref"], + "properties": { + "name": {"type": "string"}, + "ref": {"type": "string"}, + "metadata_ref": {"type": "string"}, + "provenance": {"$ref": "#/schemas/graph_provenance"} + } + } + }, + "edges": { + "type": "array", + "items": { + "type": "object", + "required": ["subject", "predicate", "object"], + "properties": { + "subject": {"type": "string"}, + "predicate": {"type": "string"}, + "object": {"type": "string"}, + "metadata_ref": {"type": "string"}, + "provenance": {"$ref": "#/schemas/graph_provenance"} + } + } + } + } + }, + "graph_batch_response": { + "type": "object", + "required": ["ok", "applied", "results"], + "properties": { + "ok": {"type": "boolean"}, + "idempotency_key": {"type": "string"}, + "mode": {"type": "string", "enum": ["fail_fast", "continue_on_error"]}, + "applied": { + "type": "object", + "required": ["nodes", "versions", "edges"], + "properties": { + "nodes": {"type": "integer"}, + "versions": {"type": "integer"}, + "edges": {"type": "integer"} + } + }, + "results": { + "type": "array", + "items": { + "type": "object", + "required": ["kind", "index", "status", "code", "error"], + "properties": { + "kind": {"type": "string", "enum": ["node", "version", "edge"]}, + "index": {"type": "integer"}, + "status": {"type": "string", "enum": ["applied", "error"]}, + "code": {"type": "integer"}, + "error": {"type": ["string", "null"]} + } + } + } + } + }, + "graph_query_request": { + "type": "object", + "properties": { + "where": { + "type": "object", + "properties": { + "subject": {"type": "string"}, + "object": {"type": "string"}, + "node": {"type": "string"}, + "provenance_ref": {"type": "string"} + } + }, + "predicates": {"type": "array", "items": {"type": "string"}}, + "direction": {"type": "string", "enum": ["any", "outgoing", "incoming"]}, + "include_versions": {"type": "boolean"}, + "include_tombstoned": {"type": "boolean"}, + "include_stats": {"type": "boolean"}, + "max_result_bytes": {"type": "integer"}, + "as_of": {"type": ["string", "integer"]}, + "limit": {"type": "integer"}, + "cursor": {"type": ["string", "integer"]} + } + }, + "graph_query_response": { + "type": "object", + "required": ["nodes", "edges", "paging"], + "properties": { + "nodes": { + "type": "array", + "items": { + "type": "object", + "required": ["concept_ref", "name", "latest_ref"], + "properties": { + "concept_ref": {"type": "string"}, + "name": {"type": ["string", "null"]}, + "latest_ref": {"type": ["string", "null"]}, + "versions": { + "type": "array", + "items": { + "type": "object", + "required": ["edge_ref", "ref"], + "properties": { + "edge_ref": {"type": "string"}, + "ref": {"type": "string"} + } + } + } + } + } + }, + "edges": { + "type": "array", + "items": { + "type": "object", + "required": ["subject_ref", "predicate_ref", "object_ref", "edge_ref"], + "properties": { + "subject_ref": {"type": "string"}, + "predicate_ref": {"type": "string"}, + "object_ref": {"type": "string"}, + "edge_ref": {"type": "string"} + } + } + }, + "paging": { + "type": "object", + "required": ["next_cursor", "has_more"], + "properties": { + "next_cursor": {"type": ["string", "null"]}, + "has_more": {"type": "boolean"} + } + }, + "stats": { + "type": "object", + "properties": { + "scanned_edges": {"type": "integer"}, + "returned_edges": {"type": "integer"} + } + } + } + }, + "graph_retrieve_request": { + "type": "object", + "required": ["roots"], + "properties": { + "roots": {"type": "array", "items": {"type": "string"}}, + "goal_predicates": {"type": "array", "items": {"type": "string"}}, + "max_depth": {"type": "integer"}, + "max_fanout": {"type": "integer"}, + "include_versions": {"type": "boolean"}, + "include_tombstoned": {"type": "boolean"}, + "as_of": {"type": ["string", "integer"]}, + "provenance_min_confidence": {"type": ["string", "number", "integer"]}, + "limit_nodes": {"type": "integer"}, + "limit_edges": {"type": "integer"}, + "max_result_bytes": {"type": "integer"} + } + }, + "graph_retrieve_response": { + "type": "object", + "required": ["nodes", "edges", "explanations", "truncated", "stats"], + "properties": { + "nodes": { + "type": "array", + "items": { + "type": "object", + "required": ["concept_ref", "name", "latest_ref"], + "properties": { + "concept_ref": {"type": "string"}, + "name": {"type": ["string", "null"]}, + "latest_ref": {"type": ["string", "null"]}, + "versions": { + "type": "array", + "items": { + "type": "object", + "required": ["edge_ref", "ref"], + "properties": { + "edge_ref": {"type": "string"}, + "ref": {"type": "string"} + } + } + } + } + } + }, + "edges": { + "type": "array", + "items": { + "type": "object", + "required": ["subject_ref", "predicate_ref", "object_ref", "edge_ref"], + "properties": { + "subject_ref": {"type": "string"}, + "predicate_ref": {"type": "string"}, + "object_ref": {"type": "string"}, + "edge_ref": {"type": "string"} + } + } + }, + "explanations": { + "type": "array", + "items": { + "type": "object", + "required": ["edge_ref", "depth", "reasons"], + "properties": { + "edge_ref": {"type": "string"}, + "depth": {"type": "integer"}, + "reasons": {"type": "array", "items": {"type": "string"}}, + "confidence": {"type": ["number", "null"]} + } + } + }, + "truncated": {"type": "boolean"}, + "stats": { + "type": "object", + "properties": { + "scanned_edges": {"type": "integer"}, + "traversed_edges": {"type": "integer"}, + "returned_nodes": {"type": "integer"}, + "returned_edges": {"type": "integer"} + } + } + } + }, + "graph_export_request": { + "type": "object", + "properties": { + "as_of": {"type": ["string", "integer"]}, + "cursor": {"type": ["string", "integer"]}, + "limit": {"type": "integer"}, + "predicates": {"type": "array", "items": {"type": "string"}}, + "roots": {"type": "array", "items": {"type": "string"}}, + "include_tombstoned": {"type": "boolean"}, + "max_result_bytes": {"type": "integer"} + } + }, + "graph_export_response": { + "type": "object", + "required": ["items", "next_cursor", "has_more", "snapshot_as_of", "stats"], + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "required": ["seq", "edge_ref", "subject_ref", "predicate_ref", "predicate", "object_ref", "tombstoned"], + "properties": { + "seq": {"type": "integer"}, + "edge_ref": {"type": "string"}, + "subject_ref": {"type": "string"}, + "predicate_ref": {"type": "string"}, + "predicate": {"type": "string"}, + "object_ref": {"type": "string"}, + "tombstoned": {"type": "boolean"}, + "metadata_ref": {"type": ["string", "null"]} + } + } + }, + "next_cursor": {"type": ["string", "null"]}, + "has_more": {"type": "boolean"}, + "snapshot_as_of": {"type": "string"}, + "stats": { + "type": "object", + "properties": { + "scanned_edges": {"type": "integer"}, + "exported_items": {"type": "integer"} + } + } + } + }, + "graph_import_request": { + "type": "object", + "required": ["items"], + "properties": { + "mode": {"type": "string", "enum": ["fail_fast", "continue_on_error"]}, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "subject_ref": {"type": "string"}, + "subject": {"type": "string"}, + "predicate_ref": {"type": "string"}, + "predicate": {"type": "string"}, + "object_ref": {"type": "string"}, + "object": {"type": "string"}, + "metadata_ref": {"type": "string"} + } + } + } + } + }, + "graph_import_response": { + "type": "object", + "required": ["ok", "applied", "results"], + "properties": { + "ok": {"type": "boolean"}, + "applied": {"type": "integer"}, + "results": { + "type": "array", + "items": { + "type": "object", + "required": ["index", "status", "code", "error", "edge_ref"], + "properties": { + "index": {"type": "integer"}, + "status": {"type": "string", "enum": ["applied", "error"]}, + "code": {"type": "integer"}, + "error": {"type": ["string", "null"]}, + "edge_ref": {"type": ["string", "null"]} + } + } + } + } + }, + "graph_schema_predicates_request": { + "type": "object", + "properties": { + "mode": {"type": "string", "enum": ["strict", "warn", "off"]}, + "provenance_mode": {"type": "string", "enum": ["optional", "required"]}, + "predicates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "predicate_ref": {"type": "string"}, + "predicate": {"type": "string"}, + "domain": {"type": "string"}, + "range": {"type": "string"} + } + } + } + } + }, + "graph_schema_predicates_response": { + "type": "object", + "required": ["mode", "provenance_mode", "predicates"], + "properties": { + "mode": {"type": "string", "enum": ["strict", "warn", "off"]}, + "provenance_mode": {"type": "string", "enum": ["optional", "required"]}, + "predicates": { + "type": "array", + "items": { + "type": "object", + "required": ["predicate_ref", "domain", "range"], + "properties": { + "predicate_ref": {"type": "string"}, + "domain": {"type": ["string", "null"]}, + "range": {"type": ["string", "null"]} + } + } + } + } + }, + "graph_stats_response": { + "type": "object", + "required": ["edges_total", "aliases_total", "index", "tombstones"], + "properties": { + "edges_total": {"type": "integer"}, + "aliases_total": {"type": "integer"}, + "index": { + "type": "object", + "required": ["built_for_edges", "src_buckets", "dst_buckets", "predicate_buckets", "src_predicate_buckets", "dst_predicate_buckets", "healthy"], + "properties": { + "built_for_edges": {"type": "integer"}, + "src_buckets": {"type": "integer"}, + "dst_buckets": {"type": "integer"}, + "predicate_buckets": {"type": "integer"}, + "src_predicate_buckets": {"type": "integer"}, + "dst_predicate_buckets": {"type": "integer"}, + "healthy": {"type": "boolean"} + } + }, + "tombstones": { + "type": "object", + "required": ["edges", "ratio"], + "properties": { + "edges": {"type": "integer"}, + "ratio": {"type": "number"} + } + } + } + }, + "graph_capabilities_response": { + "type": "object", + "required": ["contract", "graph", "runtime"], + "properties": { + "contract": {"type": "string"}, + "graph": { + "type": "object", + "required": ["version", "features", "limits", "modes"], + "properties": { + "version": {"type": "string"}, + "features": {"type": "array", "items": {"type": "string"}}, + "limits": {"type": "object"}, + "modes": {"type": "object"} + } + }, + "runtime": {"type": "object"} + } + }, + "graph_changes_response": { + "type": "object", + "required": ["events", "next_cursor", "has_more"], + "properties": { + "events": { + "type": "array", + "items": { + "type": "object", + "required": ["event", "cursor", "edge_ref", "subject_ref", "predicate_ref", "object_ref"], + "properties": { + "event": {"type": "string", "enum": ["edge_appended", "version_published", "tombstone_applied"]}, + "cursor": {"type": "string"}, + "edge_ref": {"type": "string"}, + "subject_ref": {"type": "string"}, + "predicate_ref": {"type": "string"}, + "object_ref": {"type": "string"}, + "concept_ref": {"type": "string"}, + "ref": {"type": "string"}, + "tombstoned_edge_ref": {"type": "string"} + } + } + }, + "next_cursor": {"type": ["string", "null"]}, + "has_more": {"type": "boolean"} + } + }, + "graph_node_response": { + "type": "object", + "required": ["name", "concept_ref", "latest_ref", "versions", "outgoing", "incoming"], + "properties": { + "name": {"type": "string"}, + "concept_ref": {"type": "string"}, + "latest_ref": {"type": ["string", "null"]}, + "versions": {"type": "array", "items": {"type": "string"}}, + "outgoing": { + "type": "array", + "items": { + "type": "object", + "required": ["predicate_ref", "object_ref"], + "properties": { + "predicate_ref": {"type": "string"}, + "object_ref": {"type": "string"}, + "edge_ref": {"type": "string"}, + "metadata_ref": {"type": ["string", "null"]} + } + } + }, + "incoming": { + "type": "array", + "items": { + "type": "object", + "required": ["predicate_ref", "subject_ref"], + "properties": { + "predicate_ref": {"type": "string"}, + "subject_ref": {"type": "string"}, + "edge_ref": {"type": "string"}, + "metadata_ref": {"type": ["string", "null"]} + } + } + } + } + }, + "graph_history_response": { + "type": "object", + "required": ["name", "events"], + "properties": { + "name": {"type": "string"}, + "events": { + "type": "array", + "items": { + "type": "object", + "required": ["event", "at_ms"], + "properties": { + "event": {"type": "string"}, + "at_ms": {"type": "integer"}, + "ref": {"type": ["string", "null"]}, + "edge_ref": {"type": ["string", "null"]}, + "details": {"type": "object"} + } + } + } + } + }, + "pel_execute_request": { + "type": "object", + "required": ["program_ref", "inputs", "receipt"], + "properties": { + "program_ref": {"type": "string", "description": "hex ref or concept name"}, + "scheme_ref": {"type": "string", "description": "hex ref or 'dag'"}, + "params_ref": {"type": "string", "description": "hex ref or concept name"}, + "inputs": { + "type": "object", + "properties": { + "refs": { + "type": "array", + "items": {"type": "string", "description": "hex ref or concept name"} + }, + "inline_artifacts": { + "type": "array", + "items": { + "type": "object", + "required": ["body_hex"], + "properties": { + "content_type": {"type": "string"}, + "type_tag": {"type": "string", "description": "hex tag id, optional"}, + "body_hex": {"type": "string"} + } + } + } + } + }, + "receipt": { + "type": "object", + "required": [ + "input_manifest_ref", + "environment_ref", + "evaluator_id", + "executor_ref", + "started_at", + "completed_at" + ], + "properties": { + "input_manifest_ref": {"type": "string", "description": "hex ref or concept name"}, + "environment_ref": {"type": "string", "description": "hex ref or concept name"}, + "evaluator_id": {"type": "string"}, + "executor_ref": {"type": "string", "description": "hex ref or concept name"}, + "sbom_ref": {"type": "string", "description": "hex ref or concept name"}, + "parity_digest_hex": {"type": "string"}, + "executor_fingerprint_ref": {"type": "string", "description": "hex ref or concept name"}, + "run_id_hex": {"type": "string"}, + "limits": { + "type": "object", + "required": ["cpu_ms", "wall_ms", "max_rss_kib", "io_reads", "io_writes"], + "properties": { + "cpu_ms": {"type": "integer"}, + "wall_ms": {"type": "integer"}, + "max_rss_kib": {"type": "integer"}, + "io_reads": {"type": "integer"}, + "io_writes": {"type": "integer"} + } + }, + "logs": { + "type": "array", + "items": { + "type": "object", + "required": ["kind", "log_ref", "sha256_hex"], + "properties": { + "kind": {"type": "integer"}, + "log_ref": {"type": "string", "description": "hex ref or concept name"}, + "sha256_hex": {"type": "string"} + } + } + }, + "determinism_level": {"type": "integer", "description": "0-255"}, + "rng_seed_hex": {"type": "string"}, + "signature_hex": {"type": "string"}, + "started_at": {"type": "integer"}, + "completed_at": {"type": "integer"} + } + }, + "effects": { + "type": "object", + "properties": { + "publish_outputs": {"type": "boolean"}, + "append_fed_log": {"type": "boolean"} + } + } + } + }, + "pel_execute_response": { + "type": "object", + "required": ["run_ref", "receipt_ref", "stored_input_refs", "output_refs", "status"], + "properties": { + "run_ref": {"type": "string", "description": "hex ref"}, + "trace_ref": {"type": "string", "description": "hex ref"}, + "receipt_ref": {"type": "string", "description": "hex ref"}, + "stored_input_refs": {"type": "array", "items": {"type": "string", "description": "hex ref"}}, + "output_refs": {"type": "array", "items": {"type": "string", "description": "hex ref"}}, + "status": {"type": "string"} + } + }, + "error_response": { + "type": "object", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message", "retryable"], + "properties": { + "code": {"type": "string"}, + "message": {"type": "string"}, + "retryable": {"type": "boolean"} + } + } + } + } + } +} diff --git a/docs/v2-app-developer-guide.md b/docs/v2-app-developer-guide.md new file mode 100644 index 0000000..ebbf9a7 --- /dev/null +++ b/docs/v2-app-developer-guide.md @@ -0,0 +1,237 @@ +# Amduat v2 App Developer Guide + +This is the compact handoff guide for building an application against `amduatd` v2. + +For machine-readable contracts, see `registry/amduatd-api-contract.v2.json`. + +## 1) Runtime Model + +- One daemon instance serves one ASL store root. +- Transport is local Unix socket HTTP. +- Auth is currently filesystem/socket permission based. +- All graph APIs are under `/v2/graph/*`. + +Minimal local run: + +```sh +./vendor/amduat/build/amduat-asl init --root .amduat-asl +./build/amduatd --root .amduat-asl --sock amduatd.sock --store-backend index +``` + +## 2) Request Conventions + +- Use `X-Amduat-Space: ` for app isolation. +- Treat graph cursors as opaque tokens (`g1_*`), do not parse. +- Use `as_of` for snapshot-consistent reads. +- Use `include_tombstoned=true` only when you explicitly want retracted facts. + +## 3) Core App Flows + +### A. High-throughput ingest + +Use `POST /v2/graph/batch` with: + +- `idempotency_key` for deterministic retries +- `mode=continue_on_error` for partial-apply behavior +- per-item `metadata_ref` or `provenance` for trust/debug + +Expect response: + +- `ok` (overall) +- `applied` aggregate counts +- `results[]` with `{kind,index,status,code,error}` + +### B. Multi-hop retrieval for agents + +Primary endpoints: + +- `GET /v2/graph/subgraph` +- `POST /v2/graph/retrieve` +- `POST /v2/graph/query` for declarative filtering + +Use: + +- `as_of` for stable reasoning snapshots +- `max_depth`, `max_fanout`, `limit_nodes`, `limit_edges`, `max_result_bytes` +- provenance filters where needed (`provenance_ref` / `provenance_min_confidence`) + +### C. Incremental sync loop + +Use `GET /v2/graph/changes`: + +- Start with `since_cursor` (or bootstrap with `since_as_of`) +- Persist returned `next_cursor` after successful processing +- Handle `410` as replay-window expiry (full resync required) +- Optional long poll: `wait_ms` + +### D. Fact correction + +- Edge retraction: `POST /v2/graph/edges/tombstone` +- Node-version retraction: `POST /v2/graph/nodes/{name}/versions/tombstone` + +Reads default to exclude tombstoned facts on retrieval surfaces unless `include_tombstoned=true`. + +## 4) Endpoint Map (what to use when) + +- Write node: `POST /v2/graph/nodes` +- Write version: `POST /v2/graph/nodes/{name}/versions` +- Write edge: `POST /v2/graph/edges` +- Batch write: `POST /v2/graph/batch` +- Point-ish read: `GET /v2/graph/nodes/{name}` +- Edge scan: `GET /v2/graph/edges` +- Neighbor scan: `GET /v2/graph/nodes/{name}/neighbors` +- Path lookup: `GET /v2/graph/paths` +- Subgraph: `GET /v2/graph/subgraph` +- Declarative query: `POST /v2/graph/query` +- Agent retrieval: `POST /v2/graph/retrieve` +- Changes feed: `GET /v2/graph/changes` +- Export: `POST /v2/graph/export` +- Import: `POST /v2/graph/import` +- Predicate policy: `GET/POST /v2/graph/schema/predicates` +- Health/readiness/metrics: `GET /v2/healthz`, `GET /v2/readyz`, `GET /v2/metrics` +- Graph runtime/capability: `GET /v2/graph/stats`, `GET /v2/graph/capabilities` + +## 5) Provenance and Policy + +Provenance object fields for writes: + +- required: `source_uri`, `extractor`, `observed_at`, `ingested_at`, `trace_id` +- optional: `confidence`, `license` + +Policy endpoint: + +- `POST /v2/graph/schema/predicates` + +Key modes: + +- predicate validation: `strict|warn|off` +- provenance enforcement: `optional|required` + +## 6) Error Handling and Retry Rules + +- Retry-safe writes: only retries with same `idempotency_key` and identical payload. +- Validation failures: `400` or `422` (do not blind-retry). +- Not found for references/nodes: `404`. +- Cursor window expired: `410` on `/changes` (rebootstrap sync state). +- Result guard triggered: `422` (`max_result_bytes` or traversal/search limits). +- Internal errors: `500` (retry with backoff). + +## 7) Performance and Safety Defaults + +Recommended client defaults: + +- Set explicit `limit` on scans. +- Always pass `max_result_bytes` on large retrieval requests. +- Keep `max_depth` conservative (start with 2-4). +- Enable `include_stats=true` in development to monitor scanned/returned counts and selected plan. +- Call `/v2/graph/capabilities` once at startup for feature/limit negotiation. + +## 8) Minimal Startup Checklist (for external app) + +1. Probe `GET /v2/readyz`. +2. Read `GET /v2/graph/capabilities`. +3. Configure schema/provenance policy (`POST /v2/graph/schema/predicates`) if your app owns policy. +4. Start ingest path (`/v2/graph/batch` idempotent). +5. Start change-consumer loop (`/v2/graph/changes`). +6. Serve retrieval via `/v2/graph/retrieve` and `/v2/graph/subgraph`. +7. Monitor `/v2/metrics` and `/v2/graph/stats`. + +## 9) Useful Local Helpers + +- `scripts/graph_client_helpers.sh` contains practical shell helpers for: + - idempotent batch ingest + - one-step changes sync + - subgraph retrieval + +For integration tests/examples: + +- `scripts/test_graph_queries.sh` +- `scripts/test_graph_contract.sh` + +## 10) Copy/Paste Integration Skeleton + +Set local defaults: + +```sh +SOCK="amduatd.sock" +SPACE="app1" +BASE="http://localhost" +``` + +Startup probes: + +```sh +curl --unix-socket "${SOCK}" -sS "${BASE}/v2/readyz" +curl --unix-socket "${SOCK}" -sS "${BASE}/v2/graph/capabilities" +``` + +Idempotent batch ingest: + +```sh +curl --unix-socket "${SOCK}" -sS -X POST "${BASE}/v2/graph/batch" \ + -H "Content-Type: application/json" \ + -H "X-Amduat-Space: ${SPACE}" \ + -d '{ + "idempotency_key":"app1-batch-0001", + "mode":"continue_on_error", + "nodes":[{"name":"doc:1"}], + "edges":[ + {"subject":"doc:1","predicate":"ms.within_domain","object":"topic:alpha", + "provenance":{"source_uri":"urn:app:seed","extractor":"app-loader","observed_at":1,"ingested_at":2,"trace_id":"trace-1"}} + ] + }' +``` + +Incremental changes loop (bash skeleton): + +```sh +cursor="" +while true; do + if [ -n "${cursor}" ]; then + path="/v2/graph/changes?since_cursor=${cursor}&limit=200&wait_ms=15000" + else + path="/v2/graph/changes?limit=200&wait_ms=15000" + fi + + resp="$(curl --unix-socket "${SOCK}" -sS "${BASE}${path}" -H "X-Amduat-Space: ${SPACE}")" || break + + # TODO: parse and process resp.events[] in your app. + next="$(printf '%s\n' "${resp}" | sed -n 's/.*"next_cursor":"\([^"]*\)".*/\1/p')" + [ -n "${next}" ] && cursor="${next}" +done +``` + +Agent retrieval call: + +```sh +curl --unix-socket "${SOCK}" -sS -X POST "${BASE}/v2/graph/retrieve" \ + -H "Content-Type: application/json" \ + -H "X-Amduat-Space: ${SPACE}" \ + -d '{ + "roots":["doc:1"], + "goal_predicates":["ms.within_domain"], + "max_depth":2, + "max_fanout":1024, + "limit_nodes":200, + "limit_edges":400, + "max_result_bytes":1048576 + }' +``` + +Subgraph snapshot read: + +```sh +curl --unix-socket "${SOCK}" -sS \ + "${BASE}/v2/graph/subgraph?roots[]=doc:1&max_depth=2&dir=outgoing&limit_nodes=200&limit_edges=400&include_stats=true&max_result_bytes=1048576" \ + -H "X-Amduat-Space: ${SPACE}" +``` + +Edge correction (tombstone): + +```sh +EDGE_REF="" +curl --unix-socket "${SOCK}" -sS -X POST "${BASE}/v2/graph/edges/tombstone" \ + -H "Content-Type: application/json" \ + -H "X-Amduat-Space: ${SPACE}" \ + -d "{\"edge_ref\":\"${EDGE_REF}\"}" +``` diff --git a/scripts/bootstrap_check.sh b/scripts/bootstrap_check.sh new file mode 100755 index 0000000..8fd731e --- /dev/null +++ b/scripts/bootstrap_check.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=/dev/null +source "${ROOT_DIR}/src/client.sh" + +echo "== /v2/readyz ==" +api_get "/v2/readyz" +echo + +echo "== /v2/graph/capabilities ==" +api_get "/v2/graph/capabilities" +echo diff --git a/scripts/graph_client_helpers.sh b/scripts/graph_client_helpers.sh new file mode 100755 index 0000000..130527c --- /dev/null +++ b/scripts/graph_client_helpers.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Reusable HTTP and graph client helpers for local unix-socket amduatd usage. + +graph_helpers_init() { + if [[ $# -lt 1 ]]; then + echo "usage: graph_helpers_init ROOT_DIR" >&2 + return 1 + fi + GRAPH_HELPERS_ROOT_DIR="$1" + GRAPH_HELPERS_HTTP="${GRAPH_HELPERS_ROOT_DIR}/build/amduatd_http_unix" + GRAPH_HELPERS_USE_HTTP=0 + + if command -v curl >/dev/null 2>&1; then + if curl --help 2>/dev/null | grep -q -- '--unix-socket'; then + GRAPH_HELPERS_USE_HTTP=0 + else + GRAPH_HELPERS_USE_HTTP=1 + fi + else + GRAPH_HELPERS_USE_HTTP=1 + fi + + if [[ "${GRAPH_HELPERS_USE_HTTP}" -eq 1 && ! -x "${GRAPH_HELPERS_HTTP}" ]]; then + echo "missing http transport (need curl --unix-socket or build/amduatd_http_unix)" >&2 + return 1 + fi +} + +graph_http_get() { + local sock="$1" + local path="$2" + shift 2 + if [[ "${GRAPH_HELPERS_USE_HTTP}" -eq 1 ]]; then + "${GRAPH_HELPERS_HTTP}" --sock "${sock}" --method GET --path "${path}" "$@" + else + curl --silent --show-error --fail \ + --unix-socket "${sock}" \ + "$@" \ + "http://localhost${path}" + fi +} + +graph_http_get_allow() { + local sock="$1" + local path="$2" + shift 2 + if [[ "${GRAPH_HELPERS_USE_HTTP}" -eq 1 ]]; then + "${GRAPH_HELPERS_HTTP}" --sock "${sock}" --method GET --path "${path}" --allow-status "$@" + else + curl --silent --show-error \ + --unix-socket "${sock}" \ + "$@" \ + "http://localhost${path}" + fi +} + +graph_http_post() { + local sock="$1" + local path="$2" + local data="$3" + shift 3 + if [[ "${GRAPH_HELPERS_USE_HTTP}" -eq 1 ]]; then + "${GRAPH_HELPERS_HTTP}" --sock "${sock}" --method POST --path "${path}" --data "${data}" "$@" + else + curl --silent --show-error --fail \ + --unix-socket "${sock}" \ + "$@" \ + --data-binary "${data}" \ + "http://localhost${path}" + fi +} + +graph_http_post_allow() { + local sock="$1" + local path="$2" + local data="$3" + shift 3 + if [[ "${GRAPH_HELPERS_USE_HTTP}" -eq 1 ]]; then + "${GRAPH_HELPERS_HTTP}" --sock "${sock}" --method POST --path "${path}" --data "${data}" --allow-status "$@" + else + curl --silent --show-error \ + --unix-socket "${sock}" \ + "$@" \ + --data-binary "${data}" \ + "http://localhost${path}" + fi +} + +graph_wait_for_ready() { + local sock="$1" + local pid="$2" + local log_path="$3" + local i + for i in $(seq 1 120); do + if ! kill -0 "${pid}" >/dev/null 2>&1; then + if [[ -f "${log_path}" ]] && grep -q "bind: Operation not permitted" "${log_path}"; then + echo "skip: bind not permitted for unix socket" >&2 + return 77 + fi + if [[ -f "${log_path}" ]]; then + cat "${log_path}" >&2 + fi + return 1 + fi + if [[ -S "${sock}" ]] && graph_http_get "${sock}" "/v1/meta" >/dev/null 2>&1; then + return 0 + fi + sleep 0.1 + done + return 1 +} + +graph_batch_ingest() { + local sock="$1" + local space="$2" + local payload="$3" + graph_http_post "${sock}" "/v2/graph/batch" "${payload}" \ + --header "Content-Type: application/json" \ + --header "X-Amduat-Space: ${space}" +} + +graph_changes_sync_once() { + local sock="$1" + local space="$2" + local cursor="$3" + local limit="$4" + local path="/v2/graph/changes?limit=${limit}" + if [[ -n "${cursor}" ]]; then + path+="&since_cursor=${cursor}" + fi + graph_http_get "${sock}" "${path}" --header "X-Amduat-Space: ${space}" +} + +graph_subgraph_fetch() { + local sock="$1" + local space="$2" + local root="$3" + local max_depth="$4" + local predicates="${5:-}" + local path="/v2/graph/subgraph?roots[]=${root}&max_depth=${max_depth}&dir=outgoing&limit_nodes=256&limit_edges=256" + if [[ -n "${predicates}" ]]; then + path+="&predicates[]=${predicates}" + fi + graph_http_get "${sock}" "${path}" --header "X-Amduat-Space: ${space}" +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + if [[ $# -lt 1 ]]; then + echo "usage: $0 COMMAND ..." >&2 + echo "commands: batch-ingest, sync-once, subgraph" >&2 + exit 2 + fi + cmd="$1" + shift + : "${AMDUATD_ROOT:?set AMDUATD_ROOT to repo root}" + graph_helpers_init "${AMDUATD_ROOT}" + case "${cmd}" in + batch-ingest) + if [[ $# -ne 3 ]]; then + echo "usage: $0 batch-ingest SOCK SPACE PAYLOAD_JSON" >&2 + exit 2 + fi + graph_batch_ingest "$1" "$2" "$3" + ;; + sync-once) + if [[ $# -ne 4 ]]; then + echo "usage: $0 sync-once SOCK SPACE CURSOR LIMIT" >&2 + exit 2 + fi + graph_changes_sync_once "$1" "$2" "$3" "$4" + ;; + subgraph) + if [[ $# -lt 4 || $# -gt 5 ]]; then + echo "usage: $0 subgraph SOCK SPACE ROOT MAX_DEPTH [PREDICATE]" >&2 + exit 2 + fi + graph_subgraph_fetch "$1" "$2" "$3" "$4" "${5:-}" + ;; + *) + echo "unknown command: ${cmd}" >&2 + exit 2 + ;; + esac +fi diff --git a/scripts/ingest_example.sh b/scripts/ingest_example.sh new file mode 100755 index 0000000..7aa9ad5 --- /dev/null +++ b/scripts/ingest_example.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=/dev/null +source "${ROOT_DIR}/src/client.sh" + +payload='{ + "idempotency_key":"app1-seed-0001", + "mode":"continue_on_error", + "nodes":[{"name":"doc:1"},{"name":"topic:alpha"}], + "edges":[ + { + "subject":"doc:1", + "predicate":"ms.within_domain", + "object":"topic:alpha", + "provenance":{ + "source_uri":"urn:app:seed", + "extractor":"app-loader", + "observed_at":1, + "ingested_at":2, + "trace_id":"trace-seed-1" + } + } + ] +}' + +api_post_json "/v2/graph/batch" "${payload}" +echo diff --git a/scripts/sync_loop.sh b/scripts/sync_loop.sh new file mode 100755 index 0000000..dc6cb62 --- /dev/null +++ b/scripts/sync_loop.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="${ROOT_DIR}/config/env.local" +if [[ ! -f "${ENV_FILE}" ]]; then + ENV_FILE="${ROOT_DIR}/config/env.example" +fi +# shellcheck source=/dev/null +source "${ENV_FILE}" +# shellcheck source=/dev/null +source "${ROOT_DIR}/src/client.sh" + +limit="${SYNC_LIMIT:-200}" +wait_ms="${SYNC_WAIT_MS:-15000}" +cursor_file="${ROOT_DIR}/.cursor" +cursor="" + +if [[ -f "${cursor_file}" ]]; then + cursor="$(cat "${cursor_file}")" +fi + +while true; do + if [[ -n "${cursor}" ]]; then + path="/v2/graph/changes?since_cursor=${cursor}&limit=${limit}&wait_ms=${wait_ms}" + else + path="/v2/graph/changes?limit=${limit}&wait_ms=${wait_ms}" + fi + + resp="$(api_get "${path}")" + echo "${resp}" + + next="$(printf '%s\n' "${resp}" | sed -n 's/.*"next_cursor":"\([^"]*\)".*/\1/p')" + if [[ -n "${next}" ]]; then + cursor="${next}" + printf '%s' "${cursor}" > "${cursor_file}" + fi + + sleep 1 + done diff --git a/scripts/v2_app.sh b/scripts/v2_app.sh new file mode 100755 index 0000000..28cf138 --- /dev/null +++ b/scripts/v2_app.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=/dev/null +source "${ROOT_DIR}/src/app_v2.sh" + +usage() { + cat <&2 + exit 2 +fi + +app_init + +cmd="$1" +shift + +case "${cmd}" in + startup-check) + app_startup_checks + ;; + ingest) + if [[ $# -ne 1 ]]; then + echo "usage: $0 ingest PAYLOAD_JSON" >&2 + exit 2 + fi + app_ingest_batch "$1" + ;; + sync-once) + app_sync_once + ;; + sync-loop) + app_sync_loop + ;; + retrieve) + if [[ $# -lt 1 || $# -gt 2 ]]; then + echo "usage: $0 retrieve ROOTS_CSV [GOAL_PREDICATES_CSV]" >&2 + exit 2 + fi + app_retrieve_with_fallback "$1" "${2:-}" + ;; + tombstone) + if [[ $# -ne 1 ]]; then + echo "usage: $0 tombstone EDGE_REF" >&2 + exit 2 + fi + app_tombstone_edge "$1" + ;; + *) + usage >&2 + exit 2 + ;; +esac diff --git a/src/amduat_v2_client.sh b/src/amduat_v2_client.sh new file mode 100755 index 0000000..c0b9425 --- /dev/null +++ b/src/amduat_v2_client.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -euo pipefail + +# shellcheck source=/dev/null +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/config.sh" + +amduat_client_init() { + amduat_config_load +} + +amduat_http_raw() { + local method="$1" + local path="$2" + local body="${3:-}" + + local curl_args=( + --globoff + --silent + --show-error + --output - + --write-out $'\n%{http_code}' + --unix-socket "${SOCK}" + --connect-timeout "${CURL_CONNECT_TIMEOUT_SECONDS}" + --max-time "${CURL_MAX_TIME_SECONDS}" + -H "X-Amduat-Space: ${SPACE}" + -X "${method}" + "${BASE}${path}" + ) + + if [[ -n "${body}" ]]; then + curl_args=( + --globoff + --silent + --show-error + --output - + --write-out $'\n%{http_code}' + --unix-socket "${SOCK}" + --connect-timeout "${CURL_CONNECT_TIMEOUT_SECONDS}" + --max-time "${CURL_MAX_TIME_SECONDS}" + -H "Content-Type: application/json" + -H "X-Amduat-Space: ${SPACE}" + -X "${method}" + --data-binary "${body}" + "${BASE}${path}" + ) + fi + + curl "${curl_args[@]}" +} + +_amduat_backoff_sleep() { + local delay_ms="$1" + local delay_secs + delay_secs="$(awk "BEGIN {printf \"%.3f\", ${delay_ms}/1000}")" + sleep "${delay_secs}" +} + +_amduat_should_retry_status() { + local status="$1" + case "${status}" in + 500) return 0 ;; + *) return 1 ;; + esac +} + +_amduat_non_retryable_status() { + local status="$1" + case "${status}" in + 400|404|410|422) return 0 ;; + *) return 1 ;; + esac +} + +# Sets global AMDUAT_LAST_STATUS and AMDUAT_LAST_BODY. +amduat_api_call() { + local method="$1" + local path="$2" + local body="${3:-}" + + local attempt=1 + local delay_ms="${RETRY_INITIAL_MS}" + AMDUAT_LAST_STATUS="000" + AMDUAT_LAST_BODY="" + + while (( attempt <= RETRY_MAX_ATTEMPTS )); do + local raw + local rc=0 + raw="$(amduat_http_raw "${method}" "${path}" "${body}")" || rc=$? + + if (( rc != 0 )); then + if (( attempt < RETRY_MAX_ATTEMPTS )); then + _amduat_backoff_sleep "${delay_ms}" + delay_ms=$(( delay_ms * 2 )) + if (( delay_ms > RETRY_MAX_MS )); then + delay_ms="${RETRY_MAX_MS}" + fi + attempt=$(( attempt + 1 )) + continue + fi + echo "request failed after retries: ${method} ${path}" >&2 + return 1 + fi + + AMDUAT_LAST_STATUS="${raw##*$'\n'}" + AMDUAT_LAST_BODY="${raw%$'\n'*}" + + if [[ "${AMDUAT_LAST_STATUS}" =~ ^2[0-9][0-9]$ ]]; then + return 0 + fi + + case "${AMDUAT_LAST_STATUS}" in + 400) echo "400 bad request for ${method} ${path}" >&2 ;; + 404) echo "404 not found for ${method} ${path}" >&2 ;; + 410) echo "410 cursor window expired for ${method} ${path}" >&2 ;; + 422) echo "422 unprocessable request/result guard for ${method} ${path}" >&2 ;; + 500) echo "500 internal error for ${method} ${path}" >&2 ;; + *) echo "unexpected status ${AMDUAT_LAST_STATUS} for ${method} ${path}" >&2 ;; + esac + + if _amduat_non_retryable_status "${AMDUAT_LAST_STATUS}"; then + return 1 + fi + + if _amduat_should_retry_status "${AMDUAT_LAST_STATUS}" && (( attempt < RETRY_MAX_ATTEMPTS )); then + _amduat_backoff_sleep "${delay_ms}" + delay_ms=$(( delay_ms * 2 )) + if (( delay_ms > RETRY_MAX_MS )); then + delay_ms="${RETRY_MAX_MS}" + fi + attempt=$(( attempt + 1 )) + continue + fi + + return 1 + done + + return 1 +} diff --git a/src/app_v2.sh b/src/app_v2.sh new file mode 100755 index 0000000..165243a --- /dev/null +++ b/src/app_v2.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +set -euo pipefail + +# shellcheck source=/dev/null +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/amduat_v2_client.sh" + +app_init() { + amduat_client_init +} + +app_startup_checks() { + amduat_api_call GET "/v2/readyz" || return 1 + printf '%s\n' "${AMDUAT_LAST_BODY}" + + amduat_api_call GET "/v2/graph/capabilities" || return 1 + printf '%s\n' "${AMDUAT_LAST_BODY}" +} + +app_ingest_batch() { + local payload="$1" + amduat_api_call POST "/v2/graph/batch" "${payload}" + printf '%s\n' "${AMDUAT_LAST_BODY}" +} + +app_sync_once() { + local path="/v2/graph/changes?limit=${SYNC_LIMIT}&wait_ms=${SYNC_WAIT_MS}" + local cursor="" + + if [[ -f "${CURSOR_FILE}" ]]; then + cursor="$(cat "${CURSOR_FILE}")" + fi + + if [[ -n "${cursor}" ]]; then + path+="&since_cursor=${cursor}" + fi + + if ! amduat_api_call GET "${path}"; then + if [[ "${AMDUAT_LAST_STATUS}" == "410" ]]; then + rm -f "${CURSOR_FILE}" + echo "changes cursor expired (410); cleared ${CURSOR_FILE}" >&2 + return 0 + fi + return 1 + fi + + printf '%s\n' "${AMDUAT_LAST_BODY}" + + local next + next="$(printf '%s\n' "${AMDUAT_LAST_BODY}" | sed -n 's/.*"next_cursor"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')" + if [[ -n "${next}" ]]; then + printf '%s' "${next}" > "${CURSOR_FILE}" + fi +} + +app_sync_loop() { + while true; do + app_sync_once + sleep 1 + done +} + +app_retrieve_with_fallback() { + local roots_csv="$1" + local goals_csv="${2:-}" + + local roots_json + roots_json="$(printf '%s' "${roots_csv}" | awk -F',' 'BEGIN{printf "["} {for(i=1;i<=NF;i++){gsub(/^ +| +$/, "", $i); if (length($i)>0){if (printed) printf ","; printf "\"%s\"", $i; printed=1}}} END{printf "]"}')" + + local goals_json="[]" + if [[ -n "${goals_csv}" ]]; then + goals_json="$(printf '%s' "${goals_csv}" | awk -F',' 'BEGIN{printf "["} {for(i=1;i<=NF;i++){gsub(/^ +| +$/, "", $i); if (length($i)>0){if (printed) printf ","; printf "\"%s\"", $i; printed=1}}} END{printf "]"}')" + fi + + local payload + payload="{\"roots\":${roots_json},\"goal_predicates\":${goals_json},\"max_depth\":2,\"max_fanout\":256,\"limit_nodes\":200,\"limit_edges\":400,\"max_result_bytes\":1048576}" + + if amduat_api_call POST "/v2/graph/retrieve" "${payload}"; then + printf '%s\n' "${AMDUAT_LAST_BODY}" + return 0 + fi + + local first_root + first_root="$(printf '%s' "${roots_csv}" | awk -F',' '{gsub(/^ +| +$/, "", $1); printf "%s", $1}')" + local fallback_path="/v2/graph/subgraph?roots[]=${first_root}&max_depth=2&dir=outgoing&limit_nodes=200&limit_edges=400&max_result_bytes=1048576" + if [[ -n "${goals_csv}" ]]; then + local first_goal + first_goal="$(printf '%s' "${goals_csv}" | awk -F',' '{gsub(/^ +| +$/, "", $1); printf "%s", $1}')" + if [[ -n "${first_goal}" ]]; then + fallback_path+="&predicates[]=${first_goal}" + fi + fi + + amduat_api_call GET "${fallback_path}" + printf '%s\n' "${AMDUAT_LAST_BODY}" +} + +app_tombstone_edge() { + local edge_ref="$1" + local payload + payload="{\"edge_ref\":\"${edge_ref}\"}" + amduat_api_call POST "/v2/graph/edges/tombstone" "${payload}" + printf '%s\n' "${AMDUAT_LAST_BODY}" +} diff --git a/src/client.sh b/src/client.sh new file mode 100755 index 0000000..cfae26a --- /dev/null +++ b/src/client.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="${ROOT_DIR}/config/env.local" +if [[ ! -f "${ENV_FILE}" ]]; then + ENV_FILE="${ROOT_DIR}/config/env.example" +fi +# shellcheck source=/dev/null +source "${ENV_FILE}" + +SOCK="${SOCK:-../amduatd.sock}" +BASE="${BASE:-http://localhost}" +SPACE="${SPACE:-app1}" + +api_get() { + local path="$1" + curl --silent --show-error --fail \ + --unix-socket "${SOCK}" \ + -H "X-Amduat-Space: ${SPACE}" \ + "${BASE}${path}" +} + +api_post_json() { + local path="$1" + local body="$2" + curl --silent --show-error --fail \ + --unix-socket "${SOCK}" \ + -H "Content-Type: application/json" \ + -H "X-Amduat-Space: ${SPACE}" \ + -X POST \ + --data-binary "${body}" \ + "${BASE}${path}" +} diff --git a/src/config.sh b/src/config.sh new file mode 100755 index 0000000..7c3a915 --- /dev/null +++ b/src/config.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +amduat_config_load() { + local root_dir + root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + local override_sock="${SOCK:-}" + local override_base="${BASE:-}" + local override_space="${SPACE:-}" + local override_sync_limit="${SYNC_LIMIT:-}" + local override_sync_wait_ms="${SYNC_WAIT_MS:-}" + local override_cursor_file="${CURSOR_FILE:-}" + local override_retry_max_attempts="${RETRY_MAX_ATTEMPTS:-}" + local override_retry_initial_ms="${RETRY_INITIAL_MS:-}" + local override_retry_max_ms="${RETRY_MAX_MS:-}" + local override_connect_timeout="${CURL_CONNECT_TIMEOUT_SECONDS:-}" + local override_max_time="${CURL_MAX_TIME_SECONDS:-}" + + local env_file="${root_dir}/config/env.local" + if [[ ! -f "${env_file}" ]]; then + env_file="${root_dir}/config/env.example" + fi + # shellcheck source=/dev/null + source "${env_file}" + + if [[ -n "${override_sock}" ]]; then SOCK="${override_sock}"; fi + if [[ -n "${override_base}" ]]; then BASE="${override_base}"; fi + if [[ -n "${override_space}" ]]; then SPACE="${override_space}"; fi + if [[ -n "${override_sync_limit}" ]]; then SYNC_LIMIT="${override_sync_limit}"; fi + if [[ -n "${override_sync_wait_ms}" ]]; then SYNC_WAIT_MS="${override_sync_wait_ms}"; fi + if [[ -n "${override_cursor_file}" ]]; then CURSOR_FILE="${override_cursor_file}"; fi + if [[ -n "${override_retry_max_attempts}" ]]; then RETRY_MAX_ATTEMPTS="${override_retry_max_attempts}"; fi + if [[ -n "${override_retry_initial_ms}" ]]; then RETRY_INITIAL_MS="${override_retry_initial_ms}"; fi + if [[ -n "${override_retry_max_ms}" ]]; then RETRY_MAX_MS="${override_retry_max_ms}"; fi + if [[ -n "${override_connect_timeout}" ]]; then CURL_CONNECT_TIMEOUT_SECONDS="${override_connect_timeout}"; fi + if [[ -n "${override_max_time}" ]]; then CURL_MAX_TIME_SECONDS="${override_max_time}"; fi + + SOCK="${SOCK:-../amduatd.sock}" + BASE="${BASE:-http://localhost}" + SPACE="${SPACE:-app1}" + + SYNC_LIMIT="${SYNC_LIMIT:-200}" + SYNC_WAIT_MS="${SYNC_WAIT_MS:-15000}" + CURSOR_FILE="${CURSOR_FILE:-${root_dir}/.cursor}" + if [[ "${CURSOR_FILE}" != /* ]]; then + CURSOR_FILE="${root_dir}/${CURSOR_FILE}" + fi + + RETRY_MAX_ATTEMPTS="${RETRY_MAX_ATTEMPTS:-4}" + RETRY_INITIAL_MS="${RETRY_INITIAL_MS:-200}" + RETRY_MAX_MS="${RETRY_MAX_MS:-2000}" + + CURL_CONNECT_TIMEOUT_SECONDS="${CURL_CONNECT_TIMEOUT_SECONDS:-2}" + CURL_MAX_TIME_SECONDS="${CURL_MAX_TIME_SECONDS:-30}" +} diff --git a/tests/integration_v2.sh b/tests/integration_v2.sh new file mode 100755 index 0000000..421f25a --- /dev/null +++ b/tests/integration_v2.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=/dev/null +source "${ROOT_DIR}/src/app_v2.sh" + +require_jq() { + if ! command -v jq >/dev/null 2>&1; then + echo "jq is required for integration_v2.sh" >&2 + exit 2 + fi +} + +assert_contains() { + local haystack="$1" + local needle="$2" + if [[ "${haystack}" != *"${needle}"* ]]; then + echo "assertion failed: expected to find ${needle}" >&2 + exit 1 + fi +} + +app_init +require_jq +if [[ ! -S "${SOCK}" ]]; then + echo "integration_v2.sh: SKIP (socket not found at ${SOCK})" + exit 77 +fi + +# 1) startup checks +startup_out="$(app_startup_checks)" +assert_contains "${startup_out}" '"ok"' + +# 2) idempotent ingest (batch + continue_on_error) +run_id="$(date +%s)" +trace_id="trace-it-${run_id}" +idempotency_key="it-seed-${run_id}" +doc_name="doc:it${run_id}" +topic_name="topic:italpha${run_id}" +payload="$(cat <&2; exit 1; } + +# 4) retrieval endpoint + fallback path available +retrieve_out="$(app_retrieve_with_fallback "${doc_name}" "ms.within_domain")" +assert_contains "${retrieve_out}" '"edges"' + +# Capture edge_ref using subgraph surface to avoid format differences. +subgraph_out="$(amduat_api_call GET "/v2/graph/subgraph?roots[]=${doc_name}&max_depth=2&dir=outgoing&limit_nodes=200&limit_edges=400&include_stats=true&max_result_bytes=1048576" && printf '%s' "${AMDUAT_LAST_BODY}")" +edge_ref="$(printf '%s' "${subgraph_out}" | jq -r '.edges[0].edge_ref // empty')" +if [[ -z "${edge_ref}" ]]; then + echo "failed to resolve edge_ref" >&2 + exit 1 +fi + +# 5) correction path and tombstone visibility semantics +app_tombstone_edge "${edge_ref}" >/dev/null +post_tombstone_retrieve="$(app_retrieve_with_fallback "${doc_name}" "ms.within_domain")" +post_edges_count="$(printf '%s' "${post_tombstone_retrieve}" | jq '.edges | length')" +if [[ "${post_edges_count}" != "0" ]]; then + echo "expected retrieval default to hide tombstoned edges" >&2 + exit 1 +fi + +visible_tombstone="$(amduat_api_call GET "/v2/graph/subgraph?roots[]=${doc_name}&max_depth=2&dir=outgoing&limit_nodes=200&limit_edges=400&include_tombstoned=true&max_result_bytes=1048576" && printf '%s' "${AMDUAT_LAST_BODY}")" +assert_contains "${visible_tombstone}" '"edges"' + +echo "integration_v2.sh: PASS"