Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b25e92c94 | ||
|
|
95fc4d37f0 | ||
|
|
9195470ceb | ||
|
|
cbe33ecab8 | ||
|
|
e3fc48e5b9 | ||
|
|
ed3bcf76c7 |
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"contract_commit": "cbe33ecab8e9794ba5f318bea43aeaf447a56fbc",
|
||||||
|
"created_at": "2026-07-17T14:13:38+00:00",
|
||||||
|
"schema_version": 1,
|
||||||
|
"state_writer": "openclaw",
|
||||||
|
"work_units": [
|
||||||
|
{
|
||||||
|
"fallback": "codex",
|
||||||
|
"gate": "R0_RUNNER_LIVE_RECOVERY",
|
||||||
|
"id": "R0-DISCOVER-SECRET-PROVIDER",
|
||||||
|
"primary": "openclaw",
|
||||||
|
"status": "READY",
|
||||||
|
"track": "R_RUNNER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fallback": "codex",
|
||||||
|
"gate": "P2_REAL_B3_EVIDENCE",
|
||||||
|
"id": "P2-INVENTORY-REAL-EVIDENCE",
|
||||||
|
"primary": "openclaw",
|
||||||
|
"status": "READY",
|
||||||
|
"track": "P_PERSONAL_WEIXIN"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fallback": "openclaw",
|
||||||
|
"gate": "P1_REPAIR3_CODE",
|
||||||
|
"id": "P1-POST-CONTRACT-REACK",
|
||||||
|
"primary": "codex",
|
||||||
|
"status": "READY",
|
||||||
|
"track": "P_PERSONAL_WEIXIN"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
{
|
||||||
|
"adaptive_dispatch": {
|
||||||
|
"codex_first": [
|
||||||
|
"complex_code",
|
||||||
|
"security_boundary",
|
||||||
|
"architecture",
|
||||||
|
"high_risk_review",
|
||||||
|
"strong_model_materially_better"
|
||||||
|
],
|
||||||
|
"default_orchestrator": "openclaw",
|
||||||
|
"dual_required": [
|
||||||
|
"release_gate",
|
||||||
|
"crypto_or_identity_change",
|
||||||
|
"production_migration_or_rollback"
|
||||||
|
],
|
||||||
|
"max_attempts_per_work_unit": 2,
|
||||||
|
"on_timeout": [
|
||||||
|
"mark_TIMEOUT",
|
||||||
|
"preserve_logs",
|
||||||
|
"switch_agent"
|
||||||
|
],
|
||||||
|
"openclaw_first": [
|
||||||
|
"quality_close",
|
||||||
|
"large_or_repetitive",
|
||||||
|
"long_running",
|
||||||
|
"infra_or_git_inventory",
|
||||||
|
"cost_sensitive"
|
||||||
|
],
|
||||||
|
"single_state_writer_lease_required": true,
|
||||||
|
"strict_role_partition": false,
|
||||||
|
"timeout_can_pass": false
|
||||||
|
},
|
||||||
|
"authoritative_state": {
|
||||||
|
"b3": "BLOCKED_EVIDENCE_REQUIRED",
|
||||||
|
"b4": "NOT_STARTED",
|
||||||
|
"baseline": "V1R4C6",
|
||||||
|
"production_release": "NOT_STARTED",
|
||||||
|
"release": "paused",
|
||||||
|
"repair3": {
|
||||||
|
"base": "8d9c6da90fc8caa69b691d243c6fefb40b6b0575",
|
||||||
|
"candidate": "PASS_CANDIDATE_PREPARED",
|
||||||
|
"commit": "e110cda9968cadc5c7b7179ec5003b05aad11ce4",
|
||||||
|
"exact4": "PASS",
|
||||||
|
"result": "49a9c2e0cc898ecf2bbd45f454e007644d7f3045"
|
||||||
|
},
|
||||||
|
"runner": {
|
||||||
|
"artifact_sha256": "85c472c05a1e55a5899b215d3cd41df53912787de529ac9cb3d5308da4158130",
|
||||||
|
"commit": "bbd27cf06acf1f589eef291eab54d4b127aa3aea",
|
||||||
|
"live": "BLOCKED_GATEWAY_SECRET_REF_UNAVAILABLE",
|
||||||
|
"offline": "PASS",
|
||||||
|
"result": "e789632e359e9232d8ae081e7c4f23ed7b615a9d"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contract_id": "V2R1-PERSONAL-WEIXIN-PRODUCTION-EXECUTION-CONTRACT",
|
||||||
|
"facts_policy": {
|
||||||
|
"accept": [
|
||||||
|
"committed_git_tree",
|
||||||
|
"full_oid",
|
||||||
|
"worktree_bare_gitea_oid_match",
|
||||||
|
"test_log_with_rc",
|
||||||
|
"immutable_digest",
|
||||||
|
"result_only_commit",
|
||||||
|
"live_evidence"
|
||||||
|
],
|
||||||
|
"reject": [
|
||||||
|
"verbal_claim",
|
||||||
|
"readme_only",
|
||||||
|
"local_only_commit",
|
||||||
|
"environment_variable_as_secret_ref",
|
||||||
|
"compile_only",
|
||||||
|
"timeout_as_pass",
|
||||||
|
"unrelated_repo",
|
||||||
|
"fabricated_evidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"forbidden_git": [
|
||||||
|
"reset",
|
||||||
|
"amend",
|
||||||
|
"rebase",
|
||||||
|
"force_push",
|
||||||
|
"direct_main_push",
|
||||||
|
"direct_bare_ref_mutation",
|
||||||
|
"unapproved_gitea_db_mutation",
|
||||||
|
"reuse_accident_worktree",
|
||||||
|
"delete_live_worktree_to_clear_collision"
|
||||||
|
],
|
||||||
|
"generated_at": "2026-07-17T14:13:38+00:00",
|
||||||
|
"handoff": {
|
||||||
|
"ack": "CONTRACT_ACK=<full_contract_commit_oid>",
|
||||||
|
"advance": "independent_verification_required",
|
||||||
|
"all_agents_match": true
|
||||||
|
},
|
||||||
|
"join_gate": {
|
||||||
|
"requires": [
|
||||||
|
"RUNNER_RECOVERED",
|
||||||
|
"B3_PASS",
|
||||||
|
"B4_PASS",
|
||||||
|
"INTEGRATION_PASS"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"model_policy": {
|
||||||
|
"codex": "strongest_model_actually_available_in_runtime",
|
||||||
|
"gpt_5_6": "use_only_if_exposed",
|
||||||
|
"no_unverified_model_claims": true,
|
||||||
|
"openclaw_default": "DeepSeek V4 Flash"
|
||||||
|
},
|
||||||
|
"owner_authorization": {
|
||||||
|
"adaptive_agent_assignment_authorized": true,
|
||||||
|
"agent_substitution_authorized": true,
|
||||||
|
"autonomous_execution": true,
|
||||||
|
"may_bypass_release_controls": false,
|
||||||
|
"may_bypass_secret_controls": false,
|
||||||
|
"may_fabricate_evidence": false,
|
||||||
|
"user_step_by_step_confirmation_required": false
|
||||||
|
},
|
||||||
|
"release_gates": [
|
||||||
|
"MAIN_MERGE",
|
||||||
|
"STAGING_AND_CANARY",
|
||||||
|
"PRODUCTION"
|
||||||
|
],
|
||||||
|
"schema_version": 3,
|
||||||
|
"state_rules": [
|
||||||
|
"tracks_run_parallel",
|
||||||
|
"runner_block_does_not_block_offline_work_or_evidence_inventory",
|
||||||
|
"join_requires_both_tracks",
|
||||||
|
"failure_stops_dependents",
|
||||||
|
"production_remains_V1R4C6_paused_until_production_pass"
|
||||||
|
],
|
||||||
|
"status": "ACTIVE",
|
||||||
|
"tracks": {
|
||||||
|
"P_PERSONAL_WEIXIN": {
|
||||||
|
"b3": "BLOCKED_EVIDENCE_REQUIRED",
|
||||||
|
"b4": "NOT_STARTED",
|
||||||
|
"parallel": true,
|
||||||
|
"repair3": "PASS_CANDIDATE_PREPARED",
|
||||||
|
"repair3_commit": "e110cda9968cadc5c7b7179ec5003b05aad11ce4"
|
||||||
|
},
|
||||||
|
"R_RUNNER": {
|
||||||
|
"commit": "bbd27cf06acf1f589eef291eab54d4b127aa3aea",
|
||||||
|
"current": "BLOCKED_GATEWAY_SECRET_REF_UNAVAILABLE",
|
||||||
|
"fallback": "codex",
|
||||||
|
"parallel": true,
|
||||||
|
"primary": "openclaw",
|
||||||
|
"required": [
|
||||||
|
"artifact_verified",
|
||||||
|
"approved_secret_provider_contract",
|
||||||
|
"owner_probe_verified",
|
||||||
|
"service_healthy",
|
||||||
|
"heartbeat_healthy",
|
||||||
|
"gateway_auth",
|
||||||
|
"durable_task_create_read_cancel",
|
||||||
|
"execution_channel",
|
||||||
|
"live_rollback"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# RESULT: AIOS Orchestrator Gateway v0.2 — BLOCKED
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
status: BLOCKED_FULL_REPOSITORY_TEST_BASELINE_MISSING_SOURCE
|
||||||
|
bundle_sha256: 4d49e91ce65d816190ae3b6e22afe1b3f85de17f99a23e4d44f27aa770c0111f
|
||||||
|
apply_script_sha256: 16065453f10b2bec0276f43d08be03f4a77003a08881795fd8ffbb8a93f24f98
|
||||||
|
implementation_commit: 95fc4d37f0b9502945e5ba97a1f8e5a146768f6b
|
||||||
|
result_commit: HEAD
|
||||||
|
python_compile: PASS
|
||||||
|
unit_tests: PASS_41_OF_41
|
||||||
|
full_repository_tests: FAIL_BASELINE_MISSING_scripts_wechat_send_backend_py
|
||||||
|
systemd_verify: PASS
|
||||||
|
polling_recovery: PASS
|
||||||
|
workspace_agent_callback: PASS
|
||||||
|
responses_background_polling: PASS
|
||||||
|
openclaw_lifecycle_sync: PASS
|
||||||
|
gitleaks: PASS_CURRENT_TREE_AND_IMPLEMENTATION_BRANCH_HISTORY
|
||||||
|
rollback: PASS_INITIAL_REPEAT_NOOP_AND_DIGEST_MISMATCH_FAIL_CLOSED
|
||||||
|
worktree_oid: 95fc4d37f0b9502945e5ba97a1f8e5a146768f6b
|
||||||
|
bare_oid: 95fc4d37f0b9502945e5ba97a1f8e5a146768f6b
|
||||||
|
gitea_oid: 95fc4d37f0b9502945e5ba97a1f8e5a146768f6b
|
||||||
|
all_equal: true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Blocking fact
|
||||||
|
|
||||||
|
The final repository-wide `unittest discover` run executed all 41 Gateway
|
||||||
|
tests successfully, then failed because the authoritative baseline does not
|
||||||
|
contain `scripts/wechat_send_backend.py`, which is imported by
|
||||||
|
`tests/test_wechat_send_backend.py`.
|
||||||
|
|
||||||
|
The same missing-file failure was reproduced read-only from clean base commit
|
||||||
|
`9195470ceb78fd57ffface3dcc38f40b75efa9d8`. The missing implementation exists
|
||||||
|
only on unrelated branch lineages and was not copied into this scoped Gateway
|
||||||
|
change. Therefore `full_repository_tests` is not PASS and the Gateway code
|
||||||
|
review is blocked fail-closed.
|
||||||
|
|
||||||
|
## Executed verification facts
|
||||||
|
|
||||||
|
- Bundle and apply-script SHA-256 values matched the supplied values before
|
||||||
|
application; all archive-internal checksums also matched.
|
||||||
|
- Python compilation passed.
|
||||||
|
- Gateway unit and negative suite passed 41 of 41, including duplicate event
|
||||||
|
conflict, stale state version, two independent SQLite worker connections,
|
||||||
|
Workspace callback timeout, Responses queued/in-progress/completed polling,
|
||||||
|
file-inbox recovery, Git-ref polling recovery, traversal, symlink, OpenClaw
|
||||||
|
retry, same-blocker limit, per-gate hop limit, and callback contract mismatch.
|
||||||
|
- JSON parsing, Draft 2020-12 schema validation, OpenAPI 3.1 validation, Bash
|
||||||
|
syntax, `systemd-analyze verify`, and `git diff --check` passed.
|
||||||
|
- Current-tree Gitleaks and full current-branch/implementation-range history
|
||||||
|
scans passed. A separate all-ref repository scan found four pre-existing
|
||||||
|
findings on unrelated, non-ancestor refs; none belongs to this implementation.
|
||||||
|
- Root-isolated installer verification preserved existing token contents while
|
||||||
|
normalizing ownership/mode, and the versioned install completed successfully.
|
||||||
|
- Root-isolated rollback passed initial rollback, repeat no-op, polling-unit
|
||||||
|
coordination, and tampered digest fail-closed checks.
|
||||||
|
- The implementation branch was pushed without force and its worktree, local
|
||||||
|
bare-ref, and Gitea OIDs are identical.
|
||||||
|
|
||||||
|
No secret value was printed or committed. No reset, amend, rebase, force push,
|
||||||
|
direct main push, controller mutation, or fabricated evidence was used.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"b3": "BLOCKED_EVIDENCE_REQUIRED",
|
||||||
|
"b4": "NOT_STARTED",
|
||||||
|
"baseline": "V1R4C6",
|
||||||
|
"contract_branch": "task/governance/v2r1-personal-weixin-production-contract-20260717",
|
||||||
|
"contract_commit": "cbe33ecab8e9794ba5f318bea43aeaf447a56fbc",
|
||||||
|
"dispatch_commit": null,
|
||||||
|
"production_release": "NOT_STARTED",
|
||||||
|
"release": "paused",
|
||||||
|
"repair3": "PASS_CANDIDATE_PREPARED",
|
||||||
|
"runner": "BLOCKED_GATEWAY_SECRET_REF_UNAVAILABLE",
|
||||||
|
"schema_version": 1,
|
||||||
|
"single_state_writer": "openclaw",
|
||||||
|
"updated_at": "2026-07-17T14:13:38+00:00"
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Codex task: apply and verify AIOS Orchestrator Gateway v1
|
||||||
|
|
||||||
|
Contract: `cbe33ecab8e9794ba5f318bea43aeaf447a56fbc`
|
||||||
|
|
||||||
|
Use the strongest actually available Codex model for the initial security review.
|
||||||
|
If unavailable or timed out, record TIMEOUT/NOT_EXECUTED and let OpenClaw run
|
||||||
|
the deterministic test path. Do not count a timeout as PASS.
|
||||||
|
|
||||||
|
## Apply
|
||||||
|
|
||||||
|
1. Create a new clean append-only branch and isolated worktree.
|
||||||
|
2. Validate the delivery tarball and `SHA256SUMS`.
|
||||||
|
3. Run the external `scripts/apply_gateway_patch.sh` with `SOURCE_DIR` and
|
||||||
|
`WORKTREE`; do not alter unrelated paths.
|
||||||
|
4. Review all added code, especially:
|
||||||
|
- exact contract and optimistic state-version checks;
|
||||||
|
- event/decision idempotency;
|
||||||
|
- SQLite leases and retry behavior;
|
||||||
|
- Workspace Agent `202` asynchronous callback flow;
|
||||||
|
- Responses background creation and polling;
|
||||||
|
- file-inbox and Git-ref polling;
|
||||||
|
- traversal/symlink protections;
|
||||||
|
- OpenClaw lifecycle synchronization;
|
||||||
|
- systemd sandbox;
|
||||||
|
- install and rollback transactions.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Run from the final source tree:
|
||||||
|
|
||||||
|
- Python compile for all gateway and helper files;
|
||||||
|
- unit tests;
|
||||||
|
- JSON schema/config parsing;
|
||||||
|
- Bash syntax;
|
||||||
|
- `systemd-analyze verify`;
|
||||||
|
- event duplicate and payload-conflict tests;
|
||||||
|
- stale state-version rejection;
|
||||||
|
- lease/double-claim test;
|
||||||
|
- Workspace trigger failure and Responses fallback;
|
||||||
|
- response queued/in-progress/completed polling;
|
||||||
|
- file-inbox recovery after HTTP failure;
|
||||||
|
- Git-ref initial baseline and subsequent-change event;
|
||||||
|
- task start/end and project summary synchronization;
|
||||||
|
- dispatch traversal and symlink rejection;
|
||||||
|
- retry/backoff/dead-letter;
|
||||||
|
- install dry-run;
|
||||||
|
- rollback test in an isolated fixture;
|
||||||
|
- `git diff --check`;
|
||||||
|
- full repository tests;
|
||||||
|
- final Gitleaks.
|
||||||
|
|
||||||
|
## Commit and review
|
||||||
|
|
||||||
|
1. Create an append-only implementation commit.
|
||||||
|
2. Push without force.
|
||||||
|
3. Confirm `WORKTREE_OID == BARE_OID == GITEA_OID`.
|
||||||
|
4. Create an independent RESULT-only branch and exact-one result commit.
|
||||||
|
5. Record the source bundle SHA-256, implementation OID, RESULT OID, tests,
|
||||||
|
Gitleaks, systemd validation and remaining external configuration blockers.
|
||||||
|
|
||||||
|
## Forbidden
|
||||||
|
|
||||||
|
No reset, amend, rebase, force push, direct main push, controller mutation,
|
||||||
|
secret disclosure, fabricated test result, production deployment or B3 evidence
|
||||||
|
fabrication.
|
||||||
|
|
||||||
|
Success:
|
||||||
|
|
||||||
|
`GATEWAY_CODE_REVIEW=PASS IMPLEMENTATION=<oid> RESULT=<oid> OID_MATCH=YES`
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Codex V2R1 adaptive execution
|
||||||
|
CONTRACT_ACK=cbe33ecab8e9794ba5f318bea43aeaf447a56fbc
|
||||||
|
Read `.agent/policies/V2R1-PERSONAL-WEIXIN-PRODUCTION-EXECUTION-CONTRACT.yaml`.
|
||||||
|
Execute P1-POST-CONTRACT-REACK now:
|
||||||
|
- read-only verify Repair3 e110cda9968cadc5c7b7179ec5003b05aad11ce4
|
||||||
|
- read-only verify RESULT 49a9c2e0cc898ecf2bbd45f454e007644d7f3045
|
||||||
|
- verify lineage from 8d9c6da90fc8caa69b691d243c6fefb40b6b0575, exact4, tests, Gitleaks and RESULT-only scope
|
||||||
|
- do not modify Repair3 code in this unit
|
||||||
|
Act as fallback for OpenClaw only on timeout/unavailability. Use GPT-5.6 only if actually exposed; otherwise strongest visible model. Prefer OpenClaw when quality is comparable or volume is high; prefer Codex for complex/security-critical work.
|
||||||
|
Forbidden: reset, amend, rebase, force push, direct main push, controller mutation, secret disclosure, fabricated evidence, skipped gates.
|
||||||
|
Report:
|
||||||
|
CONTRACT=cbe33ecab8e9794ba5f318bea43aeaf447a56fbc WORK_UNIT=<id> TRACK=<id> GATE=<id> AGENT=codex STATUS=<status> IMPLEMENTATION=<oid-or-NONE> RESULT=<oid-or-NONE> OID_MATCH=<YES-or-NO> BLOCKER=<reason-or-NONE>
|
||||||
|
Keep BASELINE=V1R4C6 RELEASE=paused PRODUCTION_RELEASE=NOT_STARTED until production gate passes.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# OpenClaw task: deploy and supervise AIOS Orchestrator Gateway v1
|
||||||
|
|
||||||
|
Contract: `cbe33ecab8e9794ba5f318bea43aeaf447a56fbc`
|
||||||
|
|
||||||
|
You are the default orchestrator and deployment supervisor.
|
||||||
|
|
||||||
|
## Preconditions
|
||||||
|
|
||||||
|
- Codex has applied and independently reviewed this source bundle on a clean,
|
||||||
|
append-only branch.
|
||||||
|
- Implementation and RESULT OIDs are identical across worktree, bare repo and
|
||||||
|
Gitea.
|
||||||
|
- Final Gitleaks and repository tests are PASS.
|
||||||
|
- No force push, reset, amend or rebase occurred.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
|
||||||
|
1. Validate the source bundle SHA-256 and `SHA256SUMS`.
|
||||||
|
2. Review the generated systemd service, poll timer, tmpfiles and sysusers files.
|
||||||
|
3. Install through `scripts/install_aios_orchestrator_gateway.sh`.
|
||||||
|
4. Configure one or both central-model channels:
|
||||||
|
- published Workspace Agent API channel with `agtch_...` trigger ID and a
|
||||||
|
Workspace Agent access token stored outside Git; or
|
||||||
|
- Responses API fallback with an approved model and API key stored outside Git.
|
||||||
|
5. Publish the callback endpoint securely and configure the Workspace Agent
|
||||||
|
action from `workspace-agent/orchestrator-decision-action.openapi.yaml`.
|
||||||
|
6. Confirm that the Workspace Agent instructions match
|
||||||
|
`workspace-agent/CENTRAL_AGENT_INSTRUCTIONS.md`.
|
||||||
|
7. Enable both:
|
||||||
|
- the continuously running gateway service;
|
||||||
|
- the independent `aios-orchestrator-gateway-poll.timer`.
|
||||||
|
8. Verify HTTP event ingestion, file-inbox polling, Git-ref polling,
|
||||||
|
Workspace Agent trigger acceptance, callback decision, Responses background
|
||||||
|
polling fallback, dispatch queue generation, retry leases and loop guards.
|
||||||
|
9. Verify OpenClaw receives and synchronizes:
|
||||||
|
- TASK_DISPATCHED
|
||||||
|
- TASK_STARTED
|
||||||
|
- TASK_ENDED
|
||||||
|
- PROJECT_SUMMARY
|
||||||
|
- PROJECT_COMPLETED
|
||||||
|
10. Run a harmless bootstrap work unit through OpenClaw and Codex and confirm
|
||||||
|
duplicate event submission is idempotent.
|
||||||
|
11. Run rollback, reinstall and heartbeat/health verification.
|
||||||
|
12. Create one RESULT-only review recording exact commits, artifact digest,
|
||||||
|
tests, systemd security, polling recovery, lifecycle synchronization and
|
||||||
|
rollback.
|
||||||
|
|
||||||
|
## Fail-closed boundaries
|
||||||
|
|
||||||
|
- Do not put Workspace Agent tokens, API keys, callback tokens or gateway
|
||||||
|
credentials in Git, logs, command arguments or RESULT files.
|
||||||
|
- Do not claim the gateway resolves the existing B3 credential/raw-wire blocker.
|
||||||
|
- Do not advance B4 or production merely because this gateway is healthy.
|
||||||
|
- Do not bypass owner/admin production release approvals.
|
||||||
|
- Do not use reset, amend, rebase, force push, direct main push or bare-ref edits.
|
||||||
|
|
||||||
|
## Required final status
|
||||||
|
|
||||||
|
Success:
|
||||||
|
|
||||||
|
`ORCHESTRATOR_GATEWAY_LIVE=PASS POLLING=PASS OPENCLAW_SYNC=PASS ROLLBACK=PASS`
|
||||||
|
|
||||||
|
Blocker:
|
||||||
|
|
||||||
|
`ORCHESTRATOR_GATEWAY_LIVE=BLOCKED reason=<stable_reason>`
|
||||||
|
|
||||||
|
Keep `BASELINE=V1R4C6 RELEASE=paused PRODUCTION_RELEASE=NOT_STARTED` until the
|
||||||
|
existing production contract advances all release gates.
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
# OpenClaw V2R1 adaptive orchestration
|
||||||
|
CONTRACT_ACK=cbe33ecab8e9794ba5f318bea43aeaf447a56fbc
|
||||||
|
Read `.agent/policies/V2R1-PERSONAL-WEIXIN-PRODUCTION-EXECUTION-CONTRACT.yaml`.
|
||||||
|
You are default orchestrator and sole authoritative state writer.
|
||||||
|
Execute immediately:
|
||||||
|
1. R0-DISCOVER-SECRET-PROVIDER: discover approved provider metadata and owner-probe source without reading or outputting secrets. Environment variables alone are not approved secret refs.
|
||||||
|
2. P2-INVENTORY-REAL-EVIDENCE: inventory approved restricted B3 evidence locations; validate existing facts; never synthesize evidence.
|
||||||
|
3. Independently verify Codex results before advancing.
|
||||||
|
Prefer OpenClaw for comparable-quality, large, repetitive, long-running, infrastructure and cost-sensitive work. Prefer Codex strongest actually available model for complex code, security boundaries, architecture and high-risk review. On timeout mark TIMEOUT and switch agent; never infer PASS.
|
||||||
|
Forbidden: reset, amend, rebase, force push, direct main push, controller mutation, secret disclosure, fabricated evidence, skipped gates.
|
||||||
|
Report:
|
||||||
|
CONTRACT=cbe33ecab8e9794ba5f318bea43aeaf447a56fbc WORK_UNIT=<id> TRACK=<id> GATE=<id> AGENT=openclaw STATUS=<status> COMMIT=<oid-or-NONE> BLOCKER=<reason-or-NONE>
|
||||||
|
Keep BASELINE=V1R4C6 RELEASE=paused PRODUCTION_RELEASE=NOT_STARTED until production gate passes.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# AIOS Orchestrator Gateway v0.2
|
||||||
|
|
||||||
|
This component turns the current passive GPT orchestration pattern into a
|
||||||
|
durable, unattended loop:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Gitea/OpenClaw/Codex event
|
||||||
|
-> HTTP event endpoint or polled file inbox
|
||||||
|
-> durable SQLite state machine
|
||||||
|
-> Workspace Agent API trigger
|
||||||
|
-> strict callback decision
|
||||||
|
-> Responses API background fallback and polling
|
||||||
|
-> OpenClaw/Codex dispatch queues
|
||||||
|
-> start/end/result events
|
||||||
|
-> next orchestration cycle
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reliability mechanisms
|
||||||
|
|
||||||
|
The gateway does not depend on one timer, webhook or model call:
|
||||||
|
|
||||||
|
- HTTP event ingestion.
|
||||||
|
- Periodic file-inbox polling for missed webhooks and offline producers.
|
||||||
|
- Local bare-Git ref polling for `main`, governance, patch and result refs.
|
||||||
|
- Workspace Agent retries with a stable event `Idempotency-Key`.
|
||||||
|
- Workspace Agent callback timeout and Responses API background fallback.
|
||||||
|
- Responses status polling until completion or fail-closed terminal status.
|
||||||
|
- SQLite optimistic state versions, idempotency records and processing leases.
|
||||||
|
- Exponential retry with a maximum retry count and dead-letter state.
|
||||||
|
- Same-blocker and maximum-hop guards to stop non-progress loops.
|
||||||
|
- A systemd timer runs `--poll-once` as an independent reliability watchdog.
|
||||||
|
|
||||||
|
Workspace Agent trigger runs are asynchronous and do not expose a retrievable
|
||||||
|
final response. The central Workspace Agent must therefore call the
|
||||||
|
`submit_orchestration_decision` action.
|
||||||
|
|
||||||
|
## OpenClaw synchronization
|
||||||
|
|
||||||
|
Every lifecycle stage is durably written to the OpenClaw synchronization queue:
|
||||||
|
|
||||||
|
- `TASK_DISPATCHED`
|
||||||
|
- `TASK_STARTED`
|
||||||
|
- `TASK_ENDED`
|
||||||
|
- `DECISION_APPLIED`
|
||||||
|
- `PROJECT_SUMMARY`
|
||||||
|
- `PROJECT_COMPLETED`
|
||||||
|
|
||||||
|
An optional authenticated OpenClaw HTTP endpoint can receive the same records.
|
||||||
|
A failed HTTP delivery remains in the lifecycle retry queue. OpenClaw remains
|
||||||
|
the default project-status synchronizer and authoritative progress presenter.
|
||||||
|
|
||||||
|
## Agent execution contract
|
||||||
|
|
||||||
|
Each dispatched JSON envelope contains:
|
||||||
|
|
||||||
|
- exact contract commit;
|
||||||
|
- source event;
|
||||||
|
- clean base commit;
|
||||||
|
- primary and fallback agent;
|
||||||
|
- allowed paths;
|
||||||
|
- required checks;
|
||||||
|
- forbidden operations;
|
||||||
|
- completion event callback format.
|
||||||
|
|
||||||
|
OpenClaw and Codex must send a `work_unit_started` event before execution and a
|
||||||
|
`work_unit_completed`, `work_unit_blocked`, or `work_unit_failed` event when
|
||||||
|
they stop. This guarantees that OpenClaw can synchronize every task start,
|
||||||
|
finish, and overall project summary.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Model and callback credentials are read only from root-owned secret files.
|
||||||
|
- Credentials never enter Git, dispatch payloads or logs.
|
||||||
|
- The gateway runs as a dedicated unprivileged user.
|
||||||
|
- Dispatch and sync paths are protected against traversal and symlink targets.
|
||||||
|
- All state changes require the exact contract commit and current state version.
|
||||||
|
- Production evidence, credentials and approvals stay fail-closed.
|
||||||
|
- Git history operations remain controlled by the committed governance contract.
|
||||||
|
|
||||||
|
## Deployment sequence
|
||||||
|
|
||||||
|
1. Apply this source bundle to a clean append-only branch.
|
||||||
|
2. Run unit, repository, shell, JSON, systemd and Gitleaks checks.
|
||||||
|
3. Configure one or both model channels:
|
||||||
|
- published Workspace Agent API channel and Workspace Agent access token;
|
||||||
|
- Responses API key and approved fallback model.
|
||||||
|
4. Publish the decision callback as a Workspace Agent action or MCP tool.
|
||||||
|
5. Install the service through `scripts/install_aios_orchestrator_gateway.sh`.
|
||||||
|
6. Run `scripts/verify_aios_orchestrator_gateway.sh`.
|
||||||
|
7. Submit a harmless event and verify:
|
||||||
|
- trigger acceptance;
|
||||||
|
- strict decision callback;
|
||||||
|
- dispatch file;
|
||||||
|
- OpenClaw task-start/task-end/project-summary synchronization;
|
||||||
|
- duplicate idempotency;
|
||||||
|
- polling recovery after suppressing the HTTP webhook.
|
||||||
|
|
||||||
|
The installer copies an explicit gateway-only allowlist into a release directory
|
||||||
|
named by its SHA-256 digest, atomically updates `/opt/aios-orchestrator-gateway/current`,
|
||||||
|
and persists `/var/lib/aios-orchestrator-gateway/install-receipt.json` with the
|
||||||
|
current and previous release. `--dry-run` performs source validation and emits
|
||||||
|
`AIOS_ORCHESTRATOR_INSTALL_DRY_RUN=PASS` without changing installed state.
|
||||||
|
|
||||||
|
Rollback reads that root-owned `0600` receipt, verifies both release directory
|
||||||
|
names and `SOURCE_DIGEST` files, coordinates the gateway and poll units, and
|
||||||
|
atomically switches `current` to the previous release. Repeating the same
|
||||||
|
rollback is a no-op and emits `NOOP_ALREADY_ROLLED_BACK`.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 18920,
|
||||||
|
"database_path": "/var/lib/aios-orchestrator-gateway/state.sqlite3",
|
||||||
|
"event_inbox_dir": "/var/lib/aios-orchestrator-gateway/inbox/events",
|
||||||
|
"decision_inbox_dir": "/var/lib/aios-orchestrator-gateway/inbox/decisions",
|
||||||
|
"dispatch_outbox_dir": "/var/lib/aios-orchestrator-gateway/outbox",
|
||||||
|
"openclaw_sync_dir": "/var/lib/aios-orchestrator-gateway/openclaw-sync",
|
||||||
|
"command_queue_dir": "/var/lib/aios-orchestrator-gateway/commands",
|
||||||
|
"inbound_token_file": "/etc/aios-orchestrator-gateway/inbound.token",
|
||||||
|
"callback_token_file": "/etc/aios-orchestrator-gateway/callback.token",
|
||||||
|
"contract_commit": "cbe33ecab8e9794ba5f318bea43aeaf447a56fbc",
|
||||||
|
"workspace_agent": {
|
||||||
|
"trigger_id": "agtch_REPLACE_ME",
|
||||||
|
"access_token_file": "/etc/aios-orchestrator-gateway/workspace-agent.token"
|
||||||
|
},
|
||||||
|
"responses_fallback": {
|
||||||
|
"api_key_file": "/etc/aios-orchestrator-gateway/openai-api.key",
|
||||||
|
"model": "REPLACE_WITH_APPROVED_MODEL"
|
||||||
|
},
|
||||||
|
"polling": {
|
||||||
|
"interval_seconds": 10,
|
||||||
|
"summary_interval_seconds": 300
|
||||||
|
},
|
||||||
|
"loop_controls": {
|
||||||
|
"callback_timeout_seconds": 900,
|
||||||
|
"lease_seconds": 60,
|
||||||
|
"retry_base_seconds": 10,
|
||||||
|
"max_retries": 5,
|
||||||
|
"max_same_blocker_retries": 3,
|
||||||
|
"max_hops_per_gate": 20
|
||||||
|
},
|
||||||
|
"openclaw_sync": {
|
||||||
|
"endpoint": null,
|
||||||
|
"token_file": null
|
||||||
|
},
|
||||||
|
"project_key": "v2r1-personal-weixin-production",
|
||||||
|
"git_poll": {
|
||||||
|
"enabled": true,
|
||||||
|
"bare_repo": "/data/aios-gitops/repo.git",
|
||||||
|
"ref_prefixes": [
|
||||||
|
"refs/heads/main",
|
||||||
|
"refs/heads/task/governance/",
|
||||||
|
"refs/heads/patch/",
|
||||||
|
"refs/heads/result/"
|
||||||
|
],
|
||||||
|
"interval_seconds": 30
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "AIOS Orchestrator Decision",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"schema_version",
|
||||||
|
"project_key",
|
||||||
|
"contract_commit",
|
||||||
|
"event_id",
|
||||||
|
"expected_state_version",
|
||||||
|
"verdict",
|
||||||
|
"state_updates",
|
||||||
|
"next_work_units",
|
||||||
|
"requires_human_input",
|
||||||
|
"stop_reason"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"schema_version": {
|
||||||
|
"type": "integer",
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"project_key": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"contract_commit": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9a-f]{40}$"
|
||||||
|
},
|
||||||
|
"event_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"expected_state_version": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"verdict": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"BLOCKED",
|
||||||
|
"COMPLETE",
|
||||||
|
"DISPATCH",
|
||||||
|
"NOOP"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"state_updates": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"key",
|
||||||
|
"value"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"key": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"next_work_units": {
|
||||||
|
"type": "array",
|
||||||
|
"maxItems": 32,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"track_id",
|
||||||
|
"gate_id",
|
||||||
|
"primary_agent",
|
||||||
|
"fallback_agent",
|
||||||
|
"clean_base_commit",
|
||||||
|
"allowed_paths",
|
||||||
|
"required_checks",
|
||||||
|
"forbidden_operations",
|
||||||
|
"instruction"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"track_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"gate_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"primary_agent": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"openclaw",
|
||||||
|
"codex"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"fallback_agent": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"openclaw",
|
||||||
|
"codex"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"clean_base_commit": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9a-f]{40}$"
|
||||||
|
},
|
||||||
|
"allowed_paths": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required_checks": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"forbidden_operations": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"instruction": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requires_human_input": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"stop_reason": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "AIOS Orchestrator Event",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"schema_version",
|
||||||
|
"project_key",
|
||||||
|
"contract_commit",
|
||||||
|
"event_id",
|
||||||
|
"source_agent",
|
||||||
|
"event_type",
|
||||||
|
"work_unit_id",
|
||||||
|
"status",
|
||||||
|
"state_version",
|
||||||
|
"implementation_commit",
|
||||||
|
"result_commit",
|
||||||
|
"blocker",
|
||||||
|
"evidence_refs",
|
||||||
|
"payload"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"schema_version": {
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"project_key": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
"contract_commit": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9a-f]{40}$"
|
||||||
|
},
|
||||||
|
"event_id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 128
|
||||||
|
},
|
||||||
|
"source_agent": {
|
||||||
|
"enum": [
|
||||||
|
"openclaw",
|
||||||
|
"codex",
|
||||||
|
"gitea",
|
||||||
|
"system",
|
||||||
|
"workspace-agent"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"event_type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"work_unit_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"enum": [
|
||||||
|
"READY",
|
||||||
|
"RUNNING",
|
||||||
|
"PASS",
|
||||||
|
"BLOCKED",
|
||||||
|
"FAIL",
|
||||||
|
"TIMEOUT",
|
||||||
|
"NOT_EXECUTED"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"state_version": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"implementation_commit": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"pattern": "^[0-9a-f]{40}$"
|
||||||
|
},
|
||||||
|
"result_commit": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"pattern": "^[0-9a-f]{40}$"
|
||||||
|
},
|
||||||
|
"blocker": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"maxLength": 512
|
||||||
|
},
|
||||||
|
"evidence_refs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"maxItems": 64
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+63
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Seed exactly one orchestration event when the project has no durable state."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
from services.aios_orchestrator_gateway.config import Config
|
||||||
|
from services.aios_orchestrator_gateway.models import Event
|
||||||
|
from services.aios_orchestrator_gateway.securefs import atomic_write_bytes_shared
|
||||||
|
from services.aios_orchestrator_gateway.store import Store
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--config", type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
config = Config.from_file(args.config)
|
||||||
|
store = Store(config.database_path)
|
||||||
|
if store.project_state(config.project_key) is not None:
|
||||||
|
print("ORCHESTRATION_BOOTSTRAP=NOOP reason=STATE_EXISTS")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
event = Event.from_dict(
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"project_key": config.project_key,
|
||||||
|
"contract_commit": config.contract_commit,
|
||||||
|
"event_id": "bootstrap-" + config.contract_commit[:24],
|
||||||
|
"source_agent": "system",
|
||||||
|
"event_type": "orchestration_bootstrap",
|
||||||
|
"work_unit_id": "ORCHESTRATION-BOOTSTRAP",
|
||||||
|
"status": "READY",
|
||||||
|
"state_version": 0,
|
||||||
|
"implementation_commit": None,
|
||||||
|
"result_commit": None,
|
||||||
|
"blocker": None,
|
||||||
|
"evidence_refs": [],
|
||||||
|
"payload": {
|
||||||
|
"reason": "initial_gateway_install",
|
||||||
|
"polling_enabled": True,
|
||||||
|
"openclaw_sync_required": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
path = config.event_inbox_dir / "pending" / f"{event.event_id}.json"
|
||||||
|
atomic_write_bytes_shared(
|
||||||
|
path,
|
||||||
|
(event.canonical_json() + "\n").encode(),
|
||||||
|
mode=0o640,
|
||||||
|
group_writable_directory=True,
|
||||||
|
)
|
||||||
|
print(f"ORCHESTRATION_BOOTSTRAP=PASS event_id={event.event_id}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+239
@@ -0,0 +1,239 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 0077
|
||||||
|
|
||||||
|
SOURCE_ROOT="${SOURCE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||||
|
INSTALL_ROOT="${INSTALL_ROOT:-/opt/aios-orchestrator-gateway}"
|
||||||
|
CONFIG_ROOT="${CONFIG_ROOT:-/etc/aios-orchestrator-gateway}"
|
||||||
|
STATE_ROOT="${STATE_ROOT:-/var/lib/aios-orchestrator-gateway}"
|
||||||
|
UNIT_ROOT="${UNIT_ROOT:-/etc/systemd/system}"
|
||||||
|
SYSUSERS_ROOT="${SYSUSERS_ROOT:-/etc/sysusers.d}"
|
||||||
|
TMPFILES_ROOT="${TMPFILES_ROOT:-/etc/tmpfiles.d}"
|
||||||
|
DRY_RUN=0
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--dry-run" ]]; then
|
||||||
|
DRY_RUN=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'AIOS_ORCHESTRATOR_INSTALL=BLOCKED reason=%s\n' "$1" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ "$(id -u)" -eq 0 ]] || fail OWNER_INSTALL_REQUIRED
|
||||||
|
|
||||||
|
for cmd in git python3 systemd-analyze systemctl systemd-sysusers systemd-tmpfiles sha256sum find sort xargs; do
|
||||||
|
command -v "$cmd" >/dev/null 2>&1 || fail "MISSING_COMMAND_${cmd}"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -f "$SOURCE_ROOT/SHA256SUMS" ]]; then
|
||||||
|
(cd "$SOURCE_ROOT" && sha256sum --check SHA256SUMS)
|
||||||
|
fi
|
||||||
|
|
||||||
|
for path in \
|
||||||
|
"$SOURCE_ROOT/services/aios_orchestrator_gateway" \
|
||||||
|
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway.service" \
|
||||||
|
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway-poll.service" \
|
||||||
|
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway-poll.timer" \
|
||||||
|
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway.sysusers" \
|
||||||
|
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway.tmpfiles" \
|
||||||
|
"$SOURCE_ROOT/config/aios-orchestrator-gateway.example.json"; do
|
||||||
|
[[ -e "$path" ]] || fail "MISSING_SOURCE_${path}"
|
||||||
|
done
|
||||||
|
|
||||||
|
PYCACHE="/tmp/aios-orchestrator-install-pycache-$$"
|
||||||
|
rm -rf "$PYCACHE"
|
||||||
|
PYTHONPYCACHEPREFIX="$PYCACHE" python3 -m compileall -q "$SOURCE_ROOT/services" "$SOURCE_ROOT/scripts"
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH="$SOURCE_ROOT" \
|
||||||
|
python3 -m unittest -v tests.test_aios_orchestrator_gateway
|
||||||
|
rm -rf "$PYCACHE"
|
||||||
|
for script in "$SOURCE_ROOT"/scripts/*.sh; do bash -n "$script"; done
|
||||||
|
python3 -m json.tool "$SOURCE_ROOT/config/aios-orchestrator-gateway.example.json" >/dev/null
|
||||||
|
systemd-analyze verify \
|
||||||
|
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway.service" \
|
||||||
|
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway-poll.service" \
|
||||||
|
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway-poll.timer"
|
||||||
|
|
||||||
|
SOURCE_FILES=(
|
||||||
|
README-AIOS-ORCHESTRATOR-GATEWAY.md
|
||||||
|
config/aios-orchestrator-gateway.example.json
|
||||||
|
schemas/decision.schema.json
|
||||||
|
schemas/event.schema.json
|
||||||
|
scripts/bootstrap_orchestration_loop.py
|
||||||
|
scripts/install_aios_orchestrator_gateway.sh
|
||||||
|
scripts/report_orchestrator_event.py
|
||||||
|
scripts/rollback_aios_orchestrator_gateway.sh
|
||||||
|
scripts/verify_aios_orchestrator_gateway.sh
|
||||||
|
systemd/aios-orchestrator-gateway-poll.service
|
||||||
|
systemd/aios-orchestrator-gateway-poll.timer
|
||||||
|
systemd/aios-orchestrator-gateway.service
|
||||||
|
systemd/aios-orchestrator-gateway.sysusers
|
||||||
|
systemd/aios-orchestrator-gateway.tmpfiles
|
||||||
|
tests/test_aios_orchestrator_gateway.py
|
||||||
|
tests/test_aios_orchestrator_gateway_rollback.sh
|
||||||
|
workspace-agent/CENTRAL_AGENT_INSTRUCTIONS.md
|
||||||
|
workspace-agent/orchestrator-decision-action.openapi.yaml
|
||||||
|
)
|
||||||
|
while IFS= read -r path; do SOURCE_FILES+=("$path"); done < <(
|
||||||
|
cd "$SOURCE_ROOT"
|
||||||
|
find services/aios_orchestrator_gateway -type f ! -path '*/__pycache__/*' ! -name '*.pyc' -print | sort
|
||||||
|
)
|
||||||
|
for path in "${SOURCE_FILES[@]}"; do
|
||||||
|
[[ -f "$SOURCE_ROOT/$path" && ! -L "$SOURCE_ROOT/$path" ]] || fail "INVALID_SOURCE_FILE_${path}"
|
||||||
|
done
|
||||||
|
|
||||||
|
SOURCE_DIGEST="$(
|
||||||
|
cd "$SOURCE_ROOT"
|
||||||
|
printf '%s\0' "${SOURCE_FILES[@]}" | sort -z | xargs -0 sha256sum \
|
||||||
|
| sha256sum \
|
||||||
|
| awk '{print $1}'
|
||||||
|
)"
|
||||||
|
[[ "$SOURCE_DIGEST" =~ ^[0-9a-f]{64}$ ]] || fail SOURCE_DIGEST_INVALID
|
||||||
|
RELEASE_DIR="$INSTALL_ROOT/releases/$SOURCE_DIGEST"
|
||||||
|
|
||||||
|
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||||
|
printf 'AIOS_ORCHESTRATOR_INSTALL_DRY_RUN=PASS digest=%s release=%s\n' \
|
||||||
|
"$SOURCE_DIGEST" "$RELEASE_DIR"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -d -m 0755 "$INSTALL_ROOT/releases"
|
||||||
|
install -d -m 0700 "$CONFIG_ROOT"
|
||||||
|
install -d -m 0700 "$STATE_ROOT"
|
||||||
|
|
||||||
|
systemd-sysusers "$SOURCE_ROOT/systemd/aios-orchestrator-gateway.sysusers"
|
||||||
|
systemd-tmpfiles --create "$SOURCE_ROOT/systemd/aios-orchestrator-gateway.tmpfiles"
|
||||||
|
|
||||||
|
if [[ ! -d "$RELEASE_DIR" ]]; then
|
||||||
|
TMP_RELEASE="$INSTALL_ROOT/releases/.tmp-$SOURCE_DIGEST-$$"
|
||||||
|
install -d -m 0755 "$TMP_RELEASE"
|
||||||
|
for path in "${SOURCE_FILES[@]}"; do
|
||||||
|
install -d -m 0755 "$TMP_RELEASE/$(dirname "$path")"
|
||||||
|
cp -a "$SOURCE_ROOT/$path" "$TMP_RELEASE/$path"
|
||||||
|
done
|
||||||
|
printf '%s\n' "$SOURCE_DIGEST" > "$TMP_RELEASE/SOURCE_DIGEST"
|
||||||
|
chmod -R go-w "$TMP_RELEASE"
|
||||||
|
mv "$TMP_RELEASE" "$RELEASE_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
PREVIOUS_TARGET=""
|
||||||
|
if [[ -L "$INSTALL_ROOT/current" ]]; then
|
||||||
|
PREVIOUS_TARGET="$(readlink -f "$INSTALL_ROOT/current")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ln -s "$RELEASE_DIR" "$INSTALL_ROOT/.current-new-$$"
|
||||||
|
mv -Tf "$INSTALL_ROOT/.current-new-$$" "$INSTALL_ROOT/current"
|
||||||
|
|
||||||
|
install -m 0644 "$SOURCE_ROOT/systemd/aios-orchestrator-gateway.service" \
|
||||||
|
"$UNIT_ROOT/aios-orchestrator-gateway.service"
|
||||||
|
install -m 0644 "$SOURCE_ROOT/systemd/aios-orchestrator-gateway-poll.service" \
|
||||||
|
"$UNIT_ROOT/aios-orchestrator-gateway-poll.service"
|
||||||
|
install -m 0644 "$SOURCE_ROOT/systemd/aios-orchestrator-gateway-poll.timer" \
|
||||||
|
"$UNIT_ROOT/aios-orchestrator-gateway-poll.timer"
|
||||||
|
install -m 0644 "$SOURCE_ROOT/systemd/aios-orchestrator-gateway.sysusers" \
|
||||||
|
"$SYSUSERS_ROOT/aios-orchestrator-gateway.conf"
|
||||||
|
install -m 0644 "$SOURCE_ROOT/systemd/aios-orchestrator-gateway.tmpfiles" \
|
||||||
|
"$TMPFILES_ROOT/aios-orchestrator-gateway.conf"
|
||||||
|
|
||||||
|
if [[ ! -f "$CONFIG_ROOT/inbound.token" ]]; then
|
||||||
|
python3 - <<'PY' > "$CONFIG_ROOT/inbound.token"
|
||||||
|
import secrets
|
||||||
|
print(secrets.token_urlsafe(48))
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
[[ ! -L "$CONFIG_ROOT/inbound.token" ]] || fail INBOUND_TOKEN_SYMLINK
|
||||||
|
chown root:aios-orchestrator "$CONFIG_ROOT/inbound.token"
|
||||||
|
chmod 0640 "$CONFIG_ROOT/inbound.token"
|
||||||
|
|
||||||
|
if [[ ! -f "$CONFIG_ROOT/callback.token" ]]; then
|
||||||
|
python3 - <<'PY' > "$CONFIG_ROOT/callback.token"
|
||||||
|
import secrets
|
||||||
|
print(secrets.token_urlsafe(48))
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
[[ ! -L "$CONFIG_ROOT/callback.token" ]] || fail CALLBACK_TOKEN_SYMLINK
|
||||||
|
chown root:aios-orchestrator "$CONFIG_ROOT/callback.token"
|
||||||
|
chmod 0640 "$CONFIG_ROOT/callback.token"
|
||||||
|
|
||||||
|
if [[ ! -f "$CONFIG_ROOT/config.json" ]]; then
|
||||||
|
install -m 0600 \
|
||||||
|
"$SOURCE_ROOT/config/aios-orchestrator-gateway.example.json" \
|
||||||
|
"$CONFIG_ROOT/config.json.example"
|
||||||
|
printf 'AIOS_ORCHESTRATOR_INSTALL=BLOCKED reason=CONFIGURATION_REQUIRED digest=%s\n' \
|
||||||
|
"$SOURCE_DIGEST"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
chown root:aios-orchestrator "$CONFIG_ROOT/config.json"
|
||||||
|
chmod 0640 "$CONFIG_ROOT/config.json"
|
||||||
|
|
||||||
|
python3 -m json.tool "$CONFIG_ROOT/config.json" >/dev/null
|
||||||
|
AIOS_ORCHESTRATOR_CONFIG_OWNER_UID=0 \
|
||||||
|
AIOS_ORCHESTRATOR_SECRET_OWNER_UID=0 \
|
||||||
|
PYTHONPATH="$INSTALL_ROOT/current" \
|
||||||
|
python3 - "$CONFIG_ROOT/config.json" <<'PY'
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from services.aios_orchestrator_gateway.config import Config
|
||||||
|
cfg = Config.from_file(Path(sys.argv[1]))
|
||||||
|
cfg.inbound_token()
|
||||||
|
cfg.callback_token()
|
||||||
|
if cfg.workspace_trigger_id:
|
||||||
|
cfg.workspace_token()
|
||||||
|
if cfg.responses_api_key_file:
|
||||||
|
cfg.responses_api_key()
|
||||||
|
if cfg.openclaw_sync_endpoint:
|
||||||
|
cfg.openclaw_sync_token()
|
||||||
|
print("CONFIG_VALIDATE=PASS")
|
||||||
|
PY
|
||||||
|
|
||||||
|
python3 - "$STATE_ROOT/install-receipt.json" "$SOURCE_DIGEST" "$RELEASE_DIR" "$PREVIOUS_TARGET" <<'PY'
|
||||||
|
import json, os, sys, tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
path = Path(sys.argv[1])
|
||||||
|
data = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"source_digest": sys.argv[2],
|
||||||
|
"current_release": sys.argv[3],
|
||||||
|
"previous_release": sys.argv[4] or None,
|
||||||
|
"last_rollback": None,
|
||||||
|
}
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, temp = tempfile.mkstemp(prefix=".install-receipt-", dir=path.parent)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w") as handle:
|
||||||
|
json.dump(data, handle, indent=2, sort_keys=True)
|
||||||
|
handle.write("\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.chmod(temp, 0o600)
|
||||||
|
os.replace(temp, path)
|
||||||
|
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||||
|
try:
|
||||||
|
os.fsync(directory)
|
||||||
|
finally:
|
||||||
|
os.close(directory)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(temp)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
PY
|
||||||
|
chown aios-orchestrator:aios-orchestrator "$STATE_ROOT/install-receipt.json"
|
||||||
|
chmod 0600 "$STATE_ROOT/install-receipt.json"
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now aios-orchestrator-gateway.service
|
||||||
|
systemctl enable --now aios-orchestrator-gateway-poll.timer
|
||||||
|
systemctl is-active --quiet aios-orchestrator-gateway.service
|
||||||
|
systemctl is-active --quiet aios-orchestrator-gateway-poll.timer
|
||||||
|
|
||||||
|
AIOS_ORCHESTRATOR_CONFIG_OWNER_UID=0 \
|
||||||
|
AIOS_ORCHESTRATOR_SECRET_OWNER_UID=0 \
|
||||||
|
PYTHONPATH="$INSTALL_ROOT/current" \
|
||||||
|
python3 "$INSTALL_ROOT/current/scripts/bootstrap_orchestration_loop.py" \
|
||||||
|
--config "$CONFIG_ROOT/config.json"
|
||||||
|
systemctl start aios-orchestrator-gateway-poll.service
|
||||||
|
|
||||||
|
printf 'AIOS_ORCHESTRATOR_INSTALL=PASS digest=%s previous=%s\n' \
|
||||||
|
"$SOURCE_DIGEST" "${PREVIOUS_TARGET:-NONE}"
|
||||||
Executable
+130
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Submit an OpenClaw/Codex task lifecycle event over HTTP or a polled inbox."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
from services.aios_orchestrator_gateway.models import Event
|
||||||
|
from services.aios_orchestrator_gateway.securefs import atomic_write_bytes_shared, read_secure_json, read_secure_text
|
||||||
|
|
||||||
|
|
||||||
|
def parser() -> argparse.ArgumentParser:
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--project-key", required=True)
|
||||||
|
p.add_argument("--contract-commit", required=True)
|
||||||
|
p.add_argument("--event-id", required=True)
|
||||||
|
p.add_argument("--source-agent", required=True, choices=["openclaw", "codex", "gitea", "system"])
|
||||||
|
p.add_argument("--event-type", required=True)
|
||||||
|
p.add_argument("--work-unit-id", required=True)
|
||||||
|
p.add_argument(
|
||||||
|
"--status",
|
||||||
|
required=True,
|
||||||
|
choices=["READY", "RUNNING", "PASS", "BLOCKED", "FAIL", "TIMEOUT", "NOT_EXECUTED"],
|
||||||
|
)
|
||||||
|
p.add_argument("--state-version", type=int, required=True)
|
||||||
|
p.add_argument("--implementation-commit")
|
||||||
|
p.add_argument("--result-commit")
|
||||||
|
p.add_argument("--blocker")
|
||||||
|
p.add_argument("--evidence-ref", action="append", default=[])
|
||||||
|
p.add_argument("--payload-file", type=Path)
|
||||||
|
p.add_argument("--endpoint")
|
||||||
|
p.add_argument("--token-file", type=Path)
|
||||||
|
p.add_argument("--inbox-dir", type=Path)
|
||||||
|
p.add_argument("--timeout-seconds", type=int, default=15)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parser().parse_args()
|
||||||
|
if not args.endpoint and not args.inbox_dir:
|
||||||
|
raise SystemExit("either --endpoint or --inbox-dir is required")
|
||||||
|
if args.endpoint and not args.token_file:
|
||||||
|
raise SystemExit("--token-file is required with --endpoint")
|
||||||
|
payload = {}
|
||||||
|
if args.payload_file:
|
||||||
|
if not args.payload_file.is_absolute():
|
||||||
|
raise SystemExit("--payload-file must be absolute")
|
||||||
|
payload = read_secure_json(
|
||||||
|
args.payload_file, expected_uid=os.getuid(), max_bytes=262_144
|
||||||
|
)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise SystemExit("payload root must be an object")
|
||||||
|
|
||||||
|
event = Event.from_dict(
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"project_key": args.project_key,
|
||||||
|
"contract_commit": args.contract_commit,
|
||||||
|
"event_id": args.event_id,
|
||||||
|
"source_agent": args.source_agent,
|
||||||
|
"event_type": args.event_type,
|
||||||
|
"work_unit_id": args.work_unit_id,
|
||||||
|
"status": args.status,
|
||||||
|
"state_version": args.state_version,
|
||||||
|
"implementation_commit": args.implementation_commit,
|
||||||
|
"result_commit": args.result_commit,
|
||||||
|
"blocker": args.blocker,
|
||||||
|
"evidence_refs": args.evidence_ref,
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
encoded = event.canonical_json().encode()
|
||||||
|
http_error: Exception | None = None
|
||||||
|
|
||||||
|
if args.endpoint:
|
||||||
|
token = read_secure_text(
|
||||||
|
args.token_file,
|
||||||
|
expected_uid=None,
|
||||||
|
max_bytes=64_000,
|
||||||
|
)
|
||||||
|
request = urllib.request.Request(
|
||||||
|
args.endpoint.rstrip("/") + "/v1/orchestrator/events",
|
||||||
|
data=encoded,
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Idempotency-Key": event.event_id,
|
||||||
|
"User-Agent": "aios-agent-event-reporter/0.2",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=args.timeout_seconds) as response:
|
||||||
|
response.read(65_536)
|
||||||
|
if response.status not in {200, 202}:
|
||||||
|
raise RuntimeError(f"unexpected HTTP {response.status}")
|
||||||
|
print(f"EVENT_SUBMIT=PASS event_id={event.event_id} channel=http")
|
||||||
|
return 0
|
||||||
|
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError) as exc:
|
||||||
|
http_error = exc
|
||||||
|
|
||||||
|
if args.inbox_dir:
|
||||||
|
pending = args.inbox_dir / "pending"
|
||||||
|
path = pending / f"{event.event_id}.json"
|
||||||
|
atomic_write_bytes_shared(
|
||||||
|
path,
|
||||||
|
(json.dumps(event.to_dict(), indent=2, sort_keys=True) + "\n").encode(),
|
||||||
|
mode=0o640,
|
||||||
|
group_writable_directory=True,
|
||||||
|
)
|
||||||
|
print(f"EVENT_SUBMIT=PASS event_id={event.event_id} channel=file_inbox")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"EVENT_SUBMIT=FAIL event_id={event.event_id} reason={type(http_error).__name__}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 0077
|
||||||
|
|
||||||
|
INSTALL_ROOT="${INSTALL_ROOT:-/opt/aios-orchestrator-gateway}"
|
||||||
|
STATE_ROOT="${STATE_ROOT:-/var/lib/aios-orchestrator-gateway}"
|
||||||
|
RECEIPT="$STATE_ROOT/install-receipt.json"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'AIOS_ORCHESTRATOR_ROLLBACK=BLOCKED reason=%s\n' "$1" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ "$(id -u)" -eq 0 ]] || fail OWNER_REQUIRED
|
||||||
|
[[ -f "$RECEIPT" ]] || fail RECEIPT_MISSING
|
||||||
|
[[ ! -L "$RECEIPT" ]] || fail RECEIPT_SYMLINK
|
||||||
|
[[ "$(stat -c '%u:%a' "$RECEIPT")" == "0:600" ]] || fail RECEIPT_PERMISSION
|
||||||
|
|
||||||
|
readarray -t VALUES < <(
|
||||||
|
python3 - "$RECEIPT" "$INSTALL_ROOT/releases" "$INSTALL_ROOT/current" <<'PY'
|
||||||
|
import json, os, stat, sys
|
||||||
|
from pathlib import Path
|
||||||
|
receipt = Path(sys.argv[1])
|
||||||
|
releases = Path(sys.argv[2]).resolve()
|
||||||
|
current_link = Path(sys.argv[3])
|
||||||
|
data = json.loads(receipt.read_text())
|
||||||
|
previous = data.get("previous_release")
|
||||||
|
current = data.get("current_release")
|
||||||
|
if not previous or not current:
|
||||||
|
raise SystemExit(2)
|
||||||
|
previous_path = Path(previous).resolve()
|
||||||
|
current_path = Path(current).resolve()
|
||||||
|
if previous_path.parent != releases or current_path.parent != releases:
|
||||||
|
raise SystemExit(2)
|
||||||
|
if not stat.S_ISDIR(os.lstat(previous_path).st_mode) or not stat.S_ISDIR(os.lstat(current_path).st_mode):
|
||||||
|
raise SystemExit(2)
|
||||||
|
for release in (previous_path, current_path):
|
||||||
|
if len(release.name) != 64 or any(ch not in "0123456789abcdef" for ch in release.name):
|
||||||
|
raise SystemExit(2)
|
||||||
|
digest_file = release / "SOURCE_DIGEST"
|
||||||
|
if digest_file.is_symlink() or not digest_file.is_file():
|
||||||
|
raise SystemExit(2)
|
||||||
|
if digest_file.read_text(encoding="utf-8").strip() != release.name:
|
||||||
|
raise SystemExit(2)
|
||||||
|
last = data.get("last_rollback")
|
||||||
|
if isinstance(last, dict) and last.get("to") == current and current_link.is_symlink():
|
||||||
|
if current_link.resolve() == current_path:
|
||||||
|
print("ALREADY_ROLLED_BACK")
|
||||||
|
raise SystemExit(0)
|
||||||
|
print(previous_path)
|
||||||
|
print(current_path)
|
||||||
|
PY
|
||||||
|
) || fail RECEIPT_INVALID
|
||||||
|
|
||||||
|
if [[ "${VALUES[0]:-}" == "ALREADY_ROLLED_BACK" ]]; then
|
||||||
|
echo "AIOS_ORCHESTRATOR_ROLLBACK=PASS result=NOOP_ALREADY_ROLLED_BACK"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
[[ "${#VALUES[@]}" -eq 2 ]] || fail RECEIPT_INVALID
|
||||||
|
PREVIOUS="${VALUES[0]}"
|
||||||
|
CURRENT="${VALUES[1]}"
|
||||||
|
systemctl stop aios-orchestrator-gateway-poll.timer || true
|
||||||
|
systemctl stop aios-orchestrator-gateway-poll.service || true
|
||||||
|
systemctl stop aios-orchestrator-gateway.service || true
|
||||||
|
ln -s "$PREVIOUS" "$INSTALL_ROOT/.current-rollback-$$"
|
||||||
|
mv -Tf "$INSTALL_ROOT/.current-rollback-$$" "$INSTALL_ROOT/current"
|
||||||
|
systemctl start aios-orchestrator-gateway.service
|
||||||
|
systemctl is-active --quiet aios-orchestrator-gateway.service
|
||||||
|
systemctl start aios-orchestrator-gateway-poll.timer
|
||||||
|
systemctl is-active --quiet aios-orchestrator-gateway-poll.timer
|
||||||
|
|
||||||
|
python3 - "$RECEIPT" "$PREVIOUS" "$CURRENT" <<'PY'
|
||||||
|
import json, os, sys, tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
path = Path(sys.argv[1])
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
data["current_release"] = sys.argv[2]
|
||||||
|
data["previous_release"] = sys.argv[3]
|
||||||
|
data["last_rollback"] = {"from": sys.argv[3], "to": sys.argv[2]}
|
||||||
|
fd, temp = tempfile.mkstemp(prefix=".rollback-receipt-", dir=path.parent)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w") as handle:
|
||||||
|
json.dump(data, handle, indent=2, sort_keys=True)
|
||||||
|
handle.write("\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.chmod(temp, 0o600)
|
||||||
|
os.chown(temp, 0, 0)
|
||||||
|
os.replace(temp, path)
|
||||||
|
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||||
|
try:
|
||||||
|
os.fsync(directory)
|
||||||
|
finally:
|
||||||
|
os.close(directory)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(temp)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
PY
|
||||||
|
|
||||||
|
printf 'AIOS_ORCHESTRATOR_ROLLBACK=PASS current=%s previous=%s\n' "$PREVIOUS" "$CURRENT"
|
||||||
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
CONFIG="${1:-/etc/aios-orchestrator-gateway/config.json}"
|
||||||
|
PORT="${PORT:-18920}"
|
||||||
|
|
||||||
|
python3 -m json.tool "$CONFIG" >/dev/null
|
||||||
|
systemd-analyze verify \
|
||||||
|
/etc/systemd/system/aios-orchestrator-gateway.service \
|
||||||
|
/etc/systemd/system/aios-orchestrator-gateway-poll.service \
|
||||||
|
/etc/systemd/system/aios-orchestrator-gateway-poll.timer
|
||||||
|
systemctl is-active --quiet aios-orchestrator-gateway.service
|
||||||
|
systemctl is-active --quiet aios-orchestrator-gateway-poll.timer
|
||||||
|
|
||||||
|
health="$(curl --fail --silent --show-error "http://127.0.0.1:${PORT}/healthz")"
|
||||||
|
python3 -c '
|
||||||
|
import json, sys
|
||||||
|
v=json.load(sys.stdin)
|
||||||
|
assert v["service"]=="aios-orchestrator-gateway"
|
||||||
|
assert v["status"]=="active"
|
||||||
|
assert v["polling"] is True
|
||||||
|
assert len(v["contract_commit"])==40
|
||||||
|
' <<<"$health"
|
||||||
|
|
||||||
|
systemctl start aios-orchestrator-gateway-poll.service
|
||||||
|
if journalctl -u aios-orchestrator-gateway.service -n 200 --no-pager \
|
||||||
|
| grep -Eqi 'Bearer |token=|api[_-]?key|secret='; then
|
||||||
|
echo "AIOS_ORCHESTRATOR_VERIFY=BLOCKED reason=SECRET_PATTERN_IN_LOG" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "AIOS_ORCHESTRATOR_VERIFY=PASS"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""AIOS services namespace."""
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Reliable central orchestration gateway for AIOS."""
|
||||||
|
|
||||||
|
__version__ = "0.2.0"
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
from .service import Runtime, serve
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="AIOS Orchestrator Gateway")
|
||||||
|
parser.add_argument("--config", type=Path, required=True)
|
||||||
|
parser.add_argument("--log-level", default="INFO")
|
||||||
|
parser.add_argument(
|
||||||
|
"--poll-once",
|
||||||
|
action="store_true",
|
||||||
|
help="Run one inbox/model/outbox/OpenClaw synchronization polling cycle.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, args.log_level.upper(), logging.INFO),
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||||
|
)
|
||||||
|
if args.poll_once:
|
||||||
|
runtime = Runtime(Config.from_file(args.config))
|
||||||
|
runtime.poll_once()
|
||||||
|
return 0
|
||||||
|
serve(args.config)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .errors import TriggerError
|
||||||
|
from .models import DECISION_SCHEMA, Decision, Event
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceAgentClient:
|
||||||
|
"""Asynchronous Workspace Agent trigger.
|
||||||
|
|
||||||
|
The official endpoint returns only 202 and no run ID or retrievable output.
|
||||||
|
Completion therefore arrives through the gateway callback endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, trigger_id: str, token: str, timeout_seconds: int = 30):
|
||||||
|
self.trigger_id = trigger_id
|
||||||
|
self.token = token
|
||||||
|
self.timeout_seconds = timeout_seconds
|
||||||
|
|
||||||
|
def trigger(self, event: Event) -> None:
|
||||||
|
url = f"https://api.chatgpt.com/v1/workspace_agents/{self.trigger_id}/trigger"
|
||||||
|
prompt = (
|
||||||
|
"Process this AIOS orchestration event under the committed contract. "
|
||||||
|
"When complete, call submit_orchestration_decision with strict JSON. "
|
||||||
|
"Do not fabricate tests, Git state, secrets, evidence, or deployment facts.\n\n"
|
||||||
|
+ event.canonical_json()
|
||||||
|
)
|
||||||
|
body = json.dumps(
|
||||||
|
{
|
||||||
|
"conversation_key": event.project_key,
|
||||||
|
"input": prompt,
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
request = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=body,
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {self.token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Idempotency-Key": event.event_id,
|
||||||
|
"User-Agent": "aios-orchestrator-gateway/0.2",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=self.timeout_seconds) as response:
|
||||||
|
response.read(1024)
|
||||||
|
if response.status != 202:
|
||||||
|
raise TriggerError(f"workspace_agent_http_{response.status}")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raise TriggerError(f"workspace_agent_http_{exc.code}") from exc
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||||
|
raise TriggerError("workspace_agent_trigger_failed") from exc
|
||||||
|
|
||||||
|
|
||||||
|
class ResponsesClient:
|
||||||
|
"""Background Responses API client with polling and strict output validation."""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, model: str, timeout_seconds: int = 60):
|
||||||
|
self.api_key = api_key
|
||||||
|
self.model = model
|
||||||
|
self.timeout_seconds = timeout_seconds
|
||||||
|
|
||||||
|
def start(self, event: Event) -> str:
|
||||||
|
body = {
|
||||||
|
"model": self.model,
|
||||||
|
"background": True,
|
||||||
|
"instructions": (
|
||||||
|
"You are the AIOS central orchestration brain. Apply the exact "
|
||||||
|
"contract_commit in the event. Return only a strict decision. "
|
||||||
|
"Never fabricate evidence, secrets, Git facts, tests, approvals, "
|
||||||
|
"or deployment state. Keep external blockers fail-closed."
|
||||||
|
),
|
||||||
|
"input": event.canonical_json(),
|
||||||
|
"text": {
|
||||||
|
"format": {
|
||||||
|
"type": "json_schema",
|
||||||
|
"name": "aios_orchestrator_decision",
|
||||||
|
"strict": True,
|
||||||
|
"schema": DECISION_SCHEMA,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"event_id": event.event_id,
|
||||||
|
"project_key": event.project_key,
|
||||||
|
"contract_commit": event.contract_commit,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
payload = self._request(
|
||||||
|
"POST",
|
||||||
|
"https://api.openai.com/v1/responses",
|
||||||
|
body,
|
||||||
|
idempotency_key=event.event_id,
|
||||||
|
)
|
||||||
|
response_id = payload.get("id")
|
||||||
|
if not isinstance(response_id, str) or not response_id.startswith("resp_"):
|
||||||
|
raise TriggerError("responses_missing_id")
|
||||||
|
return response_id
|
||||||
|
|
||||||
|
def poll(self, response_id: str) -> tuple[str, Decision | None]:
|
||||||
|
payload = self._request(
|
||||||
|
"GET",
|
||||||
|
f"https://api.openai.com/v1/responses/{response_id}",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
status = payload.get("status")
|
||||||
|
if status in {"queued", "in_progress"}:
|
||||||
|
return str(status), None
|
||||||
|
if status == "completed":
|
||||||
|
text = _extract_output_text(payload)
|
||||||
|
try:
|
||||||
|
return "completed", Decision.from_dict(json.loads(text))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise TriggerError("responses_invalid_json") from exc
|
||||||
|
if status in {"failed", "cancelled", "incomplete"}:
|
||||||
|
raise TriggerError(f"responses_{status}")
|
||||||
|
raise TriggerError("responses_unknown_status")
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
body: dict[str, Any] | None,
|
||||||
|
*,
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
encoded = json.dumps(body).encode() if body is not None else None
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "aios-orchestrator-gateway/0.2",
|
||||||
|
}
|
||||||
|
if idempotency_key:
|
||||||
|
headers["Idempotency-Key"] = idempotency_key
|
||||||
|
request = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=encoded,
|
||||||
|
method=method,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=self.timeout_seconds) as response:
|
||||||
|
raw = response.read(4_194_304)
|
||||||
|
if response.status not in {200, 201}:
|
||||||
|
raise TriggerError(f"responses_http_{response.status}")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raise TriggerError(f"responses_http_{exc.code}") from exc
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||||
|
raise TriggerError("responses_request_failed") from exc
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise TriggerError("responses_invalid_payload") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise TriggerError("responses_payload_not_object")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_output_text(payload: dict[str, Any]) -> str:
|
||||||
|
output = payload.get("output")
|
||||||
|
if not isinstance(output, list):
|
||||||
|
raise TriggerError("responses_missing_output")
|
||||||
|
for item in output:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
content = item.get("content")
|
||||||
|
if not isinstance(content, list):
|
||||||
|
continue
|
||||||
|
for part in content:
|
||||||
|
if isinstance(part, dict) and part.get("type") == "output_text":
|
||||||
|
text = part.get("text")
|
||||||
|
if isinstance(text, str):
|
||||||
|
return text
|
||||||
|
raise TriggerError("responses_missing_output_text")
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .errors import ConfigurationError
|
||||||
|
from .securefs import read_secure_json, read_secure_text
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Config:
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
project_key: str
|
||||||
|
database_path: Path
|
||||||
|
event_inbox_dir: Path
|
||||||
|
decision_inbox_dir: Path
|
||||||
|
dispatch_outbox_dir: Path
|
||||||
|
openclaw_sync_dir: Path
|
||||||
|
command_queue_dir: Path
|
||||||
|
inbound_token_file: Path
|
||||||
|
callback_token_file: Path
|
||||||
|
contract_commit: str
|
||||||
|
workspace_trigger_id: str | None
|
||||||
|
workspace_token_file: Path | None
|
||||||
|
responses_api_key_file: Path | None
|
||||||
|
responses_model: str | None
|
||||||
|
poll_interval_seconds: int
|
||||||
|
summary_interval_seconds: int
|
||||||
|
callback_timeout_seconds: int
|
||||||
|
lease_seconds: int
|
||||||
|
retry_base_seconds: int
|
||||||
|
max_retries: int
|
||||||
|
max_same_blocker_retries: int
|
||||||
|
max_hops_per_gate: int
|
||||||
|
openclaw_sync_endpoint: str | None
|
||||||
|
openclaw_sync_token_file: Path | None
|
||||||
|
git_poll_enabled: bool
|
||||||
|
git_poll_bare_repo: Path | None
|
||||||
|
git_poll_ref_prefixes: tuple[str, ...]
|
||||||
|
git_poll_interval_seconds: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_file(cls, path: Path) -> "Config":
|
||||||
|
owner_uid = int(os.environ.get("AIOS_ORCHESTRATOR_CONFIG_OWNER_UID", "0"))
|
||||||
|
raw = read_secure_json(path, expected_uid=owner_uid)
|
||||||
|
expected = {
|
||||||
|
"host", "port", "project_key", "database_path", "event_inbox_dir",
|
||||||
|
"decision_inbox_dir", "dispatch_outbox_dir", "openclaw_sync_dir",
|
||||||
|
"command_queue_dir", "inbound_token_file", "callback_token_file",
|
||||||
|
"contract_commit", "workspace_agent", "responses_fallback",
|
||||||
|
"polling", "loop_controls", "openclaw_sync", "git_poll",
|
||||||
|
}
|
||||||
|
if set(raw) != expected:
|
||||||
|
raise ConfigurationError("invalid_config_fields")
|
||||||
|
|
||||||
|
def path_value(key: str) -> Path:
|
||||||
|
value = raw.get(key)
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ConfigurationError(f"invalid_{key}")
|
||||||
|
result = Path(value)
|
||||||
|
if not result.is_absolute():
|
||||||
|
raise ConfigurationError(f"path_not_absolute:{key}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def integer(container: dict[str, Any], key: str, lo: int, hi: int) -> int:
|
||||||
|
value = container.get(key)
|
||||||
|
if not isinstance(value, int) or not lo <= value <= hi:
|
||||||
|
raise ConfigurationError(f"invalid_{key}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
host = raw.get("host")
|
||||||
|
port = raw.get("port")
|
||||||
|
project_key = raw.get("project_key")
|
||||||
|
contract = raw.get("contract_commit")
|
||||||
|
if not isinstance(host, str) or not host:
|
||||||
|
raise ConfigurationError("invalid_host")
|
||||||
|
if not isinstance(project_key, str) or not project_key:
|
||||||
|
raise ConfigurationError("invalid_project_key")
|
||||||
|
if not isinstance(port, int) or not 1 <= port <= 65535:
|
||||||
|
raise ConfigurationError("invalid_port")
|
||||||
|
if not isinstance(contract, str) or len(contract) != 40:
|
||||||
|
raise ConfigurationError("invalid_contract_commit")
|
||||||
|
|
||||||
|
workspace = raw.get("workspace_agent")
|
||||||
|
responses = raw.get("responses_fallback")
|
||||||
|
polling = raw.get("polling")
|
||||||
|
loop = raw.get("loop_controls")
|
||||||
|
sync = raw.get("openclaw_sync")
|
||||||
|
git_poll = raw.get("git_poll")
|
||||||
|
if not all(isinstance(x, dict) for x in (workspace, responses, polling, loop, sync, git_poll)):
|
||||||
|
raise ConfigurationError("invalid_nested_config")
|
||||||
|
|
||||||
|
trigger_id = workspace.get("trigger_id")
|
||||||
|
workspace_token = workspace.get("access_token_file")
|
||||||
|
if trigger_id is not None:
|
||||||
|
if not isinstance(trigger_id, str) or not trigger_id.startswith("agtch_"):
|
||||||
|
raise ConfigurationError("invalid_workspace_trigger_id")
|
||||||
|
if not isinstance(workspace_token, str) or not Path(workspace_token).is_absolute():
|
||||||
|
raise ConfigurationError("invalid_workspace_token_file")
|
||||||
|
workspace_token_file = Path(workspace_token)
|
||||||
|
else:
|
||||||
|
workspace_token_file = None
|
||||||
|
|
||||||
|
responses_key = responses.get("api_key_file")
|
||||||
|
responses_model = responses.get("model")
|
||||||
|
if responses_key is not None:
|
||||||
|
if not isinstance(responses_key, str) or not Path(responses_key).is_absolute():
|
||||||
|
raise ConfigurationError("invalid_responses_key_file")
|
||||||
|
if not isinstance(responses_model, str) or not responses_model:
|
||||||
|
raise ConfigurationError("invalid_responses_model")
|
||||||
|
responses_key_file = Path(responses_key)
|
||||||
|
else:
|
||||||
|
responses_key_file = None
|
||||||
|
responses_model = None
|
||||||
|
|
||||||
|
if trigger_id is None and responses_key_file is None:
|
||||||
|
raise ConfigurationError("no_central_model_channel")
|
||||||
|
|
||||||
|
endpoint = sync.get("endpoint")
|
||||||
|
token_file = sync.get("token_file")
|
||||||
|
if endpoint is not None and (not isinstance(endpoint, str) or not endpoint.startswith(("http://", "https://"))):
|
||||||
|
raise ConfigurationError("invalid_openclaw_sync_endpoint")
|
||||||
|
if endpoint is not None:
|
||||||
|
if not isinstance(token_file, str) or not Path(token_file).is_absolute():
|
||||||
|
raise ConfigurationError("invalid_openclaw_sync_token_file")
|
||||||
|
sync_token_file = Path(token_file)
|
||||||
|
else:
|
||||||
|
sync_token_file = None
|
||||||
|
|
||||||
|
git_enabled = git_poll.get("enabled")
|
||||||
|
if not isinstance(git_enabled, bool):
|
||||||
|
raise ConfigurationError("invalid_git_poll_enabled")
|
||||||
|
git_bare_repo_raw = git_poll.get("bare_repo")
|
||||||
|
git_prefixes_raw = git_poll.get("ref_prefixes")
|
||||||
|
if git_enabled:
|
||||||
|
if not isinstance(git_bare_repo_raw, str) or not Path(git_bare_repo_raw).is_absolute():
|
||||||
|
raise ConfigurationError("invalid_git_poll_bare_repo")
|
||||||
|
if (
|
||||||
|
not isinstance(git_prefixes_raw, list)
|
||||||
|
or not git_prefixes_raw
|
||||||
|
or any(not isinstance(item, str) or not item.startswith("refs/") for item in git_prefixes_raw)
|
||||||
|
):
|
||||||
|
raise ConfigurationError("invalid_git_poll_ref_prefixes")
|
||||||
|
git_bare_repo = Path(git_bare_repo_raw)
|
||||||
|
git_prefixes = tuple(git_prefixes_raw)
|
||||||
|
else:
|
||||||
|
git_bare_repo = None
|
||||||
|
git_prefixes = ()
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
project_key=project_key,
|
||||||
|
database_path=path_value("database_path"),
|
||||||
|
event_inbox_dir=path_value("event_inbox_dir"),
|
||||||
|
decision_inbox_dir=path_value("decision_inbox_dir"),
|
||||||
|
dispatch_outbox_dir=path_value("dispatch_outbox_dir"),
|
||||||
|
openclaw_sync_dir=path_value("openclaw_sync_dir"),
|
||||||
|
command_queue_dir=path_value("command_queue_dir"),
|
||||||
|
inbound_token_file=path_value("inbound_token_file"),
|
||||||
|
callback_token_file=path_value("callback_token_file"),
|
||||||
|
contract_commit=contract,
|
||||||
|
workspace_trigger_id=trigger_id,
|
||||||
|
workspace_token_file=workspace_token_file,
|
||||||
|
responses_api_key_file=responses_key_file,
|
||||||
|
responses_model=responses_model,
|
||||||
|
poll_interval_seconds=integer(polling, "interval_seconds", 1, 300),
|
||||||
|
summary_interval_seconds=integer(polling, "summary_interval_seconds", 10, 86400),
|
||||||
|
callback_timeout_seconds=integer(loop, "callback_timeout_seconds", 30, 86400),
|
||||||
|
lease_seconds=integer(loop, "lease_seconds", 10, 3600),
|
||||||
|
retry_base_seconds=integer(loop, "retry_base_seconds", 1, 3600),
|
||||||
|
max_retries=integer(loop, "max_retries", 0, 20),
|
||||||
|
max_same_blocker_retries=integer(loop, "max_same_blocker_retries", 0, 20),
|
||||||
|
max_hops_per_gate=integer(loop, "max_hops_per_gate", 1, 100),
|
||||||
|
openclaw_sync_endpoint=endpoint,
|
||||||
|
openclaw_sync_token_file=sync_token_file,
|
||||||
|
git_poll_enabled=git_enabled,
|
||||||
|
git_poll_bare_repo=git_bare_repo,
|
||||||
|
git_poll_ref_prefixes=git_prefixes,
|
||||||
|
git_poll_interval_seconds=integer(git_poll, "interval_seconds", 5, 3600),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _secret(self, path: Path) -> str:
|
||||||
|
owner_uid = int(os.environ.get("AIOS_ORCHESTRATOR_SECRET_OWNER_UID", "0"))
|
||||||
|
return read_secure_text(path, expected_uid=owner_uid, max_bytes=64_000)
|
||||||
|
|
||||||
|
def inbound_token(self) -> str:
|
||||||
|
return self._secret(self.inbound_token_file)
|
||||||
|
|
||||||
|
def callback_token(self) -> str:
|
||||||
|
return self._secret(self.callback_token_file)
|
||||||
|
|
||||||
|
def workspace_token(self) -> str | None:
|
||||||
|
return self._secret(self.workspace_token_file) if self.workspace_token_file else None
|
||||||
|
|
||||||
|
def responses_api_key(self) -> str | None:
|
||||||
|
return self._secret(self.responses_api_key_file) if self.responses_api_key_file else None
|
||||||
|
|
||||||
|
def openclaw_sync_token(self) -> str | None:
|
||||||
|
return self._secret(self.openclaw_sync_token_file) if self.openclaw_sync_token_file else None
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .errors import DispatchError
|
||||||
|
from .securefs import (
|
||||||
|
atomic_write_bytes,
|
||||||
|
atomic_write_bytes_shared,
|
||||||
|
ensure_secure_directory,
|
||||||
|
ensure_shared_directory,
|
||||||
|
read_secure_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchSink:
|
||||||
|
"""Reliable file-based dispatch adapter for existing OpenClaw/Codex watchers."""
|
||||||
|
|
||||||
|
def __init__(self, outbox_dir: Path, command_queue_dir: Path, contract_commit: str):
|
||||||
|
self.outbox_dir = Path(outbox_dir)
|
||||||
|
self.command_queue_dir = Path(command_queue_dir)
|
||||||
|
self.contract_commit = contract_commit
|
||||||
|
ensure_secure_directory(self.outbox_dir)
|
||||||
|
ensure_shared_directory(self.command_queue_dir)
|
||||||
|
|
||||||
|
def dispatch(self, record: dict[str, Any]) -> tuple[Path, Path]:
|
||||||
|
work_unit_id = record.get("work_unit_id")
|
||||||
|
event_id = record.get("event_id")
|
||||||
|
agent = record.get("assigned_agent")
|
||||||
|
payload_json = record.get("payload_json")
|
||||||
|
if (
|
||||||
|
not isinstance(work_unit_id, str)
|
||||||
|
or not SAFE_ID.fullmatch(work_unit_id)
|
||||||
|
or not isinstance(event_id, str)
|
||||||
|
or not SAFE_ID.fullmatch(event_id)
|
||||||
|
or agent not in {"openclaw", "codex"}
|
||||||
|
or not isinstance(payload_json, str)
|
||||||
|
):
|
||||||
|
raise DispatchError("invalid_dispatch_record")
|
||||||
|
try:
|
||||||
|
work_unit = json.loads(payload_json)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise DispatchError("invalid_dispatch_payload") from exc
|
||||||
|
|
||||||
|
envelope = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"contract_commit": self.contract_commit,
|
||||||
|
"event_id": event_id,
|
||||||
|
"assigned_agent": agent,
|
||||||
|
"work_unit": work_unit,
|
||||||
|
"callback": {
|
||||||
|
"event_endpoint": "/v1/orchestrator/events",
|
||||||
|
"start_event_type": "work_unit_started",
|
||||||
|
"end_event_type": "work_unit_completed",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
outbox_path = self.outbox_dir / "dispatched" / f"{work_unit_id}.json"
|
||||||
|
queue_path = self.command_queue_dir / agent / "pending" / f"{work_unit_id}.json"
|
||||||
|
ensure_secure_directory(outbox_path.parent)
|
||||||
|
ensure_shared_directory(queue_path.parent)
|
||||||
|
|
||||||
|
# Idempotent immutable payload: existing identical file is accepted.
|
||||||
|
self._write_idempotent_json(outbox_path, envelope, shared=False)
|
||||||
|
self._write_idempotent_json(queue_path, envelope, shared=True)
|
||||||
|
return outbox_path, queue_path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_idempotent_json(path: Path, value: dict[str, Any], *, shared: bool) -> None:
|
||||||
|
payload = (json.dumps(value, indent=2, sort_keys=True) + "\n").encode()
|
||||||
|
try:
|
||||||
|
st = os.lstat(path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
if shared:
|
||||||
|
atomic_write_bytes_shared(path, payload, mode=0o640)
|
||||||
|
else:
|
||||||
|
atomic_write_bytes(path, payload, mode=0o600)
|
||||||
|
return
|
||||||
|
if not stat.S_ISREG(st.st_mode):
|
||||||
|
raise DispatchError(f"dispatch_target_not_regular:{path.name}")
|
||||||
|
existing = read_secure_bytes(path, expected_uid=None, max_bytes=1_048_576)
|
||||||
|
if existing != payload:
|
||||||
|
raise DispatchError(f"dispatch_idempotency_conflict:{path.name}")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
class OrchestratorError(Exception):
|
||||||
|
"""Base orchestration error."""
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigurationError(OrchestratorError):
|
||||||
|
"""Unsafe or invalid configuration."""
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(OrchestratorError):
|
||||||
|
"""Invalid event or decision."""
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationError(OrchestratorError):
|
||||||
|
"""Authentication failed."""
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictError(OrchestratorError):
|
||||||
|
"""Idempotency, lease, or optimistic concurrency conflict."""
|
||||||
|
|
||||||
|
|
||||||
|
class TriggerError(OrchestratorError):
|
||||||
|
"""Central model trigger failed."""
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchError(OrchestratorError):
|
||||||
|
"""Dispatch or synchronization output failed."""
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .errors import ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
|
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||||
|
AGENTS = {"openclaw", "codex", "gitea", "system", "workspace-agent"}
|
||||||
|
EVENT_STATUSES = {"READY", "RUNNING", "PASS", "BLOCKED", "FAIL", "TIMEOUT", "NOT_EXECUTED"}
|
||||||
|
VERDICTS = {"DISPATCH", "BLOCKED", "COMPLETE", "NOOP"}
|
||||||
|
EXECUTORS = {"openclaw", "codex"}
|
||||||
|
|
||||||
|
|
||||||
|
def req_str(value: dict[str, Any], key: str, pattern: re.Pattern[str] | None = None) -> str:
|
||||||
|
item = value.get(key)
|
||||||
|
if not isinstance(item, str) or not item:
|
||||||
|
raise ValidationError(f"invalid_{key}")
|
||||||
|
if pattern is not None and not pattern.fullmatch(item):
|
||||||
|
raise ValidationError(f"invalid_{key}")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def opt_oid(item: Any, key: str) -> str | None:
|
||||||
|
if item is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(item, str) or not OID_RE.fullmatch(item):
|
||||||
|
raise ValidationError(f"invalid_{key}")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def string_list(value: dict[str, Any], key: str, limit: int) -> tuple[str, ...]:
|
||||||
|
raw = value.get(key)
|
||||||
|
if not isinstance(raw, list) or len(raw) > limit:
|
||||||
|
raise ValidationError(f"invalid_{key}")
|
||||||
|
result: list[str] = []
|
||||||
|
for item in raw:
|
||||||
|
if not isinstance(item, str) or not item or len(item) > 1024:
|
||||||
|
raise ValidationError(f"invalid_{key}")
|
||||||
|
result.append(item)
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Event:
|
||||||
|
schema_version: int
|
||||||
|
project_key: str
|
||||||
|
contract_commit: str
|
||||||
|
event_id: str
|
||||||
|
source_agent: str
|
||||||
|
event_type: str
|
||||||
|
work_unit_id: str
|
||||||
|
status: str
|
||||||
|
state_version: int
|
||||||
|
implementation_commit: str | None = None
|
||||||
|
result_commit: str | None = None
|
||||||
|
blocker: str | None = None
|
||||||
|
evidence_refs: tuple[str, ...] = ()
|
||||||
|
payload: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, value: dict[str, Any]) -> "Event":
|
||||||
|
allowed = {
|
||||||
|
"schema_version", "project_key", "contract_commit", "event_id",
|
||||||
|
"source_agent", "event_type", "work_unit_id", "status",
|
||||||
|
"state_version", "implementation_commit", "result_commit",
|
||||||
|
"blocker", "evidence_refs", "payload",
|
||||||
|
}
|
||||||
|
if not isinstance(value, dict) or set(value) - allowed:
|
||||||
|
raise ValidationError("invalid_event_fields")
|
||||||
|
if value.get("schema_version") != 1:
|
||||||
|
raise ValidationError("unsupported_event_schema")
|
||||||
|
source = req_str(value, "source_agent")
|
||||||
|
status = req_str(value, "status")
|
||||||
|
if source not in AGENTS:
|
||||||
|
raise ValidationError("invalid_source_agent")
|
||||||
|
if status not in EVENT_STATUSES:
|
||||||
|
raise ValidationError("invalid_event_status")
|
||||||
|
version = value.get("state_version")
|
||||||
|
if not isinstance(version, int) or version < 0:
|
||||||
|
raise ValidationError("invalid_state_version")
|
||||||
|
blocker = value.get("blocker")
|
||||||
|
if blocker is not None and (not isinstance(blocker, str) or len(blocker) > 512):
|
||||||
|
raise ValidationError("invalid_blocker")
|
||||||
|
payload = value.get("payload", {})
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValidationError("invalid_payload")
|
||||||
|
if len(json.dumps(payload, sort_keys=True).encode()) > 262_144:
|
||||||
|
raise ValidationError("payload_too_large")
|
||||||
|
return cls(
|
||||||
|
schema_version=1,
|
||||||
|
project_key=req_str(value, "project_key", ID_RE),
|
||||||
|
contract_commit=req_str(value, "contract_commit", OID_RE),
|
||||||
|
event_id=req_str(value, "event_id", ID_RE),
|
||||||
|
source_agent=source,
|
||||||
|
event_type=req_str(value, "event_type", ID_RE),
|
||||||
|
work_unit_id=req_str(value, "work_unit_id", ID_RE),
|
||||||
|
status=status,
|
||||||
|
state_version=version,
|
||||||
|
implementation_commit=opt_oid(value.get("implementation_commit"), "implementation_commit"),
|
||||||
|
result_commit=opt_oid(value.get("result_commit"), "result_commit"),
|
||||||
|
blocker=blocker,
|
||||||
|
evidence_refs=string_list(value, "evidence_refs", 64),
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"project_key": self.project_key,
|
||||||
|
"contract_commit": self.contract_commit,
|
||||||
|
"event_id": self.event_id,
|
||||||
|
"source_agent": self.source_agent,
|
||||||
|
"event_type": self.event_type,
|
||||||
|
"work_unit_id": self.work_unit_id,
|
||||||
|
"status": self.status,
|
||||||
|
"state_version": self.state_version,
|
||||||
|
"implementation_commit": self.implementation_commit,
|
||||||
|
"result_commit": self.result_commit,
|
||||||
|
"blocker": self.blocker,
|
||||||
|
"evidence_refs": list(self.evidence_refs),
|
||||||
|
"payload": self.payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
def canonical_json(self) -> str:
|
||||||
|
return json.dumps(self.to_dict(), separators=(",", ":"), sort_keys=True)
|
||||||
|
|
||||||
|
def digest(self) -> str:
|
||||||
|
return hashlib.sha256(self.canonical_json().encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WorkUnit:
|
||||||
|
id: str
|
||||||
|
track_id: str
|
||||||
|
gate_id: str
|
||||||
|
primary_agent: str
|
||||||
|
fallback_agent: str
|
||||||
|
clean_base_commit: str
|
||||||
|
allowed_paths: tuple[str, ...]
|
||||||
|
required_checks: tuple[str, ...]
|
||||||
|
forbidden_operations: tuple[str, ...]
|
||||||
|
instruction: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, value: dict[str, Any]) -> "WorkUnit":
|
||||||
|
expected = {
|
||||||
|
"id", "track_id", "gate_id", "primary_agent", "fallback_agent",
|
||||||
|
"clean_base_commit", "allowed_paths", "required_checks",
|
||||||
|
"forbidden_operations", "instruction",
|
||||||
|
}
|
||||||
|
if not isinstance(value, dict) or set(value) != expected:
|
||||||
|
raise ValidationError("invalid_work_unit_fields")
|
||||||
|
primary = req_str(value, "primary_agent")
|
||||||
|
fallback = req_str(value, "fallback_agent")
|
||||||
|
if primary not in EXECUTORS or fallback not in EXECUTORS or primary == fallback:
|
||||||
|
raise ValidationError("invalid_agent_assignment")
|
||||||
|
instruction = req_str(value, "instruction")
|
||||||
|
if len(instruction.encode()) > 65_536:
|
||||||
|
raise ValidationError("instruction_too_large")
|
||||||
|
return cls(
|
||||||
|
id=req_str(value, "id", ID_RE),
|
||||||
|
track_id=req_str(value, "track_id", ID_RE),
|
||||||
|
gate_id=req_str(value, "gate_id", ID_RE),
|
||||||
|
primary_agent=primary,
|
||||||
|
fallback_agent=fallback,
|
||||||
|
clean_base_commit=req_str(value, "clean_base_commit", OID_RE),
|
||||||
|
allowed_paths=string_list(value, "allowed_paths", 128),
|
||||||
|
required_checks=string_list(value, "required_checks", 128),
|
||||||
|
forbidden_operations=string_list(value, "forbidden_operations", 64),
|
||||||
|
instruction=instruction,
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"track_id": self.track_id,
|
||||||
|
"gate_id": self.gate_id,
|
||||||
|
"primary_agent": self.primary_agent,
|
||||||
|
"fallback_agent": self.fallback_agent,
|
||||||
|
"clean_base_commit": self.clean_base_commit,
|
||||||
|
"allowed_paths": list(self.allowed_paths),
|
||||||
|
"required_checks": list(self.required_checks),
|
||||||
|
"forbidden_operations": list(self.forbidden_operations),
|
||||||
|
"instruction": self.instruction,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Decision:
|
||||||
|
schema_version: int
|
||||||
|
project_key: str
|
||||||
|
contract_commit: str
|
||||||
|
event_id: str
|
||||||
|
expected_state_version: int
|
||||||
|
verdict: str
|
||||||
|
state_updates: dict[str, str]
|
||||||
|
next_work_units: tuple[WorkUnit, ...]
|
||||||
|
requires_human_input: bool
|
||||||
|
stop_reason: str | None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, value: dict[str, Any]) -> "Decision":
|
||||||
|
expected = {
|
||||||
|
"schema_version", "project_key", "contract_commit", "event_id",
|
||||||
|
"expected_state_version", "verdict", "state_updates",
|
||||||
|
"next_work_units", "requires_human_input", "stop_reason",
|
||||||
|
}
|
||||||
|
if not isinstance(value, dict) or set(value) != expected:
|
||||||
|
raise ValidationError("invalid_decision_fields")
|
||||||
|
if value.get("schema_version") != 1:
|
||||||
|
raise ValidationError("unsupported_decision_schema")
|
||||||
|
version = value.get("expected_state_version")
|
||||||
|
if not isinstance(version, int) or version < 0:
|
||||||
|
raise ValidationError("invalid_expected_state_version")
|
||||||
|
verdict = req_str(value, "verdict")
|
||||||
|
if verdict not in VERDICTS:
|
||||||
|
raise ValidationError("invalid_verdict")
|
||||||
|
updates = value.get("state_updates")
|
||||||
|
normalized: dict[str, str] = {}
|
||||||
|
if isinstance(updates, dict):
|
||||||
|
update_items = [{"key": key, "value": item} for key, item in updates.items()]
|
||||||
|
elif isinstance(updates, list):
|
||||||
|
update_items = updates
|
||||||
|
else:
|
||||||
|
raise ValidationError("invalid_state_updates")
|
||||||
|
if len(update_items) > 128:
|
||||||
|
raise ValidationError("invalid_state_updates")
|
||||||
|
for update in update_items:
|
||||||
|
if not isinstance(update, dict) or set(update) != {"key", "value"}:
|
||||||
|
raise ValidationError("invalid_state_update")
|
||||||
|
key = update.get("key")
|
||||||
|
item = update.get("value")
|
||||||
|
if not isinstance(key, str) or not ID_RE.fullmatch(key):
|
||||||
|
raise ValidationError("invalid_state_update_key")
|
||||||
|
if not isinstance(item, str) or not item or len(item) > 256:
|
||||||
|
raise ValidationError("invalid_state_update_value")
|
||||||
|
if key in normalized:
|
||||||
|
raise ValidationError("duplicate_state_update_key")
|
||||||
|
normalized[key] = item
|
||||||
|
raw_units = value.get("next_work_units")
|
||||||
|
if not isinstance(raw_units, list) or len(raw_units) > 32:
|
||||||
|
raise ValidationError("invalid_next_work_units")
|
||||||
|
units = tuple(WorkUnit.from_dict(item) for item in raw_units)
|
||||||
|
human = value.get("requires_human_input")
|
||||||
|
if not isinstance(human, bool):
|
||||||
|
raise ValidationError("invalid_requires_human_input")
|
||||||
|
stop = value.get("stop_reason")
|
||||||
|
if stop is not None and (not isinstance(stop, str) or len(stop) > 512):
|
||||||
|
raise ValidationError("invalid_stop_reason")
|
||||||
|
if verdict == "DISPATCH" and not units:
|
||||||
|
raise ValidationError("dispatch_without_work_units")
|
||||||
|
if verdict != "DISPATCH" and units:
|
||||||
|
raise ValidationError("work_units_without_dispatch")
|
||||||
|
return cls(
|
||||||
|
schema_version=1,
|
||||||
|
project_key=req_str(value, "project_key", ID_RE),
|
||||||
|
contract_commit=req_str(value, "contract_commit", OID_RE),
|
||||||
|
event_id=req_str(value, "event_id", ID_RE),
|
||||||
|
expected_state_version=version,
|
||||||
|
verdict=verdict,
|
||||||
|
state_updates=normalized,
|
||||||
|
next_work_units=units,
|
||||||
|
requires_human_input=human,
|
||||||
|
stop_reason=stop,
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"project_key": self.project_key,
|
||||||
|
"contract_commit": self.contract_commit,
|
||||||
|
"event_id": self.event_id,
|
||||||
|
"expected_state_version": self.expected_state_version,
|
||||||
|
"verdict": self.verdict,
|
||||||
|
"state_updates": [
|
||||||
|
{"key": key, "value": value}
|
||||||
|
for key, value in sorted(self.state_updates.items())
|
||||||
|
],
|
||||||
|
"next_work_units": [u.to_dict() for u in self.next_work_units],
|
||||||
|
"requires_human_input": self.requires_human_input,
|
||||||
|
"stop_reason": self.stop_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
DECISION_SCHEMA: dict[str, Any] = {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": False,
|
||||||
|
"required": [
|
||||||
|
"schema_version", "project_key", "contract_commit", "event_id",
|
||||||
|
"expected_state_version", "verdict", "state_updates",
|
||||||
|
"next_work_units", "requires_human_input", "stop_reason",
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"schema_version": {"type": "integer", "const": 1},
|
||||||
|
"project_key": {"type": "string"},
|
||||||
|
"contract_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
|
||||||
|
"event_id": {"type": "string"},
|
||||||
|
"expected_state_version": {"type": "integer", "minimum": 0},
|
||||||
|
"verdict": {"type": "string", "enum": sorted(VERDICTS)},
|
||||||
|
"state_updates": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": False,
|
||||||
|
"required": ["key", "value"],
|
||||||
|
"properties": {
|
||||||
|
"key": {"type": "string"},
|
||||||
|
"value": {"type": "string"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"next_work_units": {
|
||||||
|
"type": "array",
|
||||||
|
"maxItems": 32,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": False,
|
||||||
|
"required": [
|
||||||
|
"id", "track_id", "gate_id", "primary_agent",
|
||||||
|
"fallback_agent", "clean_base_commit", "allowed_paths",
|
||||||
|
"required_checks", "forbidden_operations", "instruction",
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {"type": "string"},
|
||||||
|
"track_id": {"type": "string"},
|
||||||
|
"gate_id": {"type": "string"},
|
||||||
|
"primary_agent": {"type": "string", "enum": ["openclaw", "codex"]},
|
||||||
|
"fallback_agent": {"type": "string", "enum": ["openclaw", "codex"]},
|
||||||
|
"clean_base_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
|
||||||
|
"allowed_paths": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"required_checks": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"forbidden_operations": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"instruction": {"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"requires_human_input": {"type": "boolean"},
|
||||||
|
"stop_reason": {"type": ["string", "null"]},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import stat
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .errors import ConfigurationError
|
||||||
|
|
||||||
|
|
||||||
|
_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
|
||||||
|
|
||||||
|
def reject_symlink_chain(path: Path) -> None:
|
||||||
|
"""Reject symlinks in every existing component of an absolute path."""
|
||||||
|
path = Path(path)
|
||||||
|
if not path.is_absolute():
|
||||||
|
raise ConfigurationError(f"path_not_absolute:{path}")
|
||||||
|
current = Path(path.anchor)
|
||||||
|
for part in path.parts[1:]:
|
||||||
|
current = current / part
|
||||||
|
try:
|
||||||
|
st = os.lstat(current)
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
if stat.S_ISLNK(st.st_mode):
|
||||||
|
raise ConfigurationError(f"symlink_rejected:{current}")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_secure_file(
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
expected_uid: int | None,
|
||||||
|
allowed_write_bits: int = stat.S_IWUSR,
|
||||||
|
require_executable: bool = False,
|
||||||
|
) -> os.stat_result:
|
||||||
|
path = Path(path)
|
||||||
|
reject_symlink_chain(path)
|
||||||
|
before = os.lstat(path)
|
||||||
|
if not stat.S_ISREG(before.st_mode):
|
||||||
|
raise ConfigurationError(f"not_regular_file:{path}")
|
||||||
|
if expected_uid is not None and before.st_uid != expected_uid:
|
||||||
|
raise ConfigurationError(f"unexpected_owner:{path}")
|
||||||
|
write_bits = before.st_mode & (stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH)
|
||||||
|
if write_bits & ~allowed_write_bits:
|
||||||
|
raise ConfigurationError(f"permissive_mode:{path}")
|
||||||
|
if require_executable and not (before.st_mode & stat.S_IXUSR):
|
||||||
|
raise ConfigurationError(f"not_executable:{path}")
|
||||||
|
fd = os.open(path, os.O_RDONLY | _NOFOLLOW)
|
||||||
|
try:
|
||||||
|
after = os.fstat(fd)
|
||||||
|
if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino):
|
||||||
|
raise ConfigurationError(f"inode_changed:{path}")
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
return before
|
||||||
|
|
||||||
|
|
||||||
|
def read_secure_bytes(
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
expected_uid: int | None,
|
||||||
|
max_bytes: int = 1_048_576,
|
||||||
|
) -> bytes:
|
||||||
|
path = Path(path)
|
||||||
|
before = verify_secure_file(path, expected_uid=expected_uid)
|
||||||
|
if before.st_size > max_bytes:
|
||||||
|
raise ConfigurationError(f"file_too_large:{path}")
|
||||||
|
fd = os.open(path, os.O_RDONLY | _NOFOLLOW)
|
||||||
|
try:
|
||||||
|
data = bytearray()
|
||||||
|
while True:
|
||||||
|
chunk = os.read(fd, min(65536, max_bytes + 1 - len(data)))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
data.extend(chunk)
|
||||||
|
if len(data) > max_bytes:
|
||||||
|
raise ConfigurationError(f"file_too_large:{path}")
|
||||||
|
return bytes(data)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
|
||||||
|
def read_secure_text(path: Path, *, expected_uid: int | None, max_bytes: int = 1_048_576) -> str:
|
||||||
|
try:
|
||||||
|
return read_secure_bytes(path, expected_uid=expected_uid, max_bytes=max_bytes).decode("utf-8").strip()
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise ConfigurationError(f"not_utf8:{path}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def read_secure_json(path: Path, *, expected_uid: int | None, max_bytes: int = 1_048_576) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
value = json.loads(read_secure_text(path, expected_uid=expected_uid, max_bytes=max_bytes))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ConfigurationError(f"invalid_json:{path}") from exc
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ConfigurationError(f"json_root_not_object:{path}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_secure_directory(path: Path, *, mode: int = 0o700) -> None:
|
||||||
|
path = Path(path)
|
||||||
|
if not path.is_absolute():
|
||||||
|
raise ConfigurationError(f"path_not_absolute:{path}")
|
||||||
|
reject_symlink_chain(path.parent)
|
||||||
|
path.mkdir(parents=True, exist_ok=True, mode=mode)
|
||||||
|
reject_symlink_chain(path)
|
||||||
|
st = os.lstat(path)
|
||||||
|
if not stat.S_ISDIR(st.st_mode):
|
||||||
|
raise ConfigurationError(f"not_directory:{path}")
|
||||||
|
if st.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
|
||||||
|
raise ConfigurationError(f"permissive_directory:{path}")
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write_bytes(path: Path, payload: bytes, *, mode: int = 0o600) -> None:
|
||||||
|
"""Write through a verified directory fd and atomically replace the target."""
|
||||||
|
path = Path(path)
|
||||||
|
ensure_secure_directory(path.parent)
|
||||||
|
parent_before = os.lstat(path.parent)
|
||||||
|
parent_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | _NOFOLLOW)
|
||||||
|
temp_name = f".{path.name}.tmp-{os.getpid()}-{os.urandom(6).hex()}"
|
||||||
|
try:
|
||||||
|
parent_after = os.fstat(parent_fd)
|
||||||
|
if (parent_before.st_dev, parent_before.st_ino) != (
|
||||||
|
parent_after.st_dev,
|
||||||
|
parent_after.st_ino,
|
||||||
|
):
|
||||||
|
raise ConfigurationError(f"directory_changed:{path.parent}")
|
||||||
|
fd = os.open(
|
||||||
|
temp_name,
|
||||||
|
os.O_WRONLY | os.O_CREAT | os.O_EXCL | _NOFOLLOW,
|
||||||
|
mode,
|
||||||
|
dir_fd=parent_fd,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
view = memoryview(payload)
|
||||||
|
while view:
|
||||||
|
written = os.write(fd, view)
|
||||||
|
view = view[written:]
|
||||||
|
os.fsync(fd)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
os.replace(
|
||||||
|
temp_name,
|
||||||
|
path.name,
|
||||||
|
src_dir_fd=parent_fd,
|
||||||
|
dst_dir_fd=parent_fd,
|
||||||
|
)
|
||||||
|
os.fsync(parent_fd)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(temp_name, dir_fd=parent_fd)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
os.close(parent_fd)
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write_json(path: Path, value: Any, *, mode: int = 0o600) -> None:
|
||||||
|
payload = (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
||||||
|
atomic_write_bytes(path, payload, mode=mode)
|
||||||
|
|
||||||
|
def ensure_shared_directory(path: Path, *, group_writable: bool = False) -> None:
|
||||||
|
"""Verify/create a setgid queue directory shared with a consumer group."""
|
||||||
|
path = Path(path)
|
||||||
|
if not path.is_absolute():
|
||||||
|
raise ConfigurationError(f"path_not_absolute:{path}")
|
||||||
|
reject_symlink_chain(path.parent)
|
||||||
|
mode = 0o2770 if group_writable else 0o2750
|
||||||
|
try:
|
||||||
|
path.mkdir(parents=True, mode=mode)
|
||||||
|
except FileExistsError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
os.chmod(path, mode)
|
||||||
|
reject_symlink_chain(path)
|
||||||
|
st = os.lstat(path)
|
||||||
|
if not stat.S_ISDIR(st.st_mode):
|
||||||
|
raise ConfigurationError(f"not_directory:{path}")
|
||||||
|
observed = stat.S_IMODE(st.st_mode)
|
||||||
|
required = stat.S_ISGID | stat.S_IRGRP | stat.S_IXGRP
|
||||||
|
if group_writable:
|
||||||
|
required |= stat.S_IWGRP
|
||||||
|
if observed & 0o007 or observed & required != required:
|
||||||
|
raise ConfigurationError(f"invalid_shared_directory_mode:{path}")
|
||||||
|
|
||||||
|
def atomic_write_bytes_shared(
|
||||||
|
path: Path,
|
||||||
|
payload: bytes,
|
||||||
|
*,
|
||||||
|
mode: int = 0o640,
|
||||||
|
group_writable_directory: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Atomic write into a setgid/group-readable shared queue directory."""
|
||||||
|
path = Path(path)
|
||||||
|
ensure_shared_directory(path.parent, group_writable=group_writable_directory)
|
||||||
|
parent_before = os.lstat(path.parent)
|
||||||
|
parent_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | _NOFOLLOW)
|
||||||
|
temp_name = f".{path.name}.tmp-{os.getpid()}-{os.urandom(6).hex()}"
|
||||||
|
try:
|
||||||
|
parent_after = os.fstat(parent_fd)
|
||||||
|
if (parent_before.st_dev, parent_before.st_ino) != (
|
||||||
|
parent_after.st_dev,
|
||||||
|
parent_after.st_ino,
|
||||||
|
):
|
||||||
|
raise ConfigurationError(f"directory_changed:{path.parent}")
|
||||||
|
fd = os.open(
|
||||||
|
temp_name,
|
||||||
|
os.O_WRONLY | os.O_CREAT | os.O_EXCL | _NOFOLLOW,
|
||||||
|
mode,
|
||||||
|
dir_fd=parent_fd,
|
||||||
|
)
|
||||||
|
os.fchmod(fd, mode)
|
||||||
|
try:
|
||||||
|
view = memoryview(payload)
|
||||||
|
while view:
|
||||||
|
written = os.write(fd, view)
|
||||||
|
view = view[written:]
|
||||||
|
os.fsync(fd)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
os.replace(temp_name, path.name, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
|
||||||
|
os.fsync(parent_fd)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(temp_name, dir_fd=parent_fd)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
os.close(parent_fd)
|
||||||
@@ -0,0 +1,642 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from .clients import ResponsesClient, WorkspaceAgentClient
|
||||||
|
from .config import Config
|
||||||
|
from .dispatch import DispatchSink
|
||||||
|
from .errors import (
|
||||||
|
AuthenticationError,
|
||||||
|
ConflictError,
|
||||||
|
DispatchError,
|
||||||
|
OrchestratorError,
|
||||||
|
TriggerError,
|
||||||
|
ValidationError,
|
||||||
|
)
|
||||||
|
from .models import Decision, Event
|
||||||
|
from .securefs import ensure_secure_directory, ensure_shared_directory, read_secure_json
|
||||||
|
from .store import Store
|
||||||
|
from .sync import OpenClawSynchronizer, lifecycle_record
|
||||||
|
|
||||||
|
|
||||||
|
LOG = logging.getLogger("aios-orchestrator-gateway")
|
||||||
|
|
||||||
|
|
||||||
|
class Runtime:
|
||||||
|
def __init__(self, config: Config):
|
||||||
|
self.config = config
|
||||||
|
self.store = Store(config.database_path)
|
||||||
|
self.dispatch = DispatchSink(
|
||||||
|
config.dispatch_outbox_dir,
|
||||||
|
config.command_queue_dir,
|
||||||
|
config.contract_commit,
|
||||||
|
)
|
||||||
|
self.workspace = None
|
||||||
|
workspace_token = config.workspace_token()
|
||||||
|
if config.workspace_trigger_id and workspace_token:
|
||||||
|
self.workspace = WorkspaceAgentClient(
|
||||||
|
config.workspace_trigger_id,
|
||||||
|
workspace_token,
|
||||||
|
)
|
||||||
|
self.responses = None
|
||||||
|
responses_key = config.responses_api_key()
|
||||||
|
if responses_key and config.responses_model:
|
||||||
|
self.responses = ResponsesClient(
|
||||||
|
responses_key,
|
||||||
|
config.responses_model,
|
||||||
|
)
|
||||||
|
self.synchronizer = OpenClawSynchronizer(
|
||||||
|
config.openclaw_sync_dir,
|
||||||
|
endpoint=config.openclaw_sync_endpoint,
|
||||||
|
bearer_token=config.openclaw_sync_token(),
|
||||||
|
)
|
||||||
|
self.worker_id = f"{socket.gethostname()}-{os.getpid()}-{threading.get_native_id()}"
|
||||||
|
self.stop_event = threading.Event()
|
||||||
|
self.worker = threading.Thread(target=self._loop, daemon=True)
|
||||||
|
self._next_git_poll = 0.0
|
||||||
|
for directory in (
|
||||||
|
config.event_inbox_dir,
|
||||||
|
config.decision_inbox_dir,
|
||||||
|
config.dispatch_outbox_dir,
|
||||||
|
config.openclaw_sync_dir,
|
||||||
|
):
|
||||||
|
ensure_secure_directory(directory)
|
||||||
|
ensure_shared_directory(config.command_queue_dir)
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
self.worker.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self.stop_event.set()
|
||||||
|
if self.worker.is_alive():
|
||||||
|
self.worker.join(timeout=15)
|
||||||
|
self.store.close()
|
||||||
|
|
||||||
|
def submit_event(self, payload: dict[str, Any]) -> tuple[bool, dict[str, Any]]:
|
||||||
|
event = Event.from_dict(payload)
|
||||||
|
if event.contract_commit != self.config.contract_commit:
|
||||||
|
raise ConflictError("contract_version_mismatch")
|
||||||
|
created, stored = self.store.register_event(event)
|
||||||
|
if created:
|
||||||
|
self._enqueue_event_lifecycle(event)
|
||||||
|
return created, stored
|
||||||
|
|
||||||
|
def submit_decision(self, payload: dict[str, Any]) -> int:
|
||||||
|
decision = Decision.from_dict(payload)
|
||||||
|
if decision.contract_commit != self.config.contract_commit:
|
||||||
|
raise ConflictError("contract_version_mismatch")
|
||||||
|
version = self.store.apply_decision(
|
||||||
|
decision,
|
||||||
|
max_hops_per_gate=self.config.max_hops_per_gate,
|
||||||
|
max_same_blocker_retries=self.config.max_same_blocker_retries,
|
||||||
|
)
|
||||||
|
self._enqueue_decision_lifecycle(decision, version)
|
||||||
|
return version
|
||||||
|
|
||||||
|
def _loop(self) -> None:
|
||||||
|
next_summary = 0.0
|
||||||
|
while not self.stop_event.wait(self.config.poll_interval_seconds):
|
||||||
|
try:
|
||||||
|
self.poll_once()
|
||||||
|
if time.monotonic() >= next_summary:
|
||||||
|
self.enqueue_periodic_summaries()
|
||||||
|
next_summary = time.monotonic() + self.config.summary_interval_seconds
|
||||||
|
except Exception:
|
||||||
|
LOG.exception("poll_cycle_failed")
|
||||||
|
|
||||||
|
def poll_once(self) -> None:
|
||||||
|
"""One deterministic reliability cycle.
|
||||||
|
|
||||||
|
This is deliberately callable from a systemd timer or health supervisor
|
||||||
|
in addition to the internal loop.
|
||||||
|
"""
|
||||||
|
self.ingest_file_inboxes()
|
||||||
|
self.poll_git_refs()
|
||||||
|
self.process_new_events()
|
||||||
|
self.process_workspace_timeouts()
|
||||||
|
self.poll_background_responses()
|
||||||
|
self.dispatch_work_units()
|
||||||
|
self.synchronize_openclaw()
|
||||||
|
|
||||||
|
def ingest_file_inboxes(self) -> None:
|
||||||
|
self._ingest_directory(self.config.event_inbox_dir, kind="event")
|
||||||
|
self._ingest_directory(self.config.decision_inbox_dir, kind="decision")
|
||||||
|
|
||||||
|
def _ingest_directory(self, directory: Path, *, kind: str) -> None:
|
||||||
|
pending = directory / "pending"
|
||||||
|
processed = directory / "processed"
|
||||||
|
rejected = directory / "rejected"
|
||||||
|
ensure_shared_directory(pending, group_writable=True)
|
||||||
|
ensure_secure_directory(processed)
|
||||||
|
ensure_secure_directory(rejected)
|
||||||
|
for path in sorted(pending.glob("*.json"))[:128]:
|
||||||
|
try:
|
||||||
|
payload = read_secure_json(path, expected_uid=None)
|
||||||
|
if kind == "event":
|
||||||
|
self.submit_event(payload)
|
||||||
|
else:
|
||||||
|
self.submit_decision(payload)
|
||||||
|
target = processed / path.name
|
||||||
|
except Exception as exc:
|
||||||
|
LOG.warning("file_inbox_rejected path=%s error=%s", path, type(exc).__name__)
|
||||||
|
target = rejected / path.name
|
||||||
|
if target.exists():
|
||||||
|
# Same file name is an idempotency replay. Keep original audit copy.
|
||||||
|
path.unlink()
|
||||||
|
else:
|
||||||
|
path.replace(target)
|
||||||
|
|
||||||
|
def poll_git_refs(self) -> None:
|
||||||
|
"""Poll authoritative local bare refs so missed webhooks cannot stall the loop."""
|
||||||
|
if not self.config.git_poll_enabled or self.config.git_poll_bare_repo is None:
|
||||||
|
return
|
||||||
|
now = time.monotonic()
|
||||||
|
if now < self._next_git_poll:
|
||||||
|
return
|
||||||
|
self._next_git_poll = now + self.config.git_poll_interval_seconds
|
||||||
|
command = [
|
||||||
|
"git",
|
||||||
|
f"--git-dir={self.config.git_poll_bare_repo}",
|
||||||
|
"for-each-ref",
|
||||||
|
"--format=%(refname) %(objectname)",
|
||||||
|
*self.config.git_poll_ref_prefixes,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
timeout=30,
|
||||||
|
env={"PATH": "/usr/bin:/bin", "LANG": "C", "LC_ALL": "C"},
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
LOG.exception("git_ref_poll_failed")
|
||||||
|
return
|
||||||
|
if result.returncode != 0:
|
||||||
|
LOG.warning("git_ref_poll_rc=%s", result.returncode)
|
||||||
|
return
|
||||||
|
refs: dict[str, str] = {}
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
refname, sep, oid = line.partition(" ")
|
||||||
|
if not sep or not refname.startswith("refs/") or len(oid) != 40:
|
||||||
|
continue
|
||||||
|
if any(ch not in "0123456789abcdef" for ch in oid):
|
||||||
|
continue
|
||||||
|
refs[refname] = oid
|
||||||
|
initialized, changes = self.store.observe_refs(refs)
|
||||||
|
if not initialized or not changes:
|
||||||
|
return
|
||||||
|
payload_changes = [
|
||||||
|
{"ref": refname, "old_oid": old_oid, "new_oid": new_oid}
|
||||||
|
for refname, old_oid, new_oid in sorted(changes)
|
||||||
|
]
|
||||||
|
canonical = json.dumps(payload_changes, separators=(",", ":"), sort_keys=True)
|
||||||
|
event_id = "gitpoll-" + hashlib.sha256(canonical.encode()).hexdigest()[:40]
|
||||||
|
state = self.store.project_state(self.config.project_key)
|
||||||
|
state_version = 0 if state is None else int(state["state_version"])
|
||||||
|
event = Event.from_dict(
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"project_key": self.config.project_key,
|
||||||
|
"contract_commit": self.config.contract_commit,
|
||||||
|
"event_id": event_id,
|
||||||
|
"source_agent": "gitea",
|
||||||
|
"event_type": "git_refs_changed",
|
||||||
|
"work_unit_id": "GIT-REF-POLL",
|
||||||
|
"status": "READY",
|
||||||
|
"state_version": state_version,
|
||||||
|
"implementation_commit": None,
|
||||||
|
"result_commit": None,
|
||||||
|
"blocker": None,
|
||||||
|
"evidence_refs": [],
|
||||||
|
"payload": {"changes": payload_changes},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self.submit_event(event.to_dict())
|
||||||
|
except ConflictError:
|
||||||
|
LOG.info("git_ref_event_already_registered event_id=%s", event_id)
|
||||||
|
|
||||||
|
def process_new_events(self) -> None:
|
||||||
|
rows = self.store.claim_events(
|
||||||
|
owner=self.worker_id,
|
||||||
|
statuses=("RECEIVED", "TRIGGER_FAILED"),
|
||||||
|
limit=32,
|
||||||
|
lease_seconds=self.config.lease_seconds,
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
event = Event.from_dict(json.loads(row["event_json"]))
|
||||||
|
attempts = int(row["attempts"])
|
||||||
|
try:
|
||||||
|
if self.workspace is not None:
|
||||||
|
self.workspace.trigger(event)
|
||||||
|
self.store.update_event_trigger(
|
||||||
|
event.event_id,
|
||||||
|
status="AWAITING_DECISION",
|
||||||
|
channel="workspace_agent",
|
||||||
|
remote_id=None,
|
||||||
|
)
|
||||||
|
elif self.responses is not None:
|
||||||
|
response_id = self.responses.start(event)
|
||||||
|
self.store.update_event_trigger(
|
||||||
|
event.event_id,
|
||||||
|
status="POLLING_RESPONSE",
|
||||||
|
channel="responses_api",
|
||||||
|
remote_id=response_id,
|
||||||
|
retry_after=self.config.poll_interval_seconds,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise TriggerError("no_model_channel")
|
||||||
|
except TriggerError as exc:
|
||||||
|
self._retry_or_dead_letter(event, attempts, str(exc))
|
||||||
|
|
||||||
|
def process_workspace_timeouts(self) -> None:
|
||||||
|
cutoff = int(time.time()) - self.config.callback_timeout_seconds
|
||||||
|
for row in self.store.claim_workspace_timeouts(
|
||||||
|
owner=self.worker_id,
|
||||||
|
older_than=cutoff,
|
||||||
|
limit=32,
|
||||||
|
lease_seconds=self.config.lease_seconds,
|
||||||
|
):
|
||||||
|
event = Event.from_dict(json.loads(row["event_json"]))
|
||||||
|
if self.responses is None:
|
||||||
|
self._retry_or_dead_letter(
|
||||||
|
event,
|
||||||
|
int(row["attempts"]),
|
||||||
|
"workspace_callback_timeout",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
response_id = self.responses.start(event)
|
||||||
|
self.store.update_event_trigger(
|
||||||
|
event.event_id,
|
||||||
|
status="POLLING_RESPONSE",
|
||||||
|
channel="responses_api",
|
||||||
|
remote_id=response_id,
|
||||||
|
retry_after=self.config.poll_interval_seconds,
|
||||||
|
increment_attempt=True,
|
||||||
|
)
|
||||||
|
except TriggerError as exc:
|
||||||
|
self._retry_or_dead_letter(event, int(row["attempts"]), str(exc))
|
||||||
|
|
||||||
|
def poll_background_responses(self) -> None:
|
||||||
|
if self.responses is None:
|
||||||
|
return
|
||||||
|
for row in self.store.claim_response_polls(
|
||||||
|
owner=self.worker_id,
|
||||||
|
limit=32,
|
||||||
|
lease_seconds=self.config.lease_seconds,
|
||||||
|
):
|
||||||
|
event = Event.from_dict(json.loads(row["event_json"]))
|
||||||
|
response_id = row.get("remote_id")
|
||||||
|
if not isinstance(response_id, str):
|
||||||
|
self._retry_or_dead_letter(event, int(row["attempts"]), "missing_response_id")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
status, decision = self.responses.poll(response_id)
|
||||||
|
if status in {"queued", "in_progress"}:
|
||||||
|
attempts = int(row["attempts"])
|
||||||
|
if attempts + 1 > self.config.max_retries:
|
||||||
|
self._retry_or_dead_letter(
|
||||||
|
event, attempts, f"response_{status}_retry_limit"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
self.store.update_event_trigger(
|
||||||
|
event.event_id,
|
||||||
|
status="POLLING_RESPONSE",
|
||||||
|
channel="responses_api",
|
||||||
|
remote_id=response_id,
|
||||||
|
retry_after=self.config.poll_interval_seconds,
|
||||||
|
increment_attempt=True,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if decision is None:
|
||||||
|
raise TriggerError("completed_without_decision")
|
||||||
|
self.submit_decision(decision.to_dict())
|
||||||
|
except OrchestratorError as exc:
|
||||||
|
self._retry_or_dead_letter(event, int(row["attempts"]), str(exc))
|
||||||
|
|
||||||
|
def dispatch_work_units(self) -> None:
|
||||||
|
rows = self.store.claim_outbox(
|
||||||
|
owner=self.worker_id,
|
||||||
|
limit=64,
|
||||||
|
lease_seconds=self.config.lease_seconds,
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
outbox_path, queue_path = self.dispatch.dispatch(row)
|
||||||
|
self.store.mark_outbox(row["work_unit_id"], status="DISPATCHED")
|
||||||
|
payload = {
|
||||||
|
"assigned_agent": row["assigned_agent"],
|
||||||
|
"event_id": row["event_id"],
|
||||||
|
"dispatch_outbox": str(outbox_path),
|
||||||
|
"command_queue": str(queue_path),
|
||||||
|
}
|
||||||
|
record = lifecycle_record(
|
||||||
|
contract_commit=self.config.contract_commit,
|
||||||
|
project_key=self._project_for_event(row["event_id"]),
|
||||||
|
kind="TASK_DISPATCHED",
|
||||||
|
work_unit_id=row["work_unit_id"],
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
self.store.enqueue_lifecycle(
|
||||||
|
sync_id=record["sync_id"],
|
||||||
|
project_key=record["project_key"],
|
||||||
|
work_unit_id=row["work_unit_id"],
|
||||||
|
kind=record["kind"],
|
||||||
|
payload=record,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
attempts = int(row["attempts"])
|
||||||
|
status = "DEAD_LETTER" if attempts >= self.config.max_retries else "RETRY"
|
||||||
|
self.store.mark_outbox(
|
||||||
|
row["work_unit_id"],
|
||||||
|
status=status,
|
||||||
|
retry_after=self._backoff(attempts),
|
||||||
|
increment_attempt=True,
|
||||||
|
)
|
||||||
|
LOG.exception("dispatch_failed work_unit=%s", row["work_unit_id"])
|
||||||
|
|
||||||
|
def synchronize_openclaw(self) -> None:
|
||||||
|
rows = self.store.claim_lifecycle(
|
||||||
|
owner=self.worker_id,
|
||||||
|
limit=64,
|
||||||
|
lease_seconds=self.config.lease_seconds,
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
payload = json.loads(row["payload_json"])
|
||||||
|
self.synchronizer.emit(payload)
|
||||||
|
self.store.mark_lifecycle(row["sync_id"], status="SENT")
|
||||||
|
except Exception:
|
||||||
|
attempts = int(row["attempts"])
|
||||||
|
status = "DEAD_LETTER" if attempts >= self.config.max_retries else "RETRY"
|
||||||
|
self.store.mark_lifecycle(
|
||||||
|
row["sync_id"],
|
||||||
|
status=status,
|
||||||
|
retry_after=self._backoff(attempts),
|
||||||
|
increment_attempt=True,
|
||||||
|
)
|
||||||
|
LOG.exception("openclaw_sync_failed sync_id=%s", row["sync_id"])
|
||||||
|
|
||||||
|
def enqueue_periodic_summaries(self) -> None:
|
||||||
|
for state in self.store.all_project_states():
|
||||||
|
project_key = state["project_key"]
|
||||||
|
if not self.store.summary_due(project_key, self.config.summary_interval_seconds):
|
||||||
|
continue
|
||||||
|
summary = self.store.project_summary(project_key)
|
||||||
|
if summary is None:
|
||||||
|
continue
|
||||||
|
digest = hashlib.sha256(
|
||||||
|
json.dumps(summary, separators=(",", ":"), sort_keys=True).encode()
|
||||||
|
).hexdigest()
|
||||||
|
record = lifecycle_record(
|
||||||
|
contract_commit=self.config.contract_commit,
|
||||||
|
project_key=project_key,
|
||||||
|
kind="PROJECT_SUMMARY",
|
||||||
|
work_unit_id=None,
|
||||||
|
payload=summary,
|
||||||
|
)
|
||||||
|
self.store.enqueue_lifecycle(
|
||||||
|
sync_id=record["sync_id"],
|
||||||
|
project_key=project_key,
|
||||||
|
work_unit_id=None,
|
||||||
|
kind=record["kind"],
|
||||||
|
payload=record,
|
||||||
|
)
|
||||||
|
self.store.mark_summary(project_key, digest)
|
||||||
|
|
||||||
|
def _enqueue_event_lifecycle(self, event: Event) -> None:
|
||||||
|
if event.event_type == "work_unit_started":
|
||||||
|
kind = "TASK_STARTED"
|
||||||
|
elif event.event_type in {"work_unit_completed", "work_unit_failed", "work_unit_blocked"}:
|
||||||
|
kind = "TASK_ENDED"
|
||||||
|
else:
|
||||||
|
kind = "EVENT_RECEIVED"
|
||||||
|
record = lifecycle_record(
|
||||||
|
contract_commit=self.config.contract_commit,
|
||||||
|
project_key=event.project_key,
|
||||||
|
kind=kind,
|
||||||
|
work_unit_id=event.work_unit_id,
|
||||||
|
payload=event.to_dict(),
|
||||||
|
)
|
||||||
|
self.store.enqueue_lifecycle(
|
||||||
|
sync_id=record["sync_id"],
|
||||||
|
project_key=event.project_key,
|
||||||
|
work_unit_id=event.work_unit_id,
|
||||||
|
kind=kind,
|
||||||
|
payload=record,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _enqueue_decision_lifecycle(self, decision: Decision, version: int) -> None:
|
||||||
|
kind = "PROJECT_COMPLETED" if decision.verdict == "COMPLETE" else "DECISION_APPLIED"
|
||||||
|
record = lifecycle_record(
|
||||||
|
contract_commit=self.config.contract_commit,
|
||||||
|
project_key=decision.project_key,
|
||||||
|
kind=kind,
|
||||||
|
work_unit_id=None,
|
||||||
|
payload={"decision": decision.to_dict(), "state_version": version},
|
||||||
|
)
|
||||||
|
self.store.enqueue_lifecycle(
|
||||||
|
sync_id=record["sync_id"],
|
||||||
|
project_key=decision.project_key,
|
||||||
|
work_unit_id=None,
|
||||||
|
kind=kind,
|
||||||
|
payload=record,
|
||||||
|
)
|
||||||
|
summary = self.store.project_summary(decision.project_key)
|
||||||
|
if summary:
|
||||||
|
summary_record = lifecycle_record(
|
||||||
|
contract_commit=self.config.contract_commit,
|
||||||
|
project_key=decision.project_key,
|
||||||
|
kind="PROJECT_SUMMARY",
|
||||||
|
work_unit_id=None,
|
||||||
|
payload=summary,
|
||||||
|
)
|
||||||
|
self.store.enqueue_lifecycle(
|
||||||
|
sync_id=summary_record["sync_id"],
|
||||||
|
project_key=decision.project_key,
|
||||||
|
work_unit_id=None,
|
||||||
|
kind=summary_record["kind"],
|
||||||
|
payload=summary_record,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _project_for_event(self, event_id: str) -> str:
|
||||||
|
rows = self.store._conn().execute(
|
||||||
|
"SELECT project_key FROM events WHERE event_id=?",
|
||||||
|
(event_id,),
|
||||||
|
).fetchone()
|
||||||
|
if rows is None:
|
||||||
|
raise ConflictError("event_not_found")
|
||||||
|
return str(rows["project_key"])
|
||||||
|
|
||||||
|
def _retry_or_dead_letter(self, event: Event, attempts: int, reason: str) -> None:
|
||||||
|
next_attempt = attempts + 1
|
||||||
|
if next_attempt > self.config.max_retries:
|
||||||
|
self.store.update_event_trigger(
|
||||||
|
event.event_id,
|
||||||
|
status="DEAD_LETTER",
|
||||||
|
channel=None,
|
||||||
|
remote_id=None,
|
||||||
|
increment_attempt=True,
|
||||||
|
)
|
||||||
|
record = lifecycle_record(
|
||||||
|
contract_commit=self.config.contract_commit,
|
||||||
|
project_key=event.project_key,
|
||||||
|
kind="TASK_ENDED",
|
||||||
|
work_unit_id=event.work_unit_id,
|
||||||
|
payload={
|
||||||
|
"status": "FAIL",
|
||||||
|
"reason": reason,
|
||||||
|
"event_id": event.event_id,
|
||||||
|
"attempts": next_attempt,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.store.enqueue_lifecycle(
|
||||||
|
sync_id=record["sync_id"],
|
||||||
|
project_key=event.project_key,
|
||||||
|
work_unit_id=event.work_unit_id,
|
||||||
|
kind=record["kind"],
|
||||||
|
payload=record,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.store.update_event_trigger(
|
||||||
|
event.event_id,
|
||||||
|
status="TRIGGER_FAILED",
|
||||||
|
channel=None,
|
||||||
|
remote_id=None,
|
||||||
|
retry_after=self._backoff(attempts),
|
||||||
|
increment_attempt=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _backoff(self, attempts: int) -> int:
|
||||||
|
return min(3600, self.config.retry_base_seconds * (2 ** min(attempts, 10)))
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
runtime: Runtime
|
||||||
|
|
||||||
|
def log_message(self, fmt: str, *args: object) -> None:
|
||||||
|
LOG.info("%s %s", self.client_address[0], fmt % args)
|
||||||
|
|
||||||
|
def _reply(self, status: int, payload: dict[str, Any]) -> None:
|
||||||
|
body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _body(self) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValidationError("invalid_content_length") from exc
|
||||||
|
if length <= 0 or length > 1_048_576:
|
||||||
|
raise ValidationError("invalid_body_size")
|
||||||
|
try:
|
||||||
|
value = json.loads(self.rfile.read(length))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValidationError("invalid_json") from exc
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValidationError("json_root_not_object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _authenticate(self, expected: str) -> None:
|
||||||
|
header = self.headers.get("Authorization", "")
|
||||||
|
if not header.startswith("Bearer "):
|
||||||
|
raise AuthenticationError("missing_bearer")
|
||||||
|
if not hmac.compare_digest(header[7:], expected):
|
||||||
|
raise AuthenticationError("invalid_bearer")
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
path = urlparse(self.path).path.rstrip("/") or "/"
|
||||||
|
try:
|
||||||
|
if path == "/healthz":
|
||||||
|
self._reply(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"service": "aios-orchestrator-gateway",
|
||||||
|
"status": "active",
|
||||||
|
"contract_commit": self.runtime.config.contract_commit,
|
||||||
|
"polling": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if path.startswith("/v1/orchestrator/state/"):
|
||||||
|
self._authenticate(self.runtime.config.inbound_token())
|
||||||
|
key = path.rsplit("/", 1)[-1]
|
||||||
|
state = self.runtime.store.project_summary(key)
|
||||||
|
self._reply(200 if state else 404, state or {"error": "project_not_found"})
|
||||||
|
return
|
||||||
|
self._reply(404, {"error": "not_found"})
|
||||||
|
except AuthenticationError as exc:
|
||||||
|
self._reply(401, {"error": str(exc)})
|
||||||
|
except OrchestratorError as exc:
|
||||||
|
self._reply(400, {"error": str(exc)})
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
path = urlparse(self.path).path.rstrip("/")
|
||||||
|
try:
|
||||||
|
if path == "/v1/orchestrator/events":
|
||||||
|
self._authenticate(self.runtime.config.inbound_token())
|
||||||
|
created, event = self.runtime.submit_event(self._body())
|
||||||
|
self._reply(202 if created else 200, {"created": created, "event": event})
|
||||||
|
return
|
||||||
|
if path == "/v1/orchestrator/decisions":
|
||||||
|
self._authenticate(self.runtime.config.callback_token())
|
||||||
|
version = self.runtime.submit_decision(self._body())
|
||||||
|
self._reply(202, {"accepted": True, "state_version": version})
|
||||||
|
return
|
||||||
|
if path == "/v1/orchestrator/poll":
|
||||||
|
self._authenticate(self.runtime.config.inbound_token())
|
||||||
|
self.runtime.poll_once()
|
||||||
|
self._reply(200, {"polled": True})
|
||||||
|
return
|
||||||
|
self._reply(404, {"error": "not_found"})
|
||||||
|
except AuthenticationError as exc:
|
||||||
|
self._reply(401, {"error": str(exc)})
|
||||||
|
except ConflictError as exc:
|
||||||
|
self._reply(409, {"error": str(exc)})
|
||||||
|
except ValidationError as exc:
|
||||||
|
self._reply(422, {"error": str(exc)})
|
||||||
|
except OrchestratorError as exc:
|
||||||
|
self._reply(400, {"error": str(exc)})
|
||||||
|
except Exception:
|
||||||
|
LOG.exception("request_failed path=%s", path)
|
||||||
|
self._reply(500, {"error": "internal_error"})
|
||||||
|
|
||||||
|
|
||||||
|
def serve(config_path: Path) -> None:
|
||||||
|
config = Config.from_file(config_path)
|
||||||
|
runtime = Runtime(config)
|
||||||
|
Handler.runtime = runtime
|
||||||
|
server = ThreadingHTTPServer((config.host, config.port), Handler)
|
||||||
|
runtime.start()
|
||||||
|
LOG.info(
|
||||||
|
"gateway_started host=%s port=%s contract=%s polling=%ss",
|
||||||
|
config.host,
|
||||||
|
config.port,
|
||||||
|
config.contract_commit,
|
||||||
|
config.poll_interval_seconds,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
server.serve_forever(poll_interval=0.5)
|
||||||
|
finally:
|
||||||
|
server.server_close()
|
||||||
|
runtime.stop()
|
||||||
@@ -0,0 +1,826 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .errors import ConflictError
|
||||||
|
from .models import Decision, Event
|
||||||
|
from .securefs import ensure_secure_directory
|
||||||
|
|
||||||
|
|
||||||
|
class Store:
|
||||||
|
"""Durable state machine with idempotency, optimistic versioning, and leases."""
|
||||||
|
|
||||||
|
def __init__(self, path: Path):
|
||||||
|
self.path = Path(path)
|
||||||
|
ensure_secure_directory(self.path.parent)
|
||||||
|
self._local = threading.local()
|
||||||
|
self._initialize()
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(self.path, timeout=30, isolation_level=None)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA synchronous=FULL")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
conn.execute("PRAGMA busy_timeout=30000")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def _conn(self) -> sqlite3.Connection:
|
||||||
|
conn = getattr(self._local, "conn", None)
|
||||||
|
if conn is None:
|
||||||
|
conn = self._connect()
|
||||||
|
self._local.conn = conn
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def _initialize(self) -> None:
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
conn.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS project_state (
|
||||||
|
project_key TEXT PRIMARY KEY,
|
||||||
|
contract_commit TEXT NOT NULL,
|
||||||
|
state_version INTEGER NOT NULL,
|
||||||
|
state_json TEXT NOT NULL,
|
||||||
|
hops INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS gate_hops (
|
||||||
|
project_key TEXT NOT NULL,
|
||||||
|
gate_id TEXT NOT NULL,
|
||||||
|
hops INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(project_key, gate_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
event_id TEXT PRIMARY KEY,
|
||||||
|
project_key TEXT NOT NULL,
|
||||||
|
digest TEXT NOT NULL,
|
||||||
|
event_json TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
channel TEXT,
|
||||||
|
remote_id TEXT,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
next_attempt_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
lease_owner TEXT,
|
||||||
|
lease_until INTEGER,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_poll
|
||||||
|
ON events(status, next_attempt_at, lease_until, created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decisions (
|
||||||
|
event_id TEXT PRIMARY KEY REFERENCES events(event_id),
|
||||||
|
project_key TEXT NOT NULL,
|
||||||
|
decision_json TEXT NOT NULL,
|
||||||
|
state_version_after INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS outbox (
|
||||||
|
work_unit_id TEXT PRIMARY KEY,
|
||||||
|
event_id TEXT NOT NULL REFERENCES events(event_id),
|
||||||
|
assigned_agent TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
next_attempt_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
lease_owner TEXT,
|
||||||
|
lease_until INTEGER,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_outbox_poll
|
||||||
|
ON outbox(status, next_attempt_at, lease_until, created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lifecycle (
|
||||||
|
sync_id TEXT PRIMARY KEY,
|
||||||
|
project_key TEXT NOT NULL,
|
||||||
|
work_unit_id TEXT,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
next_attempt_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
lease_owner TEXT,
|
||||||
|
lease_until INTEGER,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lifecycle_poll
|
||||||
|
ON lifecycle(status, next_attempt_at, lease_until, created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS blocker_history (
|
||||||
|
project_key TEXT NOT NULL,
|
||||||
|
gate_id TEXT NOT NULL,
|
||||||
|
blocker TEXT NOT NULL,
|
||||||
|
repetitions INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(project_key, gate_id, blocker)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS summaries (
|
||||||
|
project_key TEXT PRIMARY KEY,
|
||||||
|
last_emitted_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_digest TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS observed_refs (
|
||||||
|
refname TEXT PRIMARY KEY,
|
||||||
|
object_id TEXT NOT NULL,
|
||||||
|
observed_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS metadata (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def register_event(self, event: Event) -> tuple[bool, dict[str, Any]]:
|
||||||
|
now = int(time.time())
|
||||||
|
conn = self._conn()
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT digest, event_json FROM events WHERE event_id=?",
|
||||||
|
(event.event_id,),
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
if existing["digest"] != event.digest():
|
||||||
|
raise ConflictError("event_id_payload_conflict")
|
||||||
|
conn.execute("COMMIT")
|
||||||
|
return False, json.loads(existing["event_json"])
|
||||||
|
|
||||||
|
state = conn.execute(
|
||||||
|
"SELECT contract_commit, state_version FROM project_state WHERE project_key=?",
|
||||||
|
(event.project_key,),
|
||||||
|
).fetchone()
|
||||||
|
if state is None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO project_state(
|
||||||
|
project_key, contract_commit, state_version, state_json,
|
||||||
|
hops, updated_at
|
||||||
|
) VALUES (?, ?, ?, '{}', 0, ?)
|
||||||
|
""",
|
||||||
|
(event.project_key, event.contract_commit, event.state_version, now),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if state["contract_commit"] != event.contract_commit:
|
||||||
|
raise ConflictError("contract_version_mismatch")
|
||||||
|
if state["state_version"] != event.state_version:
|
||||||
|
raise ConflictError("stale_state_version")
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO events(
|
||||||
|
event_id, project_key, digest, event_json, status,
|
||||||
|
next_attempt_at, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, 'RECEIVED', ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
event.event_id,
|
||||||
|
event.project_key,
|
||||||
|
event.digest(),
|
||||||
|
event.canonical_json(),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute("COMMIT")
|
||||||
|
return True, event.to_dict()
|
||||||
|
except Exception:
|
||||||
|
conn.execute("ROLLBACK")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def claim_events(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
owner: str,
|
||||||
|
statuses: tuple[str, ...],
|
||||||
|
limit: int,
|
||||||
|
lease_seconds: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if not statuses:
|
||||||
|
return []
|
||||||
|
now = int(time.time())
|
||||||
|
placeholders = ",".join("?" for _ in statuses)
|
||||||
|
conn = self._conn()
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT event_id FROM events
|
||||||
|
WHERE status IN ({placeholders})
|
||||||
|
AND next_attempt_at <= ?
|
||||||
|
AND (lease_until IS NULL OR lease_until < ?)
|
||||||
|
ORDER BY created_at
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(*statuses, now, now, limit),
|
||||||
|
).fetchall()
|
||||||
|
ids = [row["event_id"] for row in rows]
|
||||||
|
for event_id in ids:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE events SET lease_owner=?, lease_until=?, updated_at=?
|
||||||
|
WHERE event_id=?
|
||||||
|
""",
|
||||||
|
(owner, now + lease_seconds, now, event_id),
|
||||||
|
)
|
||||||
|
conn.execute("COMMIT")
|
||||||
|
except Exception:
|
||||||
|
conn.execute("ROLLBACK")
|
||||||
|
raise
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
placeholders = ",".join("?" for _ in ids)
|
||||||
|
return [
|
||||||
|
dict(row)
|
||||||
|
for row in conn.execute(
|
||||||
|
f"SELECT * FROM events WHERE event_id IN ({placeholders})",
|
||||||
|
ids,
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
def update_event_trigger(
|
||||||
|
self,
|
||||||
|
event_id: str,
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
channel: str | None,
|
||||||
|
remote_id: str | None,
|
||||||
|
retry_after: int = 0,
|
||||||
|
increment_attempt: bool = False,
|
||||||
|
) -> None:
|
||||||
|
now = int(time.time())
|
||||||
|
self._conn().execute(
|
||||||
|
"""
|
||||||
|
UPDATE events
|
||||||
|
SET status=?, channel=?, remote_id=?,
|
||||||
|
attempts=attempts + ?,
|
||||||
|
next_attempt_at=?,
|
||||||
|
lease_owner=NULL, lease_until=NULL, updated_at=?
|
||||||
|
WHERE event_id=?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
channel,
|
||||||
|
remote_id,
|
||||||
|
1 if increment_attempt else 0,
|
||||||
|
now + max(0, retry_after),
|
||||||
|
now,
|
||||||
|
event_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def claim_workspace_timeouts(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
owner: str,
|
||||||
|
older_than: int,
|
||||||
|
limit: int,
|
||||||
|
lease_seconds: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return self._claim_event_subset(
|
||||||
|
owner=owner,
|
||||||
|
where_sql="status='AWAITING_DECISION' AND channel='workspace_agent' AND updated_at <= ?",
|
||||||
|
where_params=(older_than,),
|
||||||
|
limit=limit,
|
||||||
|
lease_seconds=lease_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
def claim_response_polls(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
owner: str,
|
||||||
|
limit: int,
|
||||||
|
lease_seconds: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
now = int(time.time())
|
||||||
|
return self._claim_event_subset(
|
||||||
|
owner=owner,
|
||||||
|
where_sql="status='POLLING_RESPONSE' AND next_attempt_at <= ?",
|
||||||
|
where_params=(now,),
|
||||||
|
limit=limit,
|
||||||
|
lease_seconds=lease_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _claim_event_subset(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
owner: str,
|
||||||
|
where_sql: str,
|
||||||
|
where_params: tuple[Any, ...],
|
||||||
|
limit: int,
|
||||||
|
lease_seconds: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
allowed = {
|
||||||
|
"status='AWAITING_DECISION' AND channel='workspace_agent' AND updated_at <= ?",
|
||||||
|
"status='POLLING_RESPONSE' AND next_attempt_at <= ?",
|
||||||
|
}
|
||||||
|
if where_sql not in allowed:
|
||||||
|
raise ValueError("invalid_event_claim_query")
|
||||||
|
now = int(time.time())
|
||||||
|
conn = self._conn()
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT event_id FROM events
|
||||||
|
WHERE {where_sql}
|
||||||
|
AND (lease_until IS NULL OR lease_until < ?)
|
||||||
|
ORDER BY updated_at
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(*where_params, now, limit),
|
||||||
|
).fetchall()
|
||||||
|
ids = [str(row["event_id"]) for row in rows]
|
||||||
|
for event_id in ids:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE events SET lease_owner=?, lease_until=?
|
||||||
|
WHERE event_id=?
|
||||||
|
""",
|
||||||
|
(owner, now + lease_seconds, event_id),
|
||||||
|
)
|
||||||
|
conn.execute("COMMIT")
|
||||||
|
except Exception:
|
||||||
|
conn.execute("ROLLBACK")
|
||||||
|
raise
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
placeholders = ",".join("?" for _ in ids)
|
||||||
|
return [
|
||||||
|
dict(row)
|
||||||
|
for row in conn.execute(
|
||||||
|
f"SELECT * FROM events WHERE event_id IN ({placeholders})",
|
||||||
|
ids,
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
def apply_decision(
|
||||||
|
self,
|
||||||
|
decision: Decision,
|
||||||
|
*,
|
||||||
|
max_hops_per_gate: int,
|
||||||
|
max_same_blocker_retries: int,
|
||||||
|
) -> int:
|
||||||
|
now = int(time.time())
|
||||||
|
conn = self._conn()
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
event = conn.execute(
|
||||||
|
"SELECT project_key FROM events WHERE event_id=?",
|
||||||
|
(decision.event_id,),
|
||||||
|
).fetchone()
|
||||||
|
if event is None:
|
||||||
|
raise ConflictError("event_not_found")
|
||||||
|
if event["project_key"] != decision.project_key:
|
||||||
|
raise ConflictError("event_project_mismatch")
|
||||||
|
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT decision_json, state_version_after FROM decisions WHERE event_id=?",
|
||||||
|
(decision.event_id,),
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
if json.loads(existing["decision_json"]) != decision.to_dict():
|
||||||
|
raise ConflictError("decision_idempotency_conflict")
|
||||||
|
conn.execute("COMMIT")
|
||||||
|
return int(existing["state_version_after"])
|
||||||
|
|
||||||
|
state = conn.execute(
|
||||||
|
"SELECT * FROM project_state WHERE project_key=?",
|
||||||
|
(decision.project_key,),
|
||||||
|
).fetchone()
|
||||||
|
if state is None:
|
||||||
|
raise ConflictError("project_state_not_found")
|
||||||
|
if state["contract_commit"] != decision.contract_commit:
|
||||||
|
raise ConflictError("contract_version_mismatch")
|
||||||
|
if state["state_version"] != decision.expected_state_version:
|
||||||
|
raise ConflictError("stale_decision_state_version")
|
||||||
|
state_json = json.loads(state["state_json"])
|
||||||
|
gate = str(
|
||||||
|
decision.state_updates.get(
|
||||||
|
"current_gate", state_json.get("current_gate", "unknown")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
gate_hops = conn.execute(
|
||||||
|
"SELECT hops FROM gate_hops WHERE project_key=? AND gate_id=?",
|
||||||
|
(decision.project_key, gate),
|
||||||
|
).fetchone()
|
||||||
|
if gate_hops is not None and int(gate_hops["hops"]) >= max_hops_per_gate:
|
||||||
|
raise ConflictError("max_hops_exceeded")
|
||||||
|
|
||||||
|
state_json.update(decision.state_updates)
|
||||||
|
next_version = int(state["state_version"]) + 1
|
||||||
|
|
||||||
|
if decision.stop_reason:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT repetitions FROM blocker_history
|
||||||
|
WHERE project_key=? AND gate_id=? AND blocker=?
|
||||||
|
""",
|
||||||
|
(decision.project_key, gate, decision.stop_reason),
|
||||||
|
).fetchone()
|
||||||
|
repetitions = (int(row["repetitions"]) if row else 0) + 1
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO blocker_history(
|
||||||
|
project_key, gate_id, blocker, repetitions, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(project_key, gate_id, blocker)
|
||||||
|
DO UPDATE SET repetitions=excluded.repetitions,
|
||||||
|
updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
(decision.project_key, gate, decision.stop_reason, repetitions, now),
|
||||||
|
)
|
||||||
|
if repetitions > max_same_blocker_retries:
|
||||||
|
raise ConflictError("same_blocker_retry_limit_exceeded")
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO gate_hops(project_key, gate_id, hops, updated_at)
|
||||||
|
VALUES (?, ?, 1, ?)
|
||||||
|
ON CONFLICT(project_key, gate_id)
|
||||||
|
DO UPDATE SET hops=gate_hops.hops + 1, updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
(decision.project_key, gate, now),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE project_state
|
||||||
|
SET state_version=?, state_json=?, hops=hops+1, updated_at=?
|
||||||
|
WHERE project_key=?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
next_version,
|
||||||
|
json.dumps(state_json, separators=(",", ":"), sort_keys=True),
|
||||||
|
now,
|
||||||
|
decision.project_key,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO decisions(
|
||||||
|
event_id, project_key, decision_json,
|
||||||
|
state_version_after, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
decision.event_id,
|
||||||
|
decision.project_key,
|
||||||
|
json.dumps(decision.to_dict(), separators=(",", ":"), sort_keys=True),
|
||||||
|
next_version,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for unit in decision.next_work_units:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO outbox(
|
||||||
|
work_unit_id, event_id, assigned_agent, payload_json,
|
||||||
|
status, next_attempt_at, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, 'PENDING', ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
unit.id,
|
||||||
|
decision.event_id,
|
||||||
|
unit.primary_agent,
|
||||||
|
json.dumps(unit.to_dict(), separators=(",", ":"), sort_keys=True),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE events SET status='DECIDED', lease_owner=NULL,
|
||||||
|
lease_until=NULL, updated_at=?
|
||||||
|
WHERE event_id=?
|
||||||
|
""",
|
||||||
|
(now, decision.event_id),
|
||||||
|
)
|
||||||
|
conn.execute("COMMIT")
|
||||||
|
return next_version
|
||||||
|
except Exception:
|
||||||
|
conn.execute("ROLLBACK")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def claim_outbox(self, *, owner: str, limit: int, lease_seconds: int) -> list[dict[str, Any]]:
|
||||||
|
return self._claim_table(
|
||||||
|
table="outbox",
|
||||||
|
key="work_unit_id",
|
||||||
|
owner=owner,
|
||||||
|
statuses=("PENDING", "RETRY"),
|
||||||
|
limit=limit,
|
||||||
|
lease_seconds=lease_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
def mark_outbox(
|
||||||
|
self,
|
||||||
|
work_unit_id: str,
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
retry_after: int = 0,
|
||||||
|
increment_attempt: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self._mark_table(
|
||||||
|
table="outbox",
|
||||||
|
key="work_unit_id",
|
||||||
|
value=work_unit_id,
|
||||||
|
status=status,
|
||||||
|
retry_after=retry_after,
|
||||||
|
increment_attempt=increment_attempt,
|
||||||
|
)
|
||||||
|
|
||||||
|
def enqueue_lifecycle(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
sync_id: str,
|
||||||
|
project_key: str,
|
||||||
|
work_unit_id: str | None,
|
||||||
|
kind: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
now = int(time.time())
|
||||||
|
encoded = json.dumps(payload, separators=(",", ":"), sort_keys=True)
|
||||||
|
self._conn().execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO lifecycle(
|
||||||
|
sync_id, project_key, work_unit_id, kind, payload_json,
|
||||||
|
status, next_attempt_at, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, 'PENDING', ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(sync_id, project_key, work_unit_id, kind, encoded, now, now, now),
|
||||||
|
)
|
||||||
|
|
||||||
|
def claim_lifecycle(self, *, owner: str, limit: int, lease_seconds: int) -> list[dict[str, Any]]:
|
||||||
|
return self._claim_table(
|
||||||
|
table="lifecycle",
|
||||||
|
key="sync_id",
|
||||||
|
owner=owner,
|
||||||
|
statuses=("PENDING", "RETRY"),
|
||||||
|
limit=limit,
|
||||||
|
lease_seconds=lease_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
def mark_lifecycle(
|
||||||
|
self,
|
||||||
|
sync_id: str,
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
retry_after: int = 0,
|
||||||
|
increment_attempt: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self._mark_table(
|
||||||
|
table="lifecycle",
|
||||||
|
key="sync_id",
|
||||||
|
value=sync_id,
|
||||||
|
status=status,
|
||||||
|
retry_after=retry_after,
|
||||||
|
increment_attempt=increment_attempt,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _claim_table(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
table: str,
|
||||||
|
key: str,
|
||||||
|
owner: str,
|
||||||
|
statuses: tuple[str, ...],
|
||||||
|
limit: int,
|
||||||
|
lease_seconds: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if table not in {"outbox", "lifecycle"} or key not in {"work_unit_id", "sync_id"}:
|
||||||
|
raise ValueError("invalid_claim_table")
|
||||||
|
now = int(time.time())
|
||||||
|
placeholders = ",".join("?" for _ in statuses)
|
||||||
|
conn = self._conn()
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT {key} FROM {table}
|
||||||
|
WHERE status IN ({placeholders})
|
||||||
|
AND next_attempt_at <= ?
|
||||||
|
AND (lease_until IS NULL OR lease_until < ?)
|
||||||
|
ORDER BY created_at
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(*statuses, now, now, limit),
|
||||||
|
).fetchall()
|
||||||
|
values = [row[key] for row in rows]
|
||||||
|
for item in values:
|
||||||
|
conn.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE {table} SET lease_owner=?, lease_until=?, updated_at=?
|
||||||
|
WHERE {key}=?
|
||||||
|
""",
|
||||||
|
(owner, now + lease_seconds, now, item),
|
||||||
|
)
|
||||||
|
conn.execute("COMMIT")
|
||||||
|
except Exception:
|
||||||
|
conn.execute("ROLLBACK")
|
||||||
|
raise
|
||||||
|
if not values:
|
||||||
|
return []
|
||||||
|
placeholders = ",".join("?" for _ in values)
|
||||||
|
return [
|
||||||
|
dict(row)
|
||||||
|
for row in conn.execute(
|
||||||
|
f"SELECT * FROM {table} WHERE {key} IN ({placeholders})",
|
||||||
|
values,
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
def _mark_table(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
table: str,
|
||||||
|
key: str,
|
||||||
|
value: str,
|
||||||
|
status: str,
|
||||||
|
retry_after: int,
|
||||||
|
increment_attempt: bool,
|
||||||
|
) -> None:
|
||||||
|
if table not in {"outbox", "lifecycle"} or key not in {"work_unit_id", "sync_id"}:
|
||||||
|
raise ValueError("invalid_mark_table")
|
||||||
|
now = int(time.time())
|
||||||
|
self._conn().execute(
|
||||||
|
f"""
|
||||||
|
UPDATE {table}
|
||||||
|
SET status=?, attempts=attempts + ?, next_attempt_at=?,
|
||||||
|
lease_owner=NULL, lease_until=NULL, updated_at=?
|
||||||
|
WHERE {key}=?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
status,
|
||||||
|
1 if increment_attempt else 0,
|
||||||
|
now + max(0, retry_after),
|
||||||
|
now,
|
||||||
|
value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def project_state(self, project_key: str) -> dict[str, Any] | None:
|
||||||
|
row = self._conn().execute(
|
||||||
|
"SELECT * FROM project_state WHERE project_key=?",
|
||||||
|
(project_key,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
result = dict(row)
|
||||||
|
result["state"] = json.loads(result.pop("state_json"))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def all_project_states(self) -> list[dict[str, Any]]:
|
||||||
|
result = []
|
||||||
|
for row in self._conn().execute("SELECT * FROM project_state ORDER BY project_key").fetchall():
|
||||||
|
item = dict(row)
|
||||||
|
item["state"] = json.loads(item.pop("state_json"))
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def summary_due(self, project_key: str, interval: int) -> bool:
|
||||||
|
now = int(time.time())
|
||||||
|
row = self._conn().execute(
|
||||||
|
"SELECT last_emitted_at FROM summaries WHERE project_key=?",
|
||||||
|
(project_key,),
|
||||||
|
).fetchone()
|
||||||
|
return row is None or now - int(row["last_emitted_at"]) >= interval
|
||||||
|
|
||||||
|
def mark_summary(self, project_key: str, digest: str) -> None:
|
||||||
|
now = int(time.time())
|
||||||
|
self._conn().execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO summaries(project_key, last_emitted_at, last_digest)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(project_key)
|
||||||
|
DO UPDATE SET last_emitted_at=excluded.last_emitted_at,
|
||||||
|
last_digest=excluded.last_digest
|
||||||
|
""",
|
||||||
|
(project_key, now, digest),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def project_summary(self, project_key: str) -> dict[str, Any] | None:
|
||||||
|
state = self.project_state(project_key)
|
||||||
|
if state is None:
|
||||||
|
return None
|
||||||
|
conn = self._conn()
|
||||||
|
event_counts = {
|
||||||
|
row["status"]: int(row["count"])
|
||||||
|
for row in conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT status, COUNT(*) AS count FROM events
|
||||||
|
WHERE project_key=? GROUP BY status
|
||||||
|
""",
|
||||||
|
(project_key,),
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
outbox_counts = {
|
||||||
|
row["status"]: int(row["count"])
|
||||||
|
for row in conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT o.status, COUNT(*) AS count
|
||||||
|
FROM outbox o JOIN events e ON e.event_id=o.event_id
|
||||||
|
WHERE e.project_key=? GROUP BY o.status
|
||||||
|
""",
|
||||||
|
(project_key,),
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"project_key": project_key,
|
||||||
|
"contract_commit": state["contract_commit"],
|
||||||
|
"state_version": state["state_version"],
|
||||||
|
"state": state["state"],
|
||||||
|
"event_counts": event_counts,
|
||||||
|
"work_unit_counts": outbox_counts,
|
||||||
|
"updated_at": state["updated_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def metadata_get(self, key: str) -> str | None:
|
||||||
|
row = self._conn().execute(
|
||||||
|
"SELECT value FROM metadata WHERE key=?",
|
||||||
|
(key,),
|
||||||
|
).fetchone()
|
||||||
|
return None if row is None else str(row["value"])
|
||||||
|
|
||||||
|
def metadata_set(self, key: str, value: str) -> None:
|
||||||
|
self._conn().execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO metadata(key, value) VALUES (?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value=excluded.value
|
||||||
|
""",
|
||||||
|
(key, value),
|
||||||
|
)
|
||||||
|
|
||||||
|
def observe_refs(self, refs: dict[str, str]) -> tuple[bool, list[tuple[str, str | None, str]]]:
|
||||||
|
"""Persist a Git snapshot and return changes after the initial baseline."""
|
||||||
|
now = int(time.time())
|
||||||
|
conn = self._conn()
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
initialized = conn.execute(
|
||||||
|
"SELECT value FROM metadata WHERE key='git_poll_initialized'"
|
||||||
|
).fetchone()
|
||||||
|
previous = {
|
||||||
|
row["refname"]: row["object_id"]
|
||||||
|
for row in conn.execute("SELECT refname, object_id FROM observed_refs").fetchall()
|
||||||
|
}
|
||||||
|
changes: list[tuple[str, str | None, str]] = []
|
||||||
|
if initialized is not None:
|
||||||
|
for refname, oid in refs.items():
|
||||||
|
old = previous.get(refname)
|
||||||
|
if old != oid:
|
||||||
|
changes.append((refname, old, oid))
|
||||||
|
for refname, old in previous.items():
|
||||||
|
if refname not in refs:
|
||||||
|
changes.append((refname, old, "0" * 40))
|
||||||
|
conn.execute("DELETE FROM observed_refs")
|
||||||
|
for refname, oid in refs.items():
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO observed_refs(refname, object_id, observed_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(refname, oid, now),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO metadata(key, value)
|
||||||
|
VALUES ('git_poll_initialized', '1')
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value='1'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute("COMMIT")
|
||||||
|
return initialized is not None, changes
|
||||||
|
except Exception:
|
||||||
|
conn.execute("ROLLBACK")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
conn = getattr(self._local, "conn", None)
|
||||||
|
if conn is not None:
|
||||||
|
conn.close()
|
||||||
|
self._local.conn = None
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
from pathlib import Path
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .errors import DispatchError
|
||||||
|
from .securefs import atomic_write_bytes_shared, ensure_shared_directory, read_secure_bytes
|
||||||
|
|
||||||
|
|
||||||
|
class OpenClawSynchronizer:
|
||||||
|
"""Synchronize task starts, ends, dispatches, and project summaries.
|
||||||
|
|
||||||
|
A durable file is always emitted. An optional HTTP endpoint is best-effort;
|
||||||
|
failure leaves the lifecycle record pending for retry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
queue_dir: Path,
|
||||||
|
*,
|
||||||
|
endpoint: str | None = None,
|
||||||
|
bearer_token: str | None = None,
|
||||||
|
timeout_seconds: int = 10,
|
||||||
|
):
|
||||||
|
self.queue_dir = Path(queue_dir)
|
||||||
|
self.endpoint = endpoint
|
||||||
|
self.bearer_token = bearer_token
|
||||||
|
self.timeout_seconds = timeout_seconds
|
||||||
|
ensure_shared_directory(self.queue_dir)
|
||||||
|
|
||||||
|
def emit(self, record: dict[str, Any]) -> Path:
|
||||||
|
sync_id = record.get("sync_id")
|
||||||
|
if not isinstance(sync_id, str) or not sync_id:
|
||||||
|
raise DispatchError("invalid_sync_id")
|
||||||
|
payload = (json.dumps(record, indent=2, sort_keys=True) + "\n").encode()
|
||||||
|
path = self.queue_dir / "pending" / f"{sync_id}.json"
|
||||||
|
ensure_shared_directory(path.parent)
|
||||||
|
try:
|
||||||
|
st = os.lstat(path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
atomic_write_bytes_shared(path, payload, mode=0o640)
|
||||||
|
else:
|
||||||
|
if not stat.S_ISREG(st.st_mode):
|
||||||
|
raise DispatchError("sync_target_not_regular")
|
||||||
|
if read_secure_bytes(path, expected_uid=None, max_bytes=1_048_576) != payload:
|
||||||
|
raise DispatchError("sync_idempotency_conflict")
|
||||||
|
|
||||||
|
if self.endpoint:
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Idempotency-Key": sync_id,
|
||||||
|
"User-Agent": "aios-orchestrator-gateway/0.2",
|
||||||
|
}
|
||||||
|
if self.bearer_token:
|
||||||
|
headers["Authorization"] = f"Bearer {self.bearer_token}"
|
||||||
|
request = urllib.request.Request(
|
||||||
|
self.endpoint,
|
||||||
|
data=payload,
|
||||||
|
method="POST",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=self.timeout_seconds) as response:
|
||||||
|
response.read(65_536)
|
||||||
|
if response.status not in {200, 201, 202, 204}:
|
||||||
|
raise DispatchError(f"openclaw_sync_http_{response.status}")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raise DispatchError(f"openclaw_sync_http_{exc.code}") from exc
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||||
|
raise DispatchError("openclaw_sync_failed") from exc
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def lifecycle_record(
|
||||||
|
*,
|
||||||
|
contract_commit: str,
|
||||||
|
project_key: str,
|
||||||
|
kind: str,
|
||||||
|
work_unit_id: str | None,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
canonical = json.dumps(
|
||||||
|
{
|
||||||
|
"contract_commit": contract_commit,
|
||||||
|
"project_key": project_key,
|
||||||
|
"kind": kind,
|
||||||
|
"work_unit_id": work_unit_id,
|
||||||
|
"payload": payload,
|
||||||
|
},
|
||||||
|
separators=(",", ":"),
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
sync_id = hashlib.sha256(canonical.encode()).hexdigest()
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"sync_id": sync_id,
|
||||||
|
"contract_commit": contract_commit,
|
||||||
|
"project_key": project_key,
|
||||||
|
"kind": kind,
|
||||||
|
"work_unit_id": work_unit_id,
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AIOS Orchestrator Gateway reliability poll
|
||||||
|
After=aios-orchestrator-gateway.service
|
||||||
|
ConditionPathExists=/etc/aios-orchestrator-gateway/config.json
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=aios-orchestrator
|
||||||
|
Group=aios-orchestrator
|
||||||
|
SupplementaryGroups=gitops
|
||||||
|
WorkingDirectory=/opt/aios-orchestrator-gateway/current
|
||||||
|
ExecStart=/usr/bin/python3 -m services.aios_orchestrator_gateway --config /etc/aios-orchestrator-gateway/config.json --poll-once
|
||||||
|
UMask=0077
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
PrivateDevices=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectKernelLogs=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
ProtectHostname=true
|
||||||
|
ProtectProc=invisible
|
||||||
|
ProcSubset=pid
|
||||||
|
RestrictNamespaces=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
LockPersonality=true
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
AmbientCapabilities=
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
|
StateDirectory=aios-orchestrator-gateway
|
||||||
|
ReadOnlyPaths=/etc/aios-orchestrator-gateway /opt/aios-orchestrator-gateway
|
||||||
|
ReadWritePaths=/var/lib/aios-orchestrator-gateway
|
||||||
|
SystemCallFilter=@system-service
|
||||||
|
SystemCallErrorNumber=EPERM
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Periodically poll AIOS orchestration inboxes and model responses
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=45s
|
||||||
|
OnUnitActiveSec=60s
|
||||||
|
RandomizedDelaySec=10s
|
||||||
|
Persistent=true
|
||||||
|
Unit=aios-orchestrator-gateway-poll.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AIOS Orchestrator Gateway
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
ConditionPathExists=/etc/aios-orchestrator-gateway/config.json
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=aios-orchestrator
|
||||||
|
Group=aios-orchestrator
|
||||||
|
SupplementaryGroups=gitops
|
||||||
|
WorkingDirectory=/opt/aios-orchestrator-gateway/current
|
||||||
|
ExecStart=/usr/bin/python3 -m services.aios_orchestrator_gateway --config /etc/aios-orchestrator-gateway/config.json
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
TimeoutStartSec=30s
|
||||||
|
TimeoutStopSec=30s
|
||||||
|
UMask=0077
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
PrivateDevices=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectKernelLogs=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
ProtectClock=true
|
||||||
|
ProtectHostname=true
|
||||||
|
ProtectProc=invisible
|
||||||
|
ProcSubset=pid
|
||||||
|
RestrictNamespaces=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
LockPersonality=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
AmbientCapabilities=
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
StateDirectory=aios-orchestrator-gateway
|
||||||
|
RuntimeDirectory=aios-orchestrator-gateway
|
||||||
|
ReadOnlyPaths=/etc/aios-orchestrator-gateway /opt/aios-orchestrator-gateway
|
||||||
|
ReadWritePaths=/var/lib/aios-orchestrator-gateway
|
||||||
|
SystemCallFilter=@system-service
|
||||||
|
SystemCallErrorNumber=EPERM
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
g aios-orchestrator -
|
||||||
|
u aios-orchestrator - "AIOS Orchestrator Gateway" /var/lib/aios-orchestrator-gateway /usr/sbin/nologin
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
d /etc/aios-orchestrator-gateway 0700 root root -
|
||||||
|
d /var/lib/aios-orchestrator-gateway 0700 aios-orchestrator aios-orchestrator -
|
||||||
|
d /var/lib/aios-orchestrator-gateway/inbox 0700 aios-orchestrator aios-orchestrator -
|
||||||
|
d /var/lib/aios-orchestrator-gateway/inbox/events 0700 aios-orchestrator aios-orchestrator -
|
||||||
|
d /var/lib/aios-orchestrator-gateway/inbox/events/pending 2770 aios-orchestrator gitops -
|
||||||
|
d /var/lib/aios-orchestrator-gateway/inbox/events/processed 0700 aios-orchestrator aios-orchestrator -
|
||||||
|
d /var/lib/aios-orchestrator-gateway/inbox/events/rejected 0700 aios-orchestrator aios-orchestrator -
|
||||||
|
d /var/lib/aios-orchestrator-gateway/inbox/decisions 0700 aios-orchestrator aios-orchestrator -
|
||||||
|
d /var/lib/aios-orchestrator-gateway/inbox/decisions/pending 2770 aios-orchestrator gitops -
|
||||||
|
d /var/lib/aios-orchestrator-gateway/inbox/decisions/processed 0700 aios-orchestrator aios-orchestrator -
|
||||||
|
d /var/lib/aios-orchestrator-gateway/inbox/decisions/rejected 0700 aios-orchestrator aios-orchestrator -
|
||||||
|
d /var/lib/aios-orchestrator-gateway/outbox 0700 aios-orchestrator aios-orchestrator -
|
||||||
|
d /var/lib/aios-orchestrator-gateway/openclaw-sync 2750 aios-orchestrator gitops -
|
||||||
|
d /var/lib/aios-orchestrator-gateway/openclaw-sync/pending 2750 aios-orchestrator gitops -
|
||||||
|
d /var/lib/aios-orchestrator-gateway/commands 2750 aios-orchestrator gitops -
|
||||||
|
d /var/lib/aios-orchestrator-gateway/commands/openclaw 2750 aios-orchestrator gitops -
|
||||||
|
d /var/lib/aios-orchestrator-gateway/commands/openclaw/pending 2750 aios-orchestrator gitops -
|
||||||
|
d /var/lib/aios-orchestrator-gateway/commands/codex 2750 aios-orchestrator gitops -
|
||||||
|
d /var/lib/aios-orchestrator-gateway/commands/codex/pending 2750 aios-orchestrator gitops -
|
||||||
@@ -0,0 +1,774 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from services.aios_orchestrator_gateway.clients import ResponsesClient, WorkspaceAgentClient
|
||||||
|
from services.aios_orchestrator_gateway.config import Config
|
||||||
|
from services.aios_orchestrator_gateway.dispatch import DispatchSink
|
||||||
|
from services.aios_orchestrator_gateway.errors import ConflictError, DispatchError, ValidationError
|
||||||
|
from services.aios_orchestrator_gateway.models import Decision, Event
|
||||||
|
from services.aios_orchestrator_gateway.service import Runtime
|
||||||
|
from services.aios_orchestrator_gateway.store import Store
|
||||||
|
from services.aios_orchestrator_gateway.sync import OpenClawSynchronizer, lifecycle_record
|
||||||
|
from scripts import report_orchestrator_event
|
||||||
|
|
||||||
|
|
||||||
|
CONTRACT = "cbe33ecab8e9794ba5f318bea43aeaf447a56fbc"
|
||||||
|
BASE = "8d9c6da90fc8caa69b691d243c6fefb40b6b0575"
|
||||||
|
|
||||||
|
|
||||||
|
def event_payload(**overrides):
|
||||||
|
value = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"project_key": "v2r1-personal-weixin",
|
||||||
|
"contract_commit": CONTRACT,
|
||||||
|
"event_id": "evt-001",
|
||||||
|
"source_agent": "openclaw",
|
||||||
|
"event_type": "work_unit_completed",
|
||||||
|
"work_unit_id": "P1-REACK",
|
||||||
|
"status": "PASS",
|
||||||
|
"state_version": 0,
|
||||||
|
"implementation_commit": None,
|
||||||
|
"result_commit": None,
|
||||||
|
"blocker": None,
|
||||||
|
"evidence_refs": [],
|
||||||
|
"payload": {},
|
||||||
|
}
|
||||||
|
value.update(overrides)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def decision_payload(**overrides):
|
||||||
|
value = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"project_key": "v2r1-personal-weixin",
|
||||||
|
"contract_commit": CONTRACT,
|
||||||
|
"event_id": "evt-001",
|
||||||
|
"expected_state_version": 0,
|
||||||
|
"verdict": "DISPATCH",
|
||||||
|
"state_updates": [
|
||||||
|
{"key": "P1_REPAIR3_CODE", "value": "PASS_CANDIDATE_PREPARED"},
|
||||||
|
{"key": "current_gate", "value": "R0_RUNNER_LIVE_RECOVERY"},
|
||||||
|
],
|
||||||
|
"next_work_units": [
|
||||||
|
{
|
||||||
|
"id": "R0-DISCOVER",
|
||||||
|
"track_id": "R_RUNNER",
|
||||||
|
"gate_id": "R0_RUNNER_LIVE_RECOVERY",
|
||||||
|
"primary_agent": "openclaw",
|
||||||
|
"fallback_agent": "codex",
|
||||||
|
"clean_base_commit": BASE,
|
||||||
|
"allowed_paths": [],
|
||||||
|
"required_checks": ["secret_provider_metadata"],
|
||||||
|
"forbidden_operations": ["force_push"],
|
||||||
|
"instruction": "Discover provider metadata without reading secrets.",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requires_human_input": False,
|
||||||
|
"stop_reason": None,
|
||||||
|
}
|
||||||
|
value.update(overrides)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class ModelTests(unittest.TestCase):
|
||||||
|
def test_event_accepts_valid_payload(self):
|
||||||
|
event = Event.from_dict(event_payload())
|
||||||
|
self.assertEqual(event.event_id, "evt-001")
|
||||||
|
self.assertEqual(len(event.digest()), 64)
|
||||||
|
|
||||||
|
def test_event_rejects_extra_fields(self):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
Event.from_dict(event_payload(extra=True))
|
||||||
|
|
||||||
|
def test_event_rejects_invalid_contract_oid(self):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
Event.from_dict(event_payload(contract_commit="abc"))
|
||||||
|
|
||||||
|
def test_decision_requires_work_unit_for_dispatch(self):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
Decision.from_dict(decision_payload(next_work_units=[]))
|
||||||
|
|
||||||
|
def test_decision_rejects_same_primary_fallback(self):
|
||||||
|
value = decision_payload()
|
||||||
|
value["next_work_units"][0]["fallback_agent"] = "openclaw"
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
Decision.from_dict(value)
|
||||||
|
|
||||||
|
def test_non_dispatch_rejects_work_units(self):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
Decision.from_dict(decision_payload(verdict="BLOCKED"))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ClientTests(unittest.TestCase):
|
||||||
|
def test_workspace_trigger_accepts_202_without_run_id(self):
|
||||||
|
event = Event.from_dict(event_payload())
|
||||||
|
response = mock.MagicMock()
|
||||||
|
response.status = 202
|
||||||
|
response.read.return_value = b""
|
||||||
|
response.__enter__.return_value = response
|
||||||
|
response.__exit__.return_value = False
|
||||||
|
with mock.patch(
|
||||||
|
"services.aios_orchestrator_gateway.clients.urllib.request.urlopen",
|
||||||
|
return_value=response,
|
||||||
|
) as call:
|
||||||
|
WorkspaceAgentClient("agtch_test", "token").trigger(event)
|
||||||
|
request = call.call_args.args[0]
|
||||||
|
self.assertEqual(request.headers["Idempotency-key"], "evt-001")
|
||||||
|
|
||||||
|
def test_responses_background_start_and_poll(self):
|
||||||
|
event = Event.from_dict(event_payload())
|
||||||
|
decision = decision_payload()
|
||||||
|
client = ResponsesClient("key", "gpt-test")
|
||||||
|
with mock.patch.object(
|
||||||
|
client,
|
||||||
|
"_request",
|
||||||
|
side_effect=[
|
||||||
|
{"id": "resp_123", "status": "queued"},
|
||||||
|
{
|
||||||
|
"id": "resp_123",
|
||||||
|
"status": "completed",
|
||||||
|
"output": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "output_text",
|
||||||
|
"text": json.dumps(decision),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
):
|
||||||
|
response_id = client.start(event)
|
||||||
|
status, parsed = client.poll(response_id)
|
||||||
|
self.assertEqual(response_id, "resp_123")
|
||||||
|
self.assertEqual(status, "completed")
|
||||||
|
self.assertEqual(parsed.event_id, "evt-001")
|
||||||
|
|
||||||
|
def test_responses_poll_keeps_queued_state(self):
|
||||||
|
client = ResponsesClient("key", "gpt-test")
|
||||||
|
with mock.patch.object(
|
||||||
|
client,
|
||||||
|
"_request",
|
||||||
|
return_value={"id": "resp_123", "status": "in_progress"},
|
||||||
|
):
|
||||||
|
status, parsed = client.poll("resp_123")
|
||||||
|
self.assertEqual(status, "in_progress")
|
||||||
|
self.assertIsNone(parsed)
|
||||||
|
|
||||||
|
def test_responses_poll_queued_in_progress_completed_sequence(self):
|
||||||
|
client = ResponsesClient("key", "gpt-test")
|
||||||
|
completed = {
|
||||||
|
"status": "completed",
|
||||||
|
"output": [{"content": [{"type": "output_text", "text": json.dumps(decision_payload())}]}],
|
||||||
|
}
|
||||||
|
with mock.patch.object(
|
||||||
|
client,
|
||||||
|
"_request",
|
||||||
|
side_effect=[{"status": "queued"}, {"status": "in_progress"}, completed],
|
||||||
|
):
|
||||||
|
self.assertEqual(client.poll("resp_123"), ("queued", None))
|
||||||
|
self.assertEqual(client.poll("resp_123"), ("in_progress", None))
|
||||||
|
status, decision = client.poll("resp_123")
|
||||||
|
self.assertEqual(status, "completed")
|
||||||
|
self.assertEqual(decision.event_id, "evt-001")
|
||||||
|
|
||||||
|
def test_responses_invalid_completed_decision_fails_closed(self):
|
||||||
|
client = ResponsesClient("key", "gpt-test")
|
||||||
|
payload = {"status": "completed", "output": [{"content": [{"type": "output_text", "text": "{}"}]}]}
|
||||||
|
with mock.patch.object(client, "_request", return_value=payload):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
client.poll("resp_123")
|
||||||
|
|
||||||
|
|
||||||
|
class StoreTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.store = Store(Path(self.temp.name) / "state.sqlite3")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.store.close()
|
||||||
|
self.temp.cleanup()
|
||||||
|
|
||||||
|
def test_event_idempotency(self):
|
||||||
|
event = Event.from_dict(event_payload())
|
||||||
|
created, _ = self.store.register_event(event)
|
||||||
|
duplicate, _ = self.store.register_event(event)
|
||||||
|
self.assertTrue(created)
|
||||||
|
self.assertFalse(duplicate)
|
||||||
|
|
||||||
|
def test_event_payload_conflict_rejected(self):
|
||||||
|
self.store.register_event(Event.from_dict(event_payload()))
|
||||||
|
with self.assertRaises(ConflictError):
|
||||||
|
self.store.register_event(Event.from_dict(event_payload(status="FAIL")))
|
||||||
|
|
||||||
|
def test_stale_state_version_rejected(self):
|
||||||
|
self.store.register_event(Event.from_dict(event_payload()))
|
||||||
|
with self.assertRaises(ConflictError):
|
||||||
|
self.store.register_event(
|
||||||
|
Event.from_dict(event_payload(event_id="evt-002", state_version=1))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_lease_claim_prevents_double_claim(self):
|
||||||
|
self.store.register_event(Event.from_dict(event_payload()))
|
||||||
|
first = self.store.claim_events(
|
||||||
|
owner="one", statuses=("RECEIVED",), limit=5, lease_seconds=60
|
||||||
|
)
|
||||||
|
second_store = Store(Path(self.temp.name) / "state.sqlite3")
|
||||||
|
try:
|
||||||
|
second = second_store.claim_events(
|
||||||
|
owner="two", statuses=("RECEIVED",), limit=5, lease_seconds=60
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
second_store.close()
|
||||||
|
self.assertEqual(len(first), 1)
|
||||||
|
self.assertEqual(second, [])
|
||||||
|
|
||||||
|
def test_workspace_timeout_claim_prevents_double_worker(self):
|
||||||
|
self.store.register_event(Event.from_dict(event_payload()))
|
||||||
|
self.store.update_event_trigger("evt-001", status="AWAITING_DECISION", channel="workspace_agent", remote_id=None)
|
||||||
|
self.store._conn().execute("UPDATE events SET updated_at=0 WHERE event_id='evt-001'")
|
||||||
|
first = self.store.claim_workspace_timeouts(owner="one", older_than=1, limit=5, lease_seconds=60)
|
||||||
|
second = self.store.claim_workspace_timeouts(owner="two", older_than=1, limit=5, lease_seconds=60)
|
||||||
|
self.assertEqual(len(first), 1)
|
||||||
|
self.assertEqual(second, [])
|
||||||
|
|
||||||
|
def test_response_poll_claim_prevents_double_worker(self):
|
||||||
|
self.store.register_event(Event.from_dict(event_payload()))
|
||||||
|
self.store.update_event_trigger("evt-001", status="POLLING_RESPONSE", channel="responses_api", remote_id="resp_123")
|
||||||
|
first = self.store.claim_response_polls(owner="one", limit=5, lease_seconds=60)
|
||||||
|
second = self.store.claim_response_polls(owner="two", limit=5, lease_seconds=60)
|
||||||
|
self.assertEqual(len(first), 1)
|
||||||
|
self.assertEqual(second, [])
|
||||||
|
|
||||||
|
def test_max_hops_limit(self):
|
||||||
|
self.store.register_event(Event.from_dict(event_payload()))
|
||||||
|
self.store._conn().execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO gate_hops(project_key, gate_id, hops, updated_at)
|
||||||
|
VALUES ('v2r1-personal-weixin', 'R0_RUNNER_LIVE_RECOVERY', 1, 0)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ConflictError, "max_hops_exceeded"):
|
||||||
|
self.store.apply_decision(
|
||||||
|
Decision.from_dict(decision_payload()),
|
||||||
|
max_hops_per_gate=1,
|
||||||
|
max_same_blocker_retries=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_hop_limit_is_scoped_per_gate(self):
|
||||||
|
self.store.register_event(Event.from_dict(event_payload()))
|
||||||
|
first = Decision.from_dict(decision_payload())
|
||||||
|
self.assertEqual(
|
||||||
|
self.store.apply_decision(
|
||||||
|
first, max_hops_per_gate=1, max_same_blocker_retries=3
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.store.register_event(
|
||||||
|
Event.from_dict(event_payload(event_id="evt-002", state_version=1))
|
||||||
|
)
|
||||||
|
second_value = decision_payload(
|
||||||
|
event_id="evt-002",
|
||||||
|
expected_state_version=1,
|
||||||
|
state_updates=[{"key": "current_gate", "value": "P2_REAL_B3_EVIDENCE"}],
|
||||||
|
)
|
||||||
|
second_value["next_work_units"][0]["id"] = "P2-REVIEW"
|
||||||
|
second_value["next_work_units"][0]["gate_id"] = "P2_REAL_B3_EVIDENCE"
|
||||||
|
self.assertEqual(
|
||||||
|
self.store.apply_decision(
|
||||||
|
Decision.from_dict(second_value),
|
||||||
|
max_hops_per_gate=1,
|
||||||
|
max_same_blocker_retries=3,
|
||||||
|
),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.store.register_event(
|
||||||
|
Event.from_dict(event_payload(event_id="evt-003", state_version=2))
|
||||||
|
)
|
||||||
|
third_value = decision_payload(
|
||||||
|
event_id="evt-003",
|
||||||
|
expected_state_version=2,
|
||||||
|
state_updates=[{"key": "current_gate", "value": "P2_REAL_B3_EVIDENCE"}],
|
||||||
|
)
|
||||||
|
third_value["next_work_units"][0]["id"] = "P2-REVIEW-RETRY"
|
||||||
|
third_value["next_work_units"][0]["gate_id"] = "P2_REAL_B3_EVIDENCE"
|
||||||
|
with self.assertRaisesRegex(ConflictError, "max_hops_exceeded"):
|
||||||
|
self.store.apply_decision(
|
||||||
|
Decision.from_dict(third_value),
|
||||||
|
max_hops_per_gate=1,
|
||||||
|
max_same_blocker_retries=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_decision_advances_state_and_creates_outbox(self):
|
||||||
|
self.store.register_event(Event.from_dict(event_payload()))
|
||||||
|
version = self.store.apply_decision(
|
||||||
|
Decision.from_dict(decision_payload()),
|
||||||
|
max_hops_per_gate=20,
|
||||||
|
max_same_blocker_retries=3,
|
||||||
|
)
|
||||||
|
self.assertEqual(version, 1)
|
||||||
|
state = self.store.project_state("v2r1-personal-weixin")
|
||||||
|
self.assertEqual(state["state_version"], 1)
|
||||||
|
rows = self.store.claim_outbox(owner="x", limit=10, lease_seconds=60)
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
|
||||||
|
def test_decision_idempotency(self):
|
||||||
|
self.store.register_event(Event.from_dict(event_payload()))
|
||||||
|
decision = Decision.from_dict(decision_payload())
|
||||||
|
first = self.store.apply_decision(
|
||||||
|
decision, max_hops_per_gate=20, max_same_blocker_retries=3
|
||||||
|
)
|
||||||
|
second = self.store.apply_decision(
|
||||||
|
decision, max_hops_per_gate=20, max_same_blocker_retries=3
|
||||||
|
)
|
||||||
|
self.assertEqual(first, second)
|
||||||
|
|
||||||
|
def test_same_blocker_limit(self):
|
||||||
|
self.store.register_event(Event.from_dict(event_payload()))
|
||||||
|
decision = Decision.from_dict(
|
||||||
|
decision_payload(
|
||||||
|
verdict="BLOCKED",
|
||||||
|
next_work_units=[],
|
||||||
|
stop_reason="SAME_BLOCKER",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.store.apply_decision(
|
||||||
|
decision, max_hops_per_gate=20, max_same_blocker_retries=1
|
||||||
|
)
|
||||||
|
# New event at next state with the same blocker exceeds the configured limit.
|
||||||
|
self.store.register_event(
|
||||||
|
Event.from_dict(event_payload(event_id="evt-002", state_version=1))
|
||||||
|
)
|
||||||
|
decision2 = Decision.from_dict(
|
||||||
|
decision_payload(
|
||||||
|
event_id="evt-002",
|
||||||
|
expected_state_version=1,
|
||||||
|
verdict="BLOCKED",
|
||||||
|
next_work_units=[],
|
||||||
|
stop_reason="SAME_BLOCKER",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with self.assertRaises(ConflictError):
|
||||||
|
self.store.apply_decision(
|
||||||
|
decision2, max_hops_per_gate=20, max_same_blocker_retries=1
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_project_summary_contains_counts(self):
|
||||||
|
self.store.register_event(Event.from_dict(event_payload()))
|
||||||
|
summary = self.store.project_summary("v2r1-personal-weixin")
|
||||||
|
self.assertEqual(summary["event_counts"]["RECEIVED"], 1)
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchTests(unittest.TestCase):
|
||||||
|
def test_dispatch_is_idempotent(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
outbox = Path(temp) / "outbox"
|
||||||
|
queue = Path(temp) / "queue"
|
||||||
|
outbox.mkdir(mode=0o700)
|
||||||
|
queue.mkdir(mode=0o2770)
|
||||||
|
queue.chmod(0o2770)
|
||||||
|
sink = DispatchSink(outbox, queue, CONTRACT)
|
||||||
|
record = {
|
||||||
|
"work_unit_id": "R0-DISCOVER",
|
||||||
|
"event_id": "evt-001",
|
||||||
|
"assigned_agent": "openclaw",
|
||||||
|
"payload_json": json.dumps(decision_payload()["next_work_units"][0]),
|
||||||
|
}
|
||||||
|
first = sink.dispatch(record)
|
||||||
|
second = sink.dispatch(record)
|
||||||
|
self.assertEqual(first, second)
|
||||||
|
self.assertTrue(first[0].exists())
|
||||||
|
self.assertTrue(first[1].exists())
|
||||||
|
self.assertEqual(stat.S_IMODE(first[1].stat().st_mode), 0o640)
|
||||||
|
|
||||||
|
def test_dispatch_rejects_path_escape(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
outbox = Path(temp) / "outbox"
|
||||||
|
queue = Path(temp) / "queue"
|
||||||
|
outbox.mkdir(mode=0o700)
|
||||||
|
queue.mkdir(mode=0o2770)
|
||||||
|
queue.chmod(0o2770)
|
||||||
|
sink = DispatchSink(outbox, queue, CONTRACT)
|
||||||
|
with self.assertRaises(DispatchError):
|
||||||
|
sink.dispatch(
|
||||||
|
{
|
||||||
|
"work_unit_id": "../escape",
|
||||||
|
"event_id": "evt-001",
|
||||||
|
"assigned_agent": "openclaw",
|
||||||
|
"payload_json": "{}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_rejects_existing_symlink_target(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
outbox = Path(temp) / "outbox"
|
||||||
|
queue = Path(temp) / "queue"
|
||||||
|
outbox.mkdir(mode=0o700)
|
||||||
|
queue.mkdir(mode=0o2770)
|
||||||
|
queue.chmod(0o2770)
|
||||||
|
sink = DispatchSink(outbox, queue, CONTRACT)
|
||||||
|
target = Path(temp) / "target"
|
||||||
|
target.write_text("x")
|
||||||
|
pending = outbox / "dispatched"
|
||||||
|
pending.mkdir(mode=0o700)
|
||||||
|
(pending / "R0-DISCOVER.json").symlink_to(target)
|
||||||
|
with self.assertRaises(DispatchError):
|
||||||
|
sink.dispatch(
|
||||||
|
{
|
||||||
|
"work_unit_id": "R0-DISCOVER",
|
||||||
|
"event_id": "evt-001",
|
||||||
|
"assigned_agent": "openclaw",
|
||||||
|
"payload_json": json.dumps(decision_payload()["next_work_units"][0]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncTests(unittest.TestCase):
|
||||||
|
def test_lifecycle_record_is_stable(self):
|
||||||
|
one = lifecycle_record(
|
||||||
|
contract_commit=CONTRACT,
|
||||||
|
project_key="v2r1",
|
||||||
|
kind="TASK_STARTED",
|
||||||
|
work_unit_id="R0",
|
||||||
|
payload={"status": "RUNNING"},
|
||||||
|
)
|
||||||
|
two = lifecycle_record(
|
||||||
|
contract_commit=CONTRACT,
|
||||||
|
project_key="v2r1",
|
||||||
|
kind="TASK_STARTED",
|
||||||
|
work_unit_id="R0",
|
||||||
|
payload={"status": "RUNNING"},
|
||||||
|
)
|
||||||
|
self.assertEqual(one["sync_id"], two["sync_id"])
|
||||||
|
|
||||||
|
def test_file_sync_is_idempotent(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
root = Path(temp)
|
||||||
|
root.chmod(0o2750)
|
||||||
|
sync = OpenClawSynchronizer(root)
|
||||||
|
record = lifecycle_record(
|
||||||
|
contract_commit=CONTRACT,
|
||||||
|
project_key="v2r1",
|
||||||
|
kind="PROJECT_SUMMARY",
|
||||||
|
work_unit_id=None,
|
||||||
|
payload={"done": 1},
|
||||||
|
)
|
||||||
|
first = sync.emit(record)
|
||||||
|
second = sync.emit(record)
|
||||||
|
self.assertEqual(first, second)
|
||||||
|
|
||||||
|
def test_http_failure_keeps_file_for_retry(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
root = Path(temp)
|
||||||
|
root.chmod(0o2750)
|
||||||
|
sync = OpenClawSynchronizer(root, endpoint="https://openclaw.invalid/events")
|
||||||
|
record = lifecycle_record(
|
||||||
|
contract_commit=CONTRACT,
|
||||||
|
project_key="v2r1",
|
||||||
|
kind="PROJECT_COMPLETED",
|
||||||
|
work_unit_id=None,
|
||||||
|
payload={"done": 1},
|
||||||
|
)
|
||||||
|
with mock.patch(
|
||||||
|
"services.aios_orchestrator_gateway.sync.urllib.request.urlopen",
|
||||||
|
side_effect=OSError("offline"),
|
||||||
|
):
|
||||||
|
with self.assertRaises(DispatchError):
|
||||||
|
sync.emit(record)
|
||||||
|
pending = root / "pending" / f"{record['sync_id']}.json"
|
||||||
|
self.assertTrue(pending.exists())
|
||||||
|
response = mock.MagicMock(status=202)
|
||||||
|
response.__enter__.return_value = response
|
||||||
|
response.__exit__.return_value = False
|
||||||
|
with mock.patch(
|
||||||
|
"services.aios_orchestrator_gateway.sync.urllib.request.urlopen",
|
||||||
|
return_value=response,
|
||||||
|
):
|
||||||
|
self.assertEqual(sync.emit(record), pending)
|
||||||
|
|
||||||
|
|
||||||
|
class ReporterTests(unittest.TestCase):
|
||||||
|
def test_http_failure_falls_back_to_file_inbox(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
root = Path(temp)
|
||||||
|
inbox = root / "inbox"
|
||||||
|
inbox.mkdir(mode=0o2770)
|
||||||
|
inbox.chmod(0o2770)
|
||||||
|
token = root / "token"
|
||||||
|
token.write_text("not-a-real-secret", encoding="utf-8")
|
||||||
|
token.chmod(0o600)
|
||||||
|
argv = [
|
||||||
|
"report_orchestrator_event.py",
|
||||||
|
"--project-key", "v2r1-personal-weixin",
|
||||||
|
"--contract-commit", CONTRACT,
|
||||||
|
"--event-id", "evt-http-fallback",
|
||||||
|
"--source-agent", "codex",
|
||||||
|
"--event-type", "work_unit_completed",
|
||||||
|
"--work-unit-id", "P1-REACK",
|
||||||
|
"--status", "PASS",
|
||||||
|
"--state-version", "0",
|
||||||
|
"--endpoint", "https://gateway.invalid",
|
||||||
|
"--token-file", str(token),
|
||||||
|
"--inbox-dir", str(inbox),
|
||||||
|
]
|
||||||
|
with mock.patch("sys.argv", argv), mock.patch(
|
||||||
|
"scripts.report_orchestrator_event.urllib.request.urlopen",
|
||||||
|
side_effect=urllib.error.URLError("offline"),
|
||||||
|
):
|
||||||
|
self.assertEqual(report_orchestrator_event.main(), 0)
|
||||||
|
pending = inbox / "pending/evt-http-fallback.json"
|
||||||
|
self.assertTrue(pending.exists())
|
||||||
|
self.assertEqual(stat.S_IMODE(pending.stat().st_mode), 0o640)
|
||||||
|
|
||||||
|
def test_payload_symlink_is_rejected(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
root = Path(temp)
|
||||||
|
target = root / "payload.json"
|
||||||
|
target.write_text("{}", encoding="utf-8")
|
||||||
|
target.chmod(0o600)
|
||||||
|
link = root / "payload-link.json"
|
||||||
|
link.symlink_to(target)
|
||||||
|
argv = [
|
||||||
|
"report_orchestrator_event.py",
|
||||||
|
"--project-key", "v2r1-personal-weixin",
|
||||||
|
"--contract-commit", CONTRACT,
|
||||||
|
"--event-id", "evt-symlink",
|
||||||
|
"--source-agent", "codex",
|
||||||
|
"--event-type", "work_unit_completed",
|
||||||
|
"--work-unit-id", "P1-REACK",
|
||||||
|
"--status", "PASS",
|
||||||
|
"--state-version", "0",
|
||||||
|
"--payload-file", str(link),
|
||||||
|
"--inbox-dir", str(root / "inbox"),
|
||||||
|
]
|
||||||
|
with mock.patch("sys.argv", argv), self.assertRaisesRegex(
|
||||||
|
Exception, "symlink_rejected"
|
||||||
|
):
|
||||||
|
report_orchestrator_event.main()
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimePollingTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
for name in ("events", "decisions", "outbox", "etc"):
|
||||||
|
(self.root / name).mkdir(mode=0o700)
|
||||||
|
for name in ("sync", "commands"):
|
||||||
|
(self.root / name).mkdir(mode=0o2750)
|
||||||
|
(self.root / name).chmod(0o2750)
|
||||||
|
for name, value in (
|
||||||
|
("inbound.token", "inbound"),
|
||||||
|
("callback.token", "callback"),
|
||||||
|
("workspace.token", "workspace"),
|
||||||
|
):
|
||||||
|
path = self.root / "etc" / name
|
||||||
|
path.write_text(value)
|
||||||
|
path.chmod(0o600)
|
||||||
|
config = {
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 18920,
|
||||||
|
"project_key": "v2r1-personal-weixin-production",
|
||||||
|
"database_path": str(self.root / "state.sqlite3"),
|
||||||
|
"event_inbox_dir": str(self.root / "events"),
|
||||||
|
"decision_inbox_dir": str(self.root / "decisions"),
|
||||||
|
"dispatch_outbox_dir": str(self.root / "outbox"),
|
||||||
|
"openclaw_sync_dir": str(self.root / "sync"),
|
||||||
|
"command_queue_dir": str(self.root / "commands"),
|
||||||
|
"inbound_token_file": str(self.root / "etc/inbound.token"),
|
||||||
|
"callback_token_file": str(self.root / "etc/callback.token"),
|
||||||
|
"contract_commit": CONTRACT,
|
||||||
|
"workspace_agent": {
|
||||||
|
"trigger_id": "agtch_test",
|
||||||
|
"access_token_file": str(self.root / "etc/workspace.token"),
|
||||||
|
},
|
||||||
|
"responses_fallback": {
|
||||||
|
"api_key_file": None,
|
||||||
|
"model": None,
|
||||||
|
},
|
||||||
|
"polling": {
|
||||||
|
"interval_seconds": 1,
|
||||||
|
"summary_interval_seconds": 10,
|
||||||
|
},
|
||||||
|
"loop_controls": {
|
||||||
|
"callback_timeout_seconds": 30,
|
||||||
|
"lease_seconds": 10,
|
||||||
|
"retry_base_seconds": 1,
|
||||||
|
"max_retries": 2,
|
||||||
|
"max_same_blocker_retries": 2,
|
||||||
|
"max_hops_per_gate": 20,
|
||||||
|
},
|
||||||
|
"openclaw_sync": {"endpoint": None, "token_file": None},
|
||||||
|
"git_poll": {
|
||||||
|
"enabled": False,
|
||||||
|
"bare_repo": None,
|
||||||
|
"ref_prefixes": [],
|
||||||
|
"interval_seconds": 30,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
config_path = self.root / "etc/config.json"
|
||||||
|
config_path.write_text(json.dumps(config))
|
||||||
|
config_path.chmod(0o600)
|
||||||
|
os.environ["AIOS_ORCHESTRATOR_CONFIG_OWNER_UID"] = str(os.getuid())
|
||||||
|
os.environ["AIOS_ORCHESTRATOR_SECRET_OWNER_UID"] = str(os.getuid())
|
||||||
|
self.runtime = Runtime(Config.from_file(config_path))
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.runtime.store.close()
|
||||||
|
self.temp.cleanup()
|
||||||
|
os.environ.pop("AIOS_ORCHESTRATOR_CONFIG_OWNER_UID", None)
|
||||||
|
os.environ.pop("AIOS_ORCHESTRATOR_SECRET_OWNER_UID", None)
|
||||||
|
|
||||||
|
def test_file_event_polling_and_workspace_trigger(self):
|
||||||
|
pending = self.root / "events/pending"
|
||||||
|
pending.mkdir(mode=0o2770)
|
||||||
|
pending.chmod(0o2770)
|
||||||
|
(pending / "evt-001.json").write_text(json.dumps(event_payload()))
|
||||||
|
(pending / "evt-001.json").chmod(0o640)
|
||||||
|
with mock.patch.object(self.runtime.workspace, "trigger") as trigger:
|
||||||
|
self.runtime.poll_once()
|
||||||
|
trigger.assert_called_once()
|
||||||
|
processed = self.root / "events/processed/evt-001.json"
|
||||||
|
self.assertTrue(processed.exists())
|
||||||
|
|
||||||
|
def test_workspace_timeout_uses_responses_fallback(self):
|
||||||
|
self.runtime.submit_event(event_payload())
|
||||||
|
self.runtime.store.update_event_trigger(
|
||||||
|
"evt-001", status="AWAITING_DECISION", channel="workspace_agent", remote_id=None
|
||||||
|
)
|
||||||
|
self.runtime.store._conn().execute("UPDATE events SET updated_at=0 WHERE event_id='evt-001'")
|
||||||
|
fallback = mock.Mock()
|
||||||
|
fallback.start.return_value = "resp_fallback"
|
||||||
|
self.runtime.responses = fallback
|
||||||
|
self.runtime.process_workspace_timeouts()
|
||||||
|
row = self.runtime.store._conn().execute(
|
||||||
|
"SELECT status, channel, remote_id FROM events WHERE event_id='evt-001'"
|
||||||
|
).fetchone()
|
||||||
|
self.assertEqual(tuple(row), ("POLLING_RESPONSE", "responses_api", "resp_fallback"))
|
||||||
|
fallback.start.assert_called_once()
|
||||||
|
|
||||||
|
def test_workspace_callback_timeout_reaches_dead_letter_without_fallback(self):
|
||||||
|
self.runtime.submit_event(event_payload())
|
||||||
|
self.runtime.store.update_event_trigger(
|
||||||
|
"evt-001", status="AWAITING_DECISION", channel="workspace_agent", remote_id=None
|
||||||
|
)
|
||||||
|
self.runtime.store._conn().execute(
|
||||||
|
"UPDATE events SET attempts=2, updated_at=0 WHERE event_id='evt-001'"
|
||||||
|
)
|
||||||
|
self.assertIsNone(self.runtime.responses)
|
||||||
|
self.runtime.process_workspace_timeouts()
|
||||||
|
row = self.runtime.store._conn().execute(
|
||||||
|
"SELECT status, attempts FROM events WHERE event_id='evt-001'"
|
||||||
|
).fetchone()
|
||||||
|
self.assertEqual(tuple(row), ("DEAD_LETTER", 3))
|
||||||
|
|
||||||
|
def test_response_poll_limit_dead_letters_queued_response(self):
|
||||||
|
self.runtime.submit_event(event_payload())
|
||||||
|
self.runtime.store.update_event_trigger(
|
||||||
|
"evt-001",
|
||||||
|
status="POLLING_RESPONSE",
|
||||||
|
channel="responses_api",
|
||||||
|
remote_id="resp-stuck",
|
||||||
|
)
|
||||||
|
self.runtime.store._conn().execute(
|
||||||
|
"UPDATE events SET attempts=2, next_attempt_at=0 WHERE event_id='evt-001'"
|
||||||
|
)
|
||||||
|
fallback = mock.Mock()
|
||||||
|
fallback.poll.return_value = ("queued", None)
|
||||||
|
self.runtime.responses = fallback
|
||||||
|
self.runtime.poll_background_responses()
|
||||||
|
row = self.runtime.store._conn().execute(
|
||||||
|
"SELECT status, attempts FROM events WHERE event_id='evt-001'"
|
||||||
|
).fetchone()
|
||||||
|
self.assertEqual(tuple(row), ("DEAD_LETTER", 3))
|
||||||
|
|
||||||
|
def test_callback_rejects_wrong_contract(self):
|
||||||
|
self.runtime.submit_event(event_payload())
|
||||||
|
with self.assertRaisesRegex(ConflictError, "contract_version_mismatch"):
|
||||||
|
self.runtime.submit_decision(
|
||||||
|
decision_payload(contract_commit="f" * 40)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_task_start_and_end_are_synced(self):
|
||||||
|
start = Event.from_dict(
|
||||||
|
event_payload(
|
||||||
|
event_id="evt-start",
|
||||||
|
event_type="work_unit_started",
|
||||||
|
status="RUNNING",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.runtime.submit_event(start.to_dict())
|
||||||
|
self.runtime.synchronize_openclaw()
|
||||||
|
pending = list((self.root / "sync/pending").glob("*.json"))
|
||||||
|
self.assertTrue(pending)
|
||||||
|
kinds = {json.loads(p.read_text())["kind"] for p in pending}
|
||||||
|
self.assertIn("TASK_STARTED", kinds)
|
||||||
|
|
||||||
|
def test_decision_dispatch_and_summary_sync(self):
|
||||||
|
self.runtime.submit_event(event_payload())
|
||||||
|
self.runtime.submit_decision(decision_payload())
|
||||||
|
self.runtime.dispatch_work_units()
|
||||||
|
self.runtime.synchronize_openclaw()
|
||||||
|
self.assertTrue(list((self.root / "commands/openclaw/pending").glob("*.json")))
|
||||||
|
kinds = {
|
||||||
|
json.loads(p.read_text())["kind"]
|
||||||
|
for p in (self.root / "sync/pending").glob("*.json")
|
||||||
|
}
|
||||||
|
self.assertIn("TASK_DISPATCHED", kinds)
|
||||||
|
self.assertIn("PROJECT_SUMMARY", kinds)
|
||||||
|
|
||||||
|
def test_poll_once_ingests_decision_file(self):
|
||||||
|
self.runtime.submit_event(event_payload())
|
||||||
|
pending = self.root / "decisions/pending"
|
||||||
|
pending.mkdir(mode=0o2770)
|
||||||
|
pending.chmod(0o2770)
|
||||||
|
(pending / "decision.json").write_text(json.dumps(decision_payload()))
|
||||||
|
(pending / "decision.json").chmod(0o640)
|
||||||
|
self.runtime.poll_once()
|
||||||
|
self.assertTrue((self.root / "decisions/processed/decision.json").exists())
|
||||||
|
|
||||||
|
|
||||||
|
def test_git_ref_polling_baselines_then_emits_change(self):
|
||||||
|
object.__setattr__(self.runtime.config, "git_poll_enabled", True)
|
||||||
|
object.__setattr__(self.runtime.config, "git_poll_bare_repo", self.root / "repo.git")
|
||||||
|
object.__setattr__(self.runtime.config, "git_poll_ref_prefixes", ("refs/heads/main",))
|
||||||
|
oid1 = "1" * 40
|
||||||
|
oid2 = "2" * 40
|
||||||
|
first = mock.Mock(returncode=0, stdout=f"refs/heads/main {oid1}\n", stderr="")
|
||||||
|
second = mock.Mock(returncode=0, stdout=f"refs/heads/main {oid2}\n", stderr="")
|
||||||
|
with mock.patch(
|
||||||
|
"services.aios_orchestrator_gateway.service.subprocess.run",
|
||||||
|
side_effect=[first, second],
|
||||||
|
):
|
||||||
|
self.runtime._next_git_poll = 0
|
||||||
|
self.runtime.poll_git_refs()
|
||||||
|
self.runtime._next_git_poll = 0
|
||||||
|
self.runtime.poll_git_refs()
|
||||||
|
summary = self.runtime.store.project_summary(
|
||||||
|
"v2r1-personal-weixin-production"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(summary)
|
||||||
|
self.assertEqual(summary["event_counts"]["RECEIVED"], 1)
|
||||||
|
|
||||||
|
def test_retry_backoff_is_bounded(self):
|
||||||
|
self.assertEqual(self.runtime._backoff(0), 1)
|
||||||
|
self.assertLessEqual(self.runtime._backoff(100), 3600)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
umask 0077
|
||||||
|
|
||||||
|
SOURCE_ROOT="${1:?source root required}"
|
||||||
|
ROOT="$(mktemp -d /tmp/aios-orchestrator-rollback-test.XXXXXX)"
|
||||||
|
trap 'rm -rf "$ROOT"' EXIT
|
||||||
|
INSTALL_ROOT="$ROOT/opt"
|
||||||
|
STATE_ROOT="$ROOT/state"
|
||||||
|
mkdir -p "$INSTALL_ROOT/releases" "$STATE_ROOT" "$ROOT/bin"
|
||||||
|
chmod 0700 "$STATE_ROOT"
|
||||||
|
|
||||||
|
OLD="$(printf 'a%.0s' {1..64})"
|
||||||
|
NEW="$(printf 'b%.0s' {1..64})"
|
||||||
|
mkdir "$INSTALL_ROOT/releases/$OLD" "$INSTALL_ROOT/releases/$NEW"
|
||||||
|
printf '%s\n' "$OLD" > "$INSTALL_ROOT/releases/$OLD/SOURCE_DIGEST"
|
||||||
|
printf '%s\n' "$NEW" > "$INSTALL_ROOT/releases/$NEW/SOURCE_DIGEST"
|
||||||
|
ln -s "$INSTALL_ROOT/releases/$NEW" "$INSTALL_ROOT/current"
|
||||||
|
python3 - "$STATE_ROOT/install-receipt.json" "$INSTALL_ROOT/releases/$NEW" "$INSTALL_ROOT/releases/$OLD" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
||||||
|
json.dump({
|
||||||
|
"schema_version": 1,
|
||||||
|
"source_digest": "b" * 64,
|
||||||
|
"current_release": sys.argv[2],
|
||||||
|
"previous_release": sys.argv[3],
|
||||||
|
"last_rollback": None,
|
||||||
|
}, handle)
|
||||||
|
PY
|
||||||
|
chmod 0600 "$STATE_ROOT/install-receipt.json"
|
||||||
|
cat > "$ROOT/bin/systemctl" <<'SH'
|
||||||
|
#!/bin/sh
|
||||||
|
printf '%s\n' "$*" >> "${SYSTEMCTL_LOG:?}"
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
chmod 0755 "$ROOT/bin/systemctl"
|
||||||
|
: > "$ROOT/systemctl.log"
|
||||||
|
|
||||||
|
SYSTEMCTL_LOG="$ROOT/systemctl.log" PATH="$ROOT/bin:/usr/bin:/bin" INSTALL_ROOT="$INSTALL_ROOT" STATE_ROOT="$STATE_ROOT" \
|
||||||
|
bash "$SOURCE_ROOT/scripts/rollback_aios_orchestrator_gateway.sh"
|
||||||
|
FIRST="$(readlink -f "$INSTALL_ROOT/current")"
|
||||||
|
SYSTEMCTL_LOG="$ROOT/systemctl.log" PATH="$ROOT/bin:/usr/bin:/bin" INSTALL_ROOT="$INSTALL_ROOT" STATE_ROOT="$STATE_ROOT" \
|
||||||
|
bash "$SOURCE_ROOT/scripts/rollback_aios_orchestrator_gateway.sh"
|
||||||
|
SECOND="$(readlink -f "$INSTALL_ROOT/current")"
|
||||||
|
[[ "$FIRST" == "$INSTALL_ROOT/releases/$OLD" ]]
|
||||||
|
[[ "$SECOND" == "$FIRST" ]]
|
||||||
|
grep -Fxq 'stop aios-orchestrator-gateway-poll.timer' "$ROOT/systemctl.log"
|
||||||
|
grep -Fxq 'stop aios-orchestrator-gateway-poll.service' "$ROOT/systemctl.log"
|
||||||
|
grep -Fxq 'start aios-orchestrator-gateway-poll.timer' "$ROOT/systemctl.log"
|
||||||
|
|
||||||
|
printf 'tampered\n' > "$INSTALL_ROOT/releases/$OLD/SOURCE_DIGEST"
|
||||||
|
if SYSTEMCTL_LOG="$ROOT/systemctl.log" PATH="$ROOT/bin:/usr/bin:/bin" \
|
||||||
|
INSTALL_ROOT="$INSTALL_ROOT" STATE_ROOT="$STATE_ROOT" \
|
||||||
|
bash "$SOURCE_ROOT/scripts/rollback_aios_orchestrator_gateway.sh" >/dev/null 2>&1; then
|
||||||
|
echo 'ROLLBACK_DIGEST_MISMATCH_TEST=FAIL' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "ROLLBACK_DIGEST_MISMATCH_TEST=PASS"
|
||||||
|
echo "ROLLBACK_REPEAT_TEST=PASS"
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# AIOS Central Orchestrator Workspace Agent
|
||||||
|
|
||||||
|
Contract commit: `cbe33ecab8e9794ba5f318bea43aeaf447a56fbc`
|
||||||
|
|
||||||
|
When an API-triggered input arrives:
|
||||||
|
|
||||||
|
1. Parse the AIOS event JSON from the trigger message.
|
||||||
|
2. Verify `contract_commit` exactly matches the configured contract commit.
|
||||||
|
3. Use connected GitOps tools to read the authoritative Git tree, runtime status,
|
||||||
|
gate state, test evidence and full OIDs.
|
||||||
|
4. Never invent evidence, credentials, secrets, tests, approvals, Git results,
|
||||||
|
model availability or deployment status.
|
||||||
|
5. Apply the adaptive dispatch contract:
|
||||||
|
- Prefer OpenClaw when quality is comparable, work is large/repetitive,
|
||||||
|
monitoring-heavy, infrastructure-heavy or cost-sensitive.
|
||||||
|
- Prefer the strongest actually available Codex model for complex code,
|
||||||
|
security boundaries, architecture and final high-risk review.
|
||||||
|
- On timeout/unavailability, mark the attempt `TIMEOUT` or `NOT_EXECUTED`
|
||||||
|
and assign the same contract to the fallback agent.
|
||||||
|
- Never infer PASS from timeout or missing output.
|
||||||
|
6. Every next work unit must include a clean base commit, allowed paths, required
|
||||||
|
checks, forbidden operations, primary agent and fallback agent.
|
||||||
|
7. Keep real credential, raw-wire, reviewer, secret-provider and owner-only
|
||||||
|
release gates fail-closed.
|
||||||
|
8. Submit exactly one strict decision through the
|
||||||
|
`submit_orchestration_decision` action.
|
||||||
|
9. Do not write a second decision for the same event ID.
|
||||||
|
10. Stop after the action succeeds.
|
||||||
|
|
||||||
|
The decision must satisfy the JSON schema in the connected action. Use the
|
||||||
|
incoming `event_id`, `project_key`, `contract_commit`, and `state_version`.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: AIOS Orchestrator Decision Callback
|
||||||
|
version: 1.0.0
|
||||||
|
servers:
|
||||||
|
- url: https://REPLACE_WITH_GATEWAY_HOST
|
||||||
|
paths:
|
||||||
|
/v1/orchestrator/decisions:
|
||||||
|
post:
|
||||||
|
operationId: submit_orchestration_decision
|
||||||
|
summary: Submit one strict orchestration decision for a triggered event
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Decision'
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: Accepted
|
||||||
|
'409':
|
||||||
|
description: Contract, idempotency, or state version conflict
|
||||||
|
'422':
|
||||||
|
description: Schema validation failed
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
schemas:
|
||||||
|
WorkUnit:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- track_id
|
||||||
|
- gate_id
|
||||||
|
- primary_agent
|
||||||
|
- fallback_agent
|
||||||
|
- clean_base_commit
|
||||||
|
- allowed_paths
|
||||||
|
- required_checks
|
||||||
|
- forbidden_operations
|
||||||
|
- instruction
|
||||||
|
properties:
|
||||||
|
id: {type: string}
|
||||||
|
track_id: {type: string}
|
||||||
|
gate_id: {type: string}
|
||||||
|
primary_agent:
|
||||||
|
type: string
|
||||||
|
enum: [openclaw, codex]
|
||||||
|
fallback_agent:
|
||||||
|
type: string
|
||||||
|
enum: [openclaw, codex]
|
||||||
|
clean_base_commit:
|
||||||
|
type: string
|
||||||
|
pattern: '^[0-9a-f]{40}$'
|
||||||
|
allowed_paths:
|
||||||
|
type: array
|
||||||
|
items: {type: string}
|
||||||
|
required_checks:
|
||||||
|
type: array
|
||||||
|
items: {type: string}
|
||||||
|
forbidden_operations:
|
||||||
|
type: array
|
||||||
|
items: {type: string}
|
||||||
|
instruction: {type: string}
|
||||||
|
Decision:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- schema_version
|
||||||
|
- project_key
|
||||||
|
- contract_commit
|
||||||
|
- event_id
|
||||||
|
- expected_state_version
|
||||||
|
- verdict
|
||||||
|
- state_updates
|
||||||
|
- next_work_units
|
||||||
|
- requires_human_input
|
||||||
|
- stop_reason
|
||||||
|
properties:
|
||||||
|
schema_version:
|
||||||
|
type: integer
|
||||||
|
const: 1
|
||||||
|
project_key: {type: string}
|
||||||
|
contract_commit:
|
||||||
|
type: string
|
||||||
|
pattern: '^[0-9a-f]{40}$'
|
||||||
|
event_id: {type: string}
|
||||||
|
expected_state_version:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
verdict:
|
||||||
|
type: string
|
||||||
|
enum: [DISPATCH, BLOCKED, COMPLETE, NOOP]
|
||||||
|
state_updates:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [key, value]
|
||||||
|
properties:
|
||||||
|
key: {type: string}
|
||||||
|
value: {type: string}
|
||||||
|
next_work_units:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/WorkUnit'
|
||||||
|
requires_human_input: {type: boolean}
|
||||||
|
stop_reason:
|
||||||
|
type: [string, 'null']
|
||||||
Reference in New Issue
Block a user