mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-22 23:09:48 +00:00
feat(managed-agent): Define the managed-extension-record/1 contract (H0b) (#12837)
Add the records that the Stage H capabilities share, before anything commits them: the OperationGrant and when a Runtime gate may replace one; the logical run, physical execution and delivery lines with their single steps and the rules that tie them together, as one run block that every Stage H record embeds; recovery and quota reasons; stable identities and the Runtime binding; the definitionRevision pin; and the monitor_run record body with its revision rule. One shared schema and fixture file pins the contract. A TypeScript module in core and a Java validator in managed-agent-server replay every case, and both check every pair of states on every line. monitor_run joins the closed v1 domain index but stays disabled for submission. Refs #12827
This commit is contained in:
parent
e0b8bea9e0
commit
e68822c815
10 changed files with 24348 additions and 2 deletions
279
docs/design/2026-09-27-managed-extension-record-contract.md
Normal file
279
docs/design/2026-09-27-managed-extension-record-contract.md
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
# Managed Extension Record Contract (H0b)
|
||||
|
||||
[English](2026-09-27-managed-extension-record-contract.md) | [简体中文](2026-09-27-managed-extension-record-contract.zh-CN.md)
|
||||
|
||||
Status: contract defined; nothing commits these records yet. Updated: 2026-09-27. This is slice H0b of [#12827](https://github.com/QwenLM/qwen-code/issues/12827), stage H of the Managed Agent proposal [#12380](https://github.com/QwenLM/qwen-code/issues/12380). Below, "the reference design" is sections 3, 10, 12 and 13 of the proposal's [extension runtime design](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-extension-runtime.md), with the `OperationGrant` of its [private control protocol](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-control-protocol.md) and the domain index of its [Session storage design](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-session-storage.md), at the commit that #12827 pins.
|
||||
|
||||
## Problem
|
||||
|
||||
Stage H brings seven capabilities onto the Managed path: MCP, Hooks, background Shell and Monitor, child agents, workflows and teams, Channels, and automation. On that path a Runtime can be reclaimed and a Harness replaced, so the reference design makes every asynchronous capability a durable resource plus a trigger intent, with stable identities, receipts and a single task projection. If each slice invented its own run states, maintenance grant, failure reasons and identity rules, Java, qwen and the Runtime would each read "unknown" or "settled" differently, and no projection could merge them.
|
||||
|
||||
H0b fixes the records the seven capabilities share before anything commits them, as the attestation, Tool v2, `managed-context/1` and `managed-tool-result/1` contracts did for their slices. H0c then commits them with the Session authority and rebuilds the task list from them.
|
||||
|
||||
## Current state
|
||||
|
||||
The facts below are from `main` at `88d881491b`.
|
||||
|
||||
- **Domain index.** `packages/core/src/managed-runtime/managed-session-records.ts` has a closed index of 32 domain names. `domain.committed` carries a `recordRef` of kind `managed-<domain>` and schema version 1. Only `goal_state`, `session_metadata`, `file_history` and `session_source` are enabled for submission. The storage design lists 33 names in v1; the missing one is `monitor_run`, whose validator it assigns to H0.
|
||||
- **Domain records.** `LocalManagedSessionAuthority.commitDomainRecord` publishes the record body with `operationId`, `revision` and `previousRecordRef` around the caller's content, and keeps one revision chain per domain.
|
||||
- **Fences.** `ManagedActivationFence` exists in the activation store, the Session inbox and the embedded Harness scheduler. The code has no `OperationGrant`, `WakeIntent`, outbox or `SessionTaskView`.
|
||||
- **Java.** The control plane's Session Store keeps the authority's journal as opaque transaction records and has no domain records. In the Runtime Broker, each `RuntimeBindingRecord` is one physical Runtime generation: a replacement gets a new binding ID and the next generation. The Broker's tool execution ledger has a line of the same kind, `PREPARED`, `DISPATCHING`, `EXECUTING`, `CANCEL_REQUESTED`, `SETTLED` and `UNKNOWN`, but not step for step: a cancelled `PREPARED` call settles as `not_started` without a dispatch, and `EXECUTING` is entered before the Runtime receives the call, so it corresponds to `dispatch_started`.
|
||||
- **Legacy Monitor.** `MonitorRegistry` keeps monitors in memory with the statuses `running`, `completed`, `failed` and `cancelled`. `MonitorTool` caps `max_events` at 10,000 and `idle_timeout_ms` at 600,000, and records why a monitor stopped: a spawn error; after it started, a non-zero exit, a signal, binary output or a stream error, which also end it as `failed`; a natural exit, max events or idle timeout; or a stop request.
|
||||
|
||||
## Goals
|
||||
|
||||
- Define `OperationGrant` and when a Runtime's per-operation gate may replace one grant with another.
|
||||
- Define the three state lines (logical run, physical execution, delivery and acceptance), their single steps, and the rules that tie them together, as one run block that every Stage H record embeds.
|
||||
- Define the recovery and quota reasons a run may carry.
|
||||
- Define the stable identities (`executionCallId` or `effectId`, `dispatchId`, `deliveryId`) and the Runtime binding, and how they may change.
|
||||
- Define the `definitionRevision` pin and its consistency rule.
|
||||
- Define the `monitor_run` record body and its revision rule, and register the domain without enabling it.
|
||||
- Pin all of it with one shared schema and fixture file that TypeScript and Java both replay.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Committing.** No authority commits these records, and there is no outbox, `WakeIntent` or `SessionTaskView` projection. H0c does that.
|
||||
- **Enablement.** `monitor_run` joins the index but stays disabled for submission; enabling a domain remains a separate, explicit step.
|
||||
- **Other bodies.** The record bodies of the other Stage H domains, and the phases each domain registers, belong to H1–H6.
|
||||
- **Public API.** The task resources and their errors are H0a.
|
||||
- **Runtime paths.** No Harness loop, Broker, worker or route changes.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **`monitor_run` joins the closed v1 index** (question 1 of #12827). The storage design at the pinned commit lists it among the 33 v1 names and leaves only its validator to H0. No v1 reader can meet a `monitor_run` record yet, because the domain stays disabled, so the index keeps its version. A reader from before this change refuses the name, so when H3 enables the domain, a Session that holds such records must keep those readers out, for example by raising its `minimumReader`. The name goes after `memory_job`, in the design's order.
|
||||
2. **One run block, embedded by each record.** The shared state lines, reasons, identities, binding and pin form a closed `run` object inside each domain record, not a domain of their own. H0c rebuilds `SessionTaskView` from these blocks.
|
||||
3. **The Java consumer lives in the control plane.** Section 1 of the reference design gives product records and the task projection to Java. Whichever side commits the records (question 2), Java reads them to build the projection, so `ManagedExtensionRecords` sits in `managed-agent-server` and reads Jackson trees like the rest of that module.
|
||||
4. **A revision takes one step per line.** A later revision may leave each state line where it was or take one allowed step; an execution enters at `intent` and a delivery at `planned`. Every step, and the evidence it rests on, is then committed before the next one acts. A rule that accepted any reachable state would let `unknown → sending` pass as `unknown → partial → sending` without the partial delivery ever being recorded, which is the resend the reference design forbids.
|
||||
|
||||
Questions 2 and 3 of #12827 concern H0c and are not decided here. For question 4, H0b changes no runtime path and can land ahead of the tool-capable Hosted turn.
|
||||
|
||||
## Value rules
|
||||
|
||||
| Rule | Values |
|
||||
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| id | The Managed Session stable ID rule: a non-empty string of at most 512 UTF-8 bytes, well-formed, in NFC, with no C0, DEL or C1 character. |
|
||||
| count | A JSON integer from 0 to 2^53−2, the Managed Session sequence rule. |
|
||||
| revision | A count from 1. |
|
||||
| time | UTC Unix milliseconds, a JSON integer from 0 to 8.64 × 10^15. |
|
||||
| generation | Canonical decimal text from 1 to 2^63−1, the W0a rule for `workspaceGeneration`. Generations are compared as numbers, never as text. |
|
||||
| digest | 64 lowercase hexadecimal characters. |
|
||||
| durable ref | The five `DurableRef` fields: `resourceId` and `kind` are ids, `schemaVersion` and `byteLength` are counts, `digest` is a digest. |
|
||||
| phase | `[a-z][a-z0-9_]{0,63}`, the form of the phase names in the recovery design, such as `send_segment`. |
|
||||
|
||||
Every record below is a closed JSON object: each key is required, and a key that may be empty holds `null`. A number with a zero fraction, such as `1.0`, counts as the integer it equals, as it does for JSON Schema's `integer` and for JavaScript; producers still write plain integers. Numbers are judged as the IEEE-754 doubles that a default JSON parser produces: JavaScript's, or Jackson's default mapper in Java; a number past the double range is no integer. NFC is judged with each runtime's Unicode data, and JDK 21 knows Unicode 15 while Node 22 knows a later version, so the two may disagree on a character assigned after Unicode 15; identifiers must stay within the characters Unicode 15 assigns.
|
||||
|
||||
## Operation grant
|
||||
|
||||
An `OperationGrant` lets the owner of an accepted operation finish its planned phases without a model activation: a Channel send after the turn ended, a configuration install, a history maintenance step, a Monitor rebuild. It never authorizes a model call or new business, and it is not a second Session epoch.
|
||||
|
||||
| Key | Rule |
|
||||
| --------------------- | ------------------------------------------------------------------------ |
|
||||
| `sessionKey` | `tenantId`, `workspaceId` and `sessionId`, each an id |
|
||||
| `operationId` | id |
|
||||
| `domain` | one of the 33 names of the v1 domain index |
|
||||
| `operationRevision` | revision: the authority's control version for this grant |
|
||||
| `ownerId` | id: the owner that holds the grant |
|
||||
| `workspaceGeneration` | generation |
|
||||
| `resourceScope` | `recordRef` and `phases`, below |
|
||||
| `leaseDurationMs` | 1,000 to 300,000, the bounds of the durable Session store's writer lease |
|
||||
| `expiresAt` | time |
|
||||
|
||||
- **Scope.** `recordRef` is the committed domain record that holds the operation's plan. Its kind must be `managed-<domain>` and its schema version 1, the pairing `domain.committed` already requires, so a grant cannot point at another domain's plan. `phases` lists the 1 to 16 distinct phases of that plan the grant admits. Which phases a domain has is registered with its body in H1–H6; H0b fixes their form.
|
||||
- **No activation fields.** The record is closed, so an `epoch` or `activationId` is refused: a grant is not an activation.
|
||||
- **Replacement.** A Runtime's per-operation gate may replace grant `a` with grant `b` only when both are valid, name the same Session, operation and domain, and either
|
||||
- `b` renews `a`: the same revision and every field equal, phases in the same order, except a later `expiresAt`; or
|
||||
- `b` is a later revision whose `workspaceGeneration` is not older than `a`'s. A later revision may change the owner, scope and lease.
|
||||
|
||||
An earlier revision is stale, and so is a renewal that does not extend the lease. An identical grant replaces nothing: installing it again is idempotent at the gate, which answers as it did before.
|
||||
|
||||
## State lines
|
||||
|
||||
A run is described by three separate lines. None may stand in for another: a settled run does not prove its process drained, an accepted result does not prove the model consumed it, and a completed model turn does not prove a Channel delivered its reply.
|
||||
|
||||
**Logical run** — whether the business was admitted and has ended.
|
||||
|
||||
| From | Allowed next states |
|
||||
| -------------------------------- | --------------------------------------------------------------- |
|
||||
| `reserved` | `admitted`, `failed`, `cancelled` |
|
||||
| `admitted` | `running`, `waiting`, `failed`, `cancelled`, `recovery_blocked` |
|
||||
| `running` | `waiting`, `settled`, `failed`, `cancelled`, `recovery_blocked` |
|
||||
| `waiting` | `running`, `settled`, `failed`, `cancelled`, `recovery_blocked` |
|
||||
| `recovery_blocked` | `running`, `waiting`, `settled`, `failed`, `cancelled` |
|
||||
| `settled`, `failed`, `cancelled` | none |
|
||||
|
||||
**Physical execution** — what a Runtime, process or remote call actually did.
|
||||
|
||||
| From | Allowed next states |
|
||||
| ------------------------------------------ | --------------------------------------------------------------------------------- |
|
||||
| `intent` | `dispatch_started`, `not_started_proven`, `outcome_unknown`, `corrupt` |
|
||||
| `dispatch_started` | `running_attached`, `settled`, `not_started_proven`, `outcome_unknown`, `corrupt` |
|
||||
| `running_attached` | `settled`, `outcome_unknown`, `corrupt` |
|
||||
| `outcome_unknown` | `running_attached`, `settled`, `not_started_proven`, `corrupt` |
|
||||
| `settled`, `not_started_proven`, `corrupt` | none |
|
||||
|
||||
A dispatch that the Runtime refuses before any side effect proves nothing started. An unknown outcome leaves `outcome_unknown` only on evidence: a re-attach, including a Monitor watch rebuilt under a later generation, the original system's result, or its proof that the work never ran. `corrupt` is final, so its run stays `recovery_blocked`: the reference design defines no way out, and a user can still query it, request its cancellation or close the Session.
|
||||
|
||||
**Delivery and acceptance** — whether a result reached a Channel or a parent Session, and whether the model consumed it.
|
||||
|
||||
| From | Allowed next states |
|
||||
| ------------------------------------------------ | ---------------------------------------------- |
|
||||
| `planned` | `sending`, `accepting`, `cancelled` |
|
||||
| `sending` | `delivered`, `partial`, `unknown`, `rejected` |
|
||||
| `partial` | `sending`, `unknown` |
|
||||
| `accepting` | `accepted`, `unknown`, `rejected` |
|
||||
| `accepted` | `consumed` |
|
||||
| `unknown` | `delivered`, `partial`, `accepted`, `rejected` |
|
||||
| `delivered`, `consumed`, `rejected`, `cancelled` | none |
|
||||
|
||||
A delivery has a target. A `channel` delivery uses `planned`, `sending`, `partial`, `delivered`, `unknown`, `rejected` and `cancelled`; a `session` delivery uses `planned`, `accepting`, `accepted`, `consumed`, `unknown`, `rejected` and `cancelled`. `consumed` belongs to acceptance only: a Channel has no model to consume a reply. An unknown delivery never returns to `sending` or `accepting`; a resend is a new delivery with a new `deliveryId`. `partial` returns to `sending` only to send the segments proven unsent.
|
||||
|
||||
## Run block
|
||||
|
||||
Every Stage H record embeds one closed `run` object.
|
||||
|
||||
| Key | Rule |
|
||||
| ----------------- | ----------------------------------------------------------------------- |
|
||||
| `state` | a logical run state |
|
||||
| `reason` | null, a recovery reason or a quota reason |
|
||||
| `definition` | null or a definition pin |
|
||||
| `executionCallId` | null or id: the `tool.intent` identity of a tool-started run |
|
||||
| `effectId` | null or id: the effect identity of a domain phase |
|
||||
| `dispatchId` | null or id: a dispatch to another Session |
|
||||
| `deliveryId` | null or id: an external delivery |
|
||||
| `execution` | null or a physical execution state |
|
||||
| `runtime` | null, or `runtimeBindingId` (id) and `generation` (generation) |
|
||||
| `delivery` | null, or `target` (`channel` or `session`) and a `state` of that target |
|
||||
|
||||
**Identities and binding.**
|
||||
|
||||
- A run names at most one physical identity: `executionCallId` or `effectId`, never both, and one of them whenever `execution` is set.
|
||||
- `runtime` requires an `execution`: a binding exists only for a physical execution. An execution without one runs in a domain adapter, such as a Channel sender.
|
||||
- `deliveryId` is set exactly when the delivery targets a Channel. A Session delivery needs no ID of its own: the run identifies it, with its `dispatchId` when it crosses Sessions.
|
||||
|
||||
**States.**
|
||||
|
||||
- A `reserved` run has no execution and no delivery: nothing is dispatched before admission.
|
||||
- While the execution is `outcome_unknown` or `corrupt`, the run is `recovery_blocked`, so an outcome that nobody can prove never passes for a result.
|
||||
- A `settled`, `failed` or `cancelled` run has no execution, or one proven to have ended: `settled` or `not_started_proven`. A `settled` run's execution cannot be `not_started_proven`.
|
||||
- A `session` delivery beyond `planned` or `cancelled` needs a run that ended: a parent accepts a result only after it exists.
|
||||
|
||||
**Reasons.**
|
||||
|
||||
| State | `reason` |
|
||||
| ---------------------------------------------- | ------------------------------------------------------------- |
|
||||
| `recovery_blocked` | a recovery reason, required |
|
||||
| `running`, `waiting` | null, or a recovery reason: the run goes on in a degraded way |
|
||||
| `failed` | null, or a quota reason |
|
||||
| `reserved`, `admitted`, `settled`, `cancelled` | null |
|
||||
|
||||
- `outcome_unknown` requires an execution in `outcome_unknown`; `execution_corrupt` is the reason exactly when the execution is `corrupt`; `runtime_lost` requires the `runtime` it lost, and a run blocked on it is not `running_attached`; `dispatch_unknown` requires a `dispatchId`.
|
||||
|
||||
**Revisions.** A later revision of the same run must satisfy all of the following:
|
||||
|
||||
- Each state line stays where it was or takes one allowed step. An execution first appears as `intent` and a delivery as `planned`; once present, neither goes back to null, and a delivery keeps its target.
|
||||
- `executionCallId`, `effectId`, `dispatchId`, `deliveryId` and `definition` may be set once and never change after that. Like `runtime`, `definition` may first appear only while the previous revision's execution is null or `intent`, so a run never executes under a definition it is pinned to later.
|
||||
- `runtime` is recorded with the dispatch: it may first appear only while the previous revision's execution is null or `intent`. It changes only when an `outcome_unknown` execution becomes `running_attached` again under a strictly later generation; the binding ID may then change too, since the Broker assigns one per generation.
|
||||
- A run that ended changes only its delivery, plus the `deliveryId` of a first Channel delivery, so a result can be handed on after the run settled.
|
||||
|
||||
## Reasons
|
||||
|
||||
| Recovery reason | Meaning |
|
||||
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `outcome_unknown` | The work was dispatched and its outcome cannot be proven. |
|
||||
| `execution_corrupt` | The physical ledger or receipt is corrupt. |
|
||||
| `runtime_lost` | The Runtime binding that owned the work was lost. A blocked run was not re-attached; a running or waiting run continues after a re-attach, and results from the gap may be missing. |
|
||||
| `dispatch_unknown` | A dispatch to another Session was sent and its admission is unknown. |
|
||||
| `handler_unavailable` | A registered handler, such as a function Hook, cannot be rebuilt. |
|
||||
|
||||
| Quota reason | Limits it covers in section 12 of the reference design |
|
||||
| ------------------ | ---------------------------------------------------------------------------------------------- |
|
||||
| `count_limit` | connections, bindings, in-flight operations, processes, monitors, concurrent children, backlog |
|
||||
| `rate_limit` | triggers per period, event rate |
|
||||
| `depth_limit` | child depth |
|
||||
| `byte_limit` | log bytes, staged bytes |
|
||||
| `budget_exhausted` | model and tool budgets |
|
||||
| `duration_limit` | Runtime duration |
|
||||
|
||||
A capacity refusal before admission creates no record; a quota reason explains a run that was admitted or reserved and then failed on a limit.
|
||||
|
||||
## Definition pin
|
||||
|
||||
A pin is `definitionId` (id), `definitionRevision` (revision) and `definitionDigest` (digest): the immutable definition a run actually uses, such as a schedule, Hook catalog, MCP server or AgentBundle revision. Two pins with the same `definitionId` and `definitionRevision` must have the same digest, because a revision never names two contents. A run is pinned no later than its dispatch and keeps its pin, so a definition update affects only later runs.
|
||||
|
||||
## Monitor run
|
||||
|
||||
A `monitor_run` record body, kind `managed-monitor_run` and schema version 1, holds exactly these keys, which cover the list in section 10 of the reference design. This is the content the domain record carries; `commitDomainRecord` currently adds `operationId`, `revision` and `previousRecordRef` around its content, and H0c decides how that envelope and the content are kept apart.
|
||||
|
||||
| Key | Rule |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `monitorId` | id |
|
||||
| `ownerScopeId` | id: the activation scope that owns the monitor and receives its notifications |
|
||||
| `commandRef` | durable ref: the start call's `argsRef`, which holds the command and its directory; its digest is the command/target digest |
|
||||
| `maxEvents` | 1 to 10,000 |
|
||||
| `idleTimeoutMs` | 1 to 600,000 |
|
||||
| `debounceMs` | 0 to 600,000: the window within which the Runtime aggregates observations; any filter is part of the command behind `commandRef` |
|
||||
| `startReceiptRef` | null or durable ref: the physical start receipt of the current watch |
|
||||
| `observationSequence` | a count up to `maxEvents`: accepted meaningful observations, not raw lines |
|
||||
| `lastObservationRef` | null or durable ref: the summary of the last observation |
|
||||
| `notifiedThrough` | a count up to `observationSequence`: the notification watermark |
|
||||
| `stopReason` | null, or why the monitor ended |
|
||||
| `outputRef` | null, or a `managed-tool-result-manifest` version 1 reference: the output Artifact |
|
||||
| `run` | the run block |
|
||||
|
||||
- **Run.** The run names the start call's `executionCallId` and nothing else: no `effectId`, `dispatchId`, `deliveryId`, `delivery` or `definition`. A Monitor notifies through its watermark, not through a delivery.
|
||||
- **Observations.** `lastObservationRef` is set exactly when `observationSequence` is above 0.
|
||||
- **Start receipt.** It is set once the watch has started: whenever there is an observation or the execution is `running_attached`. It is null while the execution is null, `intent`, `dispatch_started` or `not_started_proven`. A start receipt needs the run's Runtime binding, which issued it.
|
||||
- **Stop reason.** It is set exactly when the run ended, and must fit the state: `settled` by `exited`, `max_events` or `idle_timeout`; `failed` by `start_failed`, `watch_failed` or `quota_exceeded`; `cancelled` by `stop_requested`. A `settled` monitor's execution is `settled` and it has a start receipt, since each of its stop reasons needs a watch that ran. `max_events` requires `observationSequence` equal to `maxEvents`. `start_failed` requires an attempted execution and no start receipt; `watch_failed`, a watch that ran and then failed, requires a start receipt. `quota_exceeded` is the stop reason exactly when the run's reason is a quota reason.
|
||||
- **Revisions.** A later revision keeps `monitorId`, `ownerScopeId`, `commandRef`, `maxEvents`, `idleTimeoutMs` and `debounceMs`; its run is a successor; `observationSequence` and `notifiedThrough` never decrease; `observationSequence` grows only when the execution of either revision is `running_attached`, because a lost or ended watch observes nothing, and `lastObservationRef` changes only with a new observation; `outputRef` may change as the manifest gains revisions but is never removed; `startReceiptRef` is set once, a new Runtime binding must bring a new one, and nothing else changes it. Once the monitor ended, only `notifiedThrough` may still advance, so a final pending notification can still be delivered.
|
||||
- **Rebuild.** A watch whose Runtime was lost is `recovery_blocked` with `runtime_lost` and an `outcome_unknown` execution. A target that can be rebuilt from its durable definition returns to `running` and `running_attached` under a later generation, with a new start receipt, and continues from the committed watermark; the run may keep `runtime_lost` as its reason to show that observations during the gap may be missing. A target that cannot be rebuilt stays blocked. The rebuild is a maintenance phase under an `OperationGrant`: its intent and dispatch are committed in that phase's physical ledger, keyed by Session, effect, phase and effect revision, and the monitor record commits only the outcome, the attach under the new generation. If the rebuild's outcome is unknown, the monitor stays blocked and nothing starts another watch.
|
||||
|
||||
## Shared schema and fixtures
|
||||
|
||||
`packages/core/src/managed-runtime/contracts/managed-extension-record-v1.schema.json` and `.fixtures.json` hold the contract, beside the other Managed Runtime contracts.
|
||||
|
||||
- The domain index, limits, kinds, state lines, delivery targets, reasons and Monitor stop reasons, as constants.
|
||||
- A canonical grant, pin, run block and monitor run.
|
||||
- 520 cases: grants (121, one valid grant per domain among them), grant replacements (21), pins (25), pin pairs (7), run blocks (144), run revisions (57), monitor runs (107) and monitor revisions (38). Each invalid case is aimed at one rule.
|
||||
|
||||
The schema fixes each record's shape and every rule it can state readably, including all the state, reason and stop-reason rules. It cannot state UTF-8 byte limits, NFC, well-formed UTF-16, the 2^63−1 bound readably, a record kind derived from another field, or a bound that depends on another field; the TypeScript test lists the cases on which the schema and the module disagree. Its patterns follow ECMA-262, as draft 2020-12 specifies; a validator with other regular expression semantics, such as Java's default, may accept a trailing newline that both modules refuse. A Python implementation written from this document, independent of both languages and kept outside the repository as for `managed-tool-result/1`, labeled every case, and the generator stops when it disagrees with a label.
|
||||
|
||||
- **TypeScript.** `packages/core/src/managed-runtime/managed-extension-record.ts` parses grants, pins, run blocks and monitor runs, and checks grant replacements, pin pairs and revisions. Nothing imports it until H0c.
|
||||
- **Java.** `ManagedExtensionRecords` in `managed-agent-server` implements the same rules over Jackson trees. A test replays every case, checks every pair of states on every line, pins the constants, and validates the fixtures against the schema.
|
||||
|
||||
## Files affected
|
||||
|
||||
- `packages/core/src/managed-runtime/contracts/managed-extension-record-v1.schema.json` and `.fixtures.json` (new).
|
||||
- `packages/core/src/managed-runtime/managed-extension-record.ts` and its test (new).
|
||||
- `packages/core/src/managed-runtime/managed-session-records.ts` and its test: `monitor_run` joins the v1 index.
|
||||
- `ManagedExtensionRecords` and `ManagedExtensionRecordContractTest` in `packages/sdk-java/managed-agent-server` (new).
|
||||
- This design document in both languages (new).
|
||||
|
||||
No authority, store, Harness, Broker, worker, route or CI workflow changes.
|
||||
|
||||
## Validation plan
|
||||
|
||||
- **TypeScript:** strict Ajv validation of the fixtures against the schema; every case replayed through the module; every pair of states on every line checked against the fixture table; the schema checked against every record case.
|
||||
- **Java:** every case replayed through `ManagedExtensionRecords`; every pair of states; the constants pinned; the fixtures validated against the schema with the networknt validator.
|
||||
- **Mutation check:** each check of the TypeScript module is mutated in turn and the suite rerun.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- In both languages, every malformed shape and every illegal transition in the fixtures is refused, and every valid case is accepted.
|
||||
- The schema and the module disagree only on the listed rules that JSON Schema cannot state.
|
||||
- `monitor_run` is in the v1 domain index and is still refused for submission.
|
||||
- Every existing record, authority and store behavior is unchanged.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Phases.** A grant checks only the form of its phases. Should each H slice register its domain's phases in this contract, or with its own record body?
|
||||
2. **Degraded.** `SessionTaskView` has a `degraded` state. This contract lets a running or waiting run carry a recovery reason; should H0c project exactly that as `degraded`?
|
||||
3. **Revision chains.** `commitDomainRecord` keeps one revision chain per domain, but `monitor_run` has one record per monitor. H0c needs a chain per record, keyed by `monitorId` for Monitors, and has to keep the envelope it adds apart from the closed record body.
|
||||
4. **Corrupt executions.** A run whose execution is `corrupt` stays `recovery_blocked`. Should a later slice add an explicit operator command that closes such a run, and what evidence would it record?
|
||||
|
||||
## Follow-up work
|
||||
|
||||
| Slice | Scope |
|
||||
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| H0c | Commit domain records, the outbox and wake intents with the Session authority; issue and install grants; rebuild `SessionTaskView` from the run blocks after a restart. |
|
||||
| H3 | Enable `monitor_run` and background Shell under their own admission, receipt, cancellation, quota and recovery checks, keeping readers from before H0b away from Sessions that hold them. |
|
||||
| H1–H6 | The other domain bodies and their phases, each embedding the run block. |
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
# Managed 扩展记录契约(H0b)
|
||||
|
||||
[English](2026-09-27-managed-extension-record-contract.md) | [简体中文](2026-09-27-managed-extension-record-contract.zh-CN.md)
|
||||
|
||||
状态:契约已定义;尚无任何组件提交这些记录。更新日期:2026-09-27。本文是 [#12827](https://github.com/QwenLM/qwen-code/issues/12827) 的 H0b 切片,属于 Managed Agent 提案 [#12380](https://github.com/QwenLM/qwen-code/issues/12380) 的 H 阶段。下文的"参考设计"指该提案[扩展运行时设计](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-extension-runtime.md)的第 3、10、12、13 节,并包括其[私有控制协议](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-control-protocol.md)中的 `OperationGrant` 与[Session 存储设计](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-session-storage.md)中的 domain 索引,版本为 #12827 固定的提交。
|
||||
|
||||
## 问题
|
||||
|
||||
H 阶段把七项能力接入 Managed 路径:MCP、Hooks、后台 Shell 与 Monitor、子 Agent/workflow/team、Channels 以及自动化。在这条路径上 Runtime 可能被回收,Harness 也可能被替换,因此参考设计要求每项异步能力都是"持久资源加触发意图",具备稳定身份、回执和统一的任务投影。如果每个切片各自定义运行状态、维护授权、失败原因和身份规则,Java、qwen 与 Runtime 对"unknown"或"settled"的理解就会各不相同,任何投影都无法把它们合并。
|
||||
|
||||
与 attestation、Tool v2、`managed-context/1` 和 `managed-tool-result/1` 契约一样,H0b 在任何组件提交这些记录之前,先定下七项能力共用的记录。之后由 H0c 随 Session authority 提交它们,并据此重建任务列表。
|
||||
|
||||
## 现状
|
||||
|
||||
以下事实基于 `main` 的 `88d881491b`。
|
||||
|
||||
- **domain 索引。** `packages/core/src/managed-runtime/managed-session-records.ts` 有一份包含 32 个名称的封闭 domain 索引。`domain.committed` 携带 kind 为 `managed-<domain>`、schema 版本为 1 的 `recordRef`。只有 `goal_state`、`session_metadata`、`file_history`、`session_source` 开放提交。存储设计的 v1 列出了 33 个名称,缺少的是 `monitor_run`,其 validator 被划给 H0。
|
||||
- **domain 记录。** `LocalManagedSessionAuthority.commitDomainRecord` 发布记录正文时在调用方内容外加上 `operationId`、`revision` 和 `previousRecordRef`,并且每个 domain 只保留一条修订链。
|
||||
- **fence。** `ManagedActivationFence` 已存在于 activation store、Session inbox 和内嵌 Harness 调度器中。代码中没有 `OperationGrant`、`WakeIntent`、outbox 或 `SessionTaskView`。
|
||||
- **Java。** 控制面的 Session Store 把 authority 日志作为不透明的事务记录保存,没有 domain 记录。在 Runtime Broker 中,每个 `RuntimeBindingRecord` 对应一个物理 Runtime 代:替换时获得新的 binding ID 和下一代 generation。Broker 的工具执行账本有一条同类的状态线:`PREPARED`、`DISPATCHING`、`EXECUTING`、`CANCEL_REQUESTED`、`SETTLED` 和 `UNKNOWN`,但并非逐步对应:被取消的 `PREPARED` 调用不经派发即以 `not_started` settled,而 `EXECUTING` 在 Runtime 收到调用之前就已进入,因此它对应 `dispatch_started`。
|
||||
- **Legacy Monitor。** `MonitorRegistry` 在内存中保存 monitor,状态为 `running`、`completed`、`failed`、`cancelled`。`MonitorTool` 把 `max_events` 上限定为 10,000、`idle_timeout_ms` 上限定为 600,000,并记录 monitor 停止的原因:spawn 错误;启动之后的非零退出码、信号、二进制输出或流错误,这些同样以 `failed` 结束;自然退出、达到最大事件数或空闲超时;或被请求停止。
|
||||
|
||||
## 目标
|
||||
|
||||
- 定义 `OperationGrant`,以及 Runtime 的按操作门禁何时可以用一个 grant 替换另一个。
|
||||
- 定义三条状态线(逻辑运行、物理执行、交付与接收)、各自允许的单步迁移以及把它们联系起来的规则,作为每条 H 阶段记录都内嵌的一个运行块。
|
||||
- 定义运行可以携带的恢复原因与配额原因。
|
||||
- 定义稳定身份(`executionCallId` 或 `effectId`、`dispatchId`、`deliveryId`)与 Runtime 绑定,以及它们可以如何变化。
|
||||
- 定义 `definitionRevision` 固定及其一致性规则。
|
||||
- 定义 `monitor_run` 的记录正文及其修订规则,并登记该 domain 但不开放提交。
|
||||
- 用一份 TypeScript 与 Java 都回放的共享 schema 和 fixture 文件固定上述全部内容。
|
||||
|
||||
## 非目标
|
||||
|
||||
- **提交。** 没有 authority 提交这些记录,也没有 outbox、`WakeIntent` 或 `SessionTaskView` 投影。这些由 H0c 完成。
|
||||
- **开放提交。** `monitor_run` 加入索引但仍不开放提交;开放某个 domain 的提交仍是单独、显式的一步。
|
||||
- **其他正文。** 其他 H 阶段 domain 的记录正文,以及各 domain 登记的 phase,属于 H1–H6。
|
||||
- **公开 API。** 任务资源及其错误属于 H0a。
|
||||
- **运行路径。** 不改动 Harness 循环、Broker、worker 或路由。
|
||||
|
||||
## 决策
|
||||
|
||||
1. **`monitor_run` 加入封闭的 v1 索引**(#12827 的问题 1)。在固定提交的存储设计中,它是 33 个 v1 名称之一,只把它的 validator 留给 H0。由于该 domain 仍不开放提交,任何 v1 reader 目前都不会遇到 `monitor_run` 记录,因此索引保持原版本。本变更之前的 reader 会拒绝这个名称,因此 H3 开放该 domain 时,含有这类记录的 Session 必须把这些 reader 挡在外面,例如提高其 `minimumReader`。该名称按设计中的顺序放在 `memory_job` 之后。
|
||||
2. **一个运行块,由每条记录内嵌。** 共用的状态线、原因、身份、绑定和固定组成每条 domain 记录内的一个封闭 `run` 对象,而不是单独的 domain。H0c 据这些运行块重建 `SessionTaskView`。
|
||||
3. **Java 消费方位于控制面。** 参考设计第 1 节把产品级记录和任务投影交给 Java。无论由哪一侧提交记录(问题 2),Java 都要读取它们来构建投影,因此 `ManagedExtensionRecords` 放在 `managed-agent-server` 中,并像该模块其他代码一样读取 Jackson 树。
|
||||
4. **每次修订每条线最多前进一步。** 后一次修订可以让每条状态线保持不变,或前进一个允许的单步;执行线从 `intent` 进入,交付线从 `planned` 进入。这样每一步及其依据的证据都在下一步行动前已经提交。若接受任何可达状态,`unknown → sending` 就能以 `unknown → partial → sending` 的名义通过,而部分交付从未被记录,这正是参考设计禁止的重发。
|
||||
|
||||
本文不决定 #12827 的问题 2 和 3,它们关乎 H0c。对于问题 4,H0b 不改变任何运行路径,可以先于支持工具的 Hosted 回合合入。
|
||||
|
||||
## 取值规则
|
||||
|
||||
| 规则 | 取值 |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| id | Managed Session 稳定 ID 规则:非空字符串,至多 512 个 UTF-8 字节,格式良好,采用 NFC,不含 C0、DEL 或 C1 字符。 |
|
||||
| count | 0 到 2^53−2 的 JSON 整数,即 Managed Session 序号规则。 |
|
||||
| revision | 从 1 开始的 count。 |
|
||||
| time | UTC Unix 毫秒,0 到 8.64 × 10^15 的 JSON 整数。 |
|
||||
| generation | 1 到 2^63−1 的规范十进制文本,即 W0a 对 `workspaceGeneration` 的规则。generation 按数值比较,从不按文本比较。 |
|
||||
| digest | 64 个小写十六进制字符。 |
|
||||
| durable ref | `DurableRef` 的五个字段:`resourceId` 与 `kind` 为 id,`schemaVersion` 与 `byteLength` 为 count,`digest` 为 digest。 |
|
||||
| phase | `[a-z][a-z0-9_]{0,63}`,即恢复设计中 phase 名称的形式,例如 `send_segment`。 |
|
||||
|
||||
下面的每条记录都是封闭的 JSON 对象:每个键都必需,可以为空的键取值 `null`。小数部分为零的数字(例如 `1.0`)视为与之相等的整数,这与 JSON Schema 的 `integer` 和 JavaScript 的处理一致;生产方仍写普通整数。数字按默认 JSON 解析器得到的 IEEE-754 double 判断:即 JavaScript 的解析器,或 Java 中 Jackson 的默认 mapper;超出 double 范围的数字不是整数。NFC 按各运行时自带的 Unicode 数据判断,JDK 21 支持 Unicode 15,Node 22 支持更新的版本,因此对 Unicode 15 之后才分配的字符两者可能结论不同;标识符必须限于 Unicode 15 已分配的字符。
|
||||
|
||||
## 操作授权
|
||||
|
||||
`OperationGrant` 让已受理操作的 owner 在没有模型 activation 的情况下完成计划中的 phase:回合结束后的 Channel 发送、配置安装、历史维护步骤、Monitor 重建。它从不授权模型调用或新业务,也不是第二个 Session epoch。
|
||||
|
||||
| 键 | 规则 |
|
||||
| --------------------- | ---------------------------------------------------- |
|
||||
| `sessionKey` | `tenantId`、`workspaceId`、`sessionId`,均为 id |
|
||||
| `operationId` | id |
|
||||
| `domain` | v1 domain 索引中的 33 个名称之一 |
|
||||
| `operationRevision` | revision:authority 对该 grant 的控制版本 |
|
||||
| `ownerId` | id:持有该 grant 的 owner |
|
||||
| `workspaceGeneration` | generation |
|
||||
| `resourceScope` | `recordRef` 与 `phases`,见下文 |
|
||||
| `leaseDurationMs` | 1,000 到 300,000,即持久 Session 存储写者租约的上下界 |
|
||||
| `expiresAt` | time |
|
||||
|
||||
- **范围。** `recordRef` 是保存该操作计划的已提交 domain 记录。其 kind 必须是 `managed-<domain>`、schema 版本必须为 1,这与 `domain.committed` 已有的配对要求相同,因此 grant 不能指向其他 domain 的计划。`phases` 列出该 grant 准入的、该计划中 1 到 16 个互不相同的 phase。一个 domain 有哪些 phase,在 H1–H6 中随其正文登记;H0b 只固定它们的形式。
|
||||
- **没有 activation 字段。** 记录是封闭的,因此 `epoch` 或 `activationId` 会被拒绝:grant 不是 activation。
|
||||
- **替换。** Runtime 的按操作门禁只有在以下条件下才可以用 grant `b` 替换 grant `a`:两者都有效,指向同一个 Session、操作和 domain,并且满足其一:
|
||||
- `b` 续租 `a`:修订号相同,除更晚的 `expiresAt` 外所有字段都相同,phase 顺序也相同;或
|
||||
- `b` 是更晚的修订,其 `workspaceGeneration` 不比 `a` 的旧。更晚的修订可以更换 owner、范围和租约。
|
||||
|
||||
更早的修订已经过期,没有延长租约的续租同样无效。完全相同的 grant 不替换任何东西:在门禁上重复安装是幂等的,门禁给出与之前相同的回答。
|
||||
|
||||
## 状态线
|
||||
|
||||
一次运行由三条相互独立的线描述,任何一条都不能冒充另一条:运行已 settled 不能证明其进程已排空,结果已被接收不能证明模型已消费,模型回合已完成不能证明 Channel 已送达回复。
|
||||
|
||||
**逻辑运行**:业务是否已准入、是否已结束。
|
||||
|
||||
| 当前状态 | 允许的下一状态 |
|
||||
| -------------------------------- | --------------------------------------------------------------- |
|
||||
| `reserved` | `admitted`、`failed`、`cancelled` |
|
||||
| `admitted` | `running`、`waiting`、`failed`、`cancelled`、`recovery_blocked` |
|
||||
| `running` | `waiting`、`settled`、`failed`、`cancelled`、`recovery_blocked` |
|
||||
| `waiting` | `running`、`settled`、`failed`、`cancelled`、`recovery_blocked` |
|
||||
| `recovery_blocked` | `running`、`waiting`、`settled`、`failed`、`cancelled` |
|
||||
| `settled`、`failed`、`cancelled` | 无 |
|
||||
|
||||
**物理执行**:Runtime、进程或远端调用实际发生了什么。
|
||||
|
||||
| 当前状态 | 允许的下一状态 |
|
||||
| ------------------------------------------ | --------------------------------------------------------------------------------- |
|
||||
| `intent` | `dispatch_started`、`not_started_proven`、`outcome_unknown`、`corrupt` |
|
||||
| `dispatch_started` | `running_attached`、`settled`、`not_started_proven`、`outcome_unknown`、`corrupt` |
|
||||
| `running_attached` | `settled`、`outcome_unknown`、`corrupt` |
|
||||
| `outcome_unknown` | `running_attached`、`settled`、`not_started_proven`、`corrupt` |
|
||||
| `settled`、`not_started_proven`、`corrupt` | 无 |
|
||||
|
||||
Runtime 在任何副作用之前拒绝的派发,证明没有开始执行。未知结果只能依据证据离开 `outcome_unknown`:重新 attach(包括在更晚 generation 下重建的 Monitor watch)、原系统的结果,或原系统关于该工作从未执行的证明。`corrupt` 是终态,因此其运行保持 `recovery_blocked`:参考设计没有定义出口,用户仍可查询它、请求取消或关闭 Session。
|
||||
|
||||
**交付与接收**:结果是否到达 Channel 或父 Session,以及是否被模型消费。
|
||||
|
||||
| 当前状态 | 允许的下一状态 |
|
||||
| ------------------------------------------------ | ---------------------------------------------- |
|
||||
| `planned` | `sending`、`accepting`、`cancelled` |
|
||||
| `sending` | `delivered`、`partial`、`unknown`、`rejected` |
|
||||
| `partial` | `sending`、`unknown` |
|
||||
| `accepting` | `accepted`、`unknown`、`rejected` |
|
||||
| `accepted` | `consumed` |
|
||||
| `unknown` | `delivered`、`partial`、`accepted`、`rejected` |
|
||||
| `delivered`、`consumed`、`rejected`、`cancelled` | 无 |
|
||||
|
||||
交付有一个目标。`channel` 交付使用 `planned`、`sending`、`partial`、`delivered`、`unknown`、`rejected`、`cancelled`;`session` 交付使用 `planned`、`accepting`、`accepted`、`consumed`、`unknown`、`rejected`、`cancelled`。`consumed` 只属于接收:Channel 没有模型来消费回复。未知的交付永远不会回到 `sending` 或 `accepting`;重发是带新 `deliveryId` 的新交付。`partial` 回到 `sending` 只是为了发送已证明未发出的分段。
|
||||
|
||||
## 运行块
|
||||
|
||||
每条 H 阶段记录都内嵌一个封闭的 `run` 对象。
|
||||
|
||||
| 键 | 规则 |
|
||||
| ----------------- | -------------------------------------------------------------- |
|
||||
| `state` | 一个逻辑运行状态 |
|
||||
| `reason` | null、一个恢复原因或一个配额原因 |
|
||||
| `definition` | null 或一个定义固定 |
|
||||
| `executionCallId` | null 或 id:由工具启动的运行在 `tool.intent` 中的身份 |
|
||||
| `effectId` | null 或 id:domain phase 的效果身份 |
|
||||
| `dispatchId` | null 或 id:派发到另一个 Session |
|
||||
| `deliveryId` | null 或 id:一次外部交付 |
|
||||
| `execution` | null 或一个物理执行状态 |
|
||||
| `runtime` | null,或 `runtimeBindingId`(id)与 `generation`(generation) |
|
||||
| `delivery` | null,或 `target`(`channel` 或 `session`)与该目标的一个 `state` |
|
||||
|
||||
**身份与绑定。**
|
||||
|
||||
- 一次运行至多有一个物理身份:`executionCallId` 或 `effectId`,两者不能同时存在;只要 `execution` 有值,就必须有其中之一。
|
||||
- `runtime` 要求有 `execution`:只有物理执行才有绑定。没有绑定的执行运行在 domain 适配器中,例如 Channel 发送方。
|
||||
- `deliveryId` 恰好在交付目标为 Channel 时设置。Session 交付不需要自己的 ID:运行标识它,跨 Session 时再加上其 `dispatchId`。
|
||||
|
||||
**状态。**
|
||||
|
||||
- `reserved` 的运行没有执行也没有交付:准入之前不派发任何东西。
|
||||
- 执行处于 `outcome_unknown` 或 `corrupt` 时,运行必须是 `recovery_blocked`,因此无法证明的结果永远不会被当作结果。
|
||||
- `settled`、`failed` 或 `cancelled` 的运行没有执行,或其执行已被证明结束:`settled` 或 `not_started_proven`。`settled` 的运行,其执行不能是 `not_started_proven`。
|
||||
- `session` 交付一旦越过 `planned` 或 `cancelled`,运行就必须已经结束:父方只有在结果存在之后才能接收它。
|
||||
|
||||
**原因。**
|
||||
|
||||
| 状态 | `reason` |
|
||||
| ---------------------------------------------- | ---------------------------------- |
|
||||
| `recovery_blocked` | 必须是恢复原因 |
|
||||
| `running`、`waiting` | null,或恢复原因:运行以降级方式继续 |
|
||||
| `failed` | null,或配额原因 |
|
||||
| `reserved`、`admitted`、`settled`、`cancelled` | null |
|
||||
|
||||
- `outcome_unknown` 要求执行处于 `outcome_unknown`;原因恰好在执行为 `corrupt` 时是 `execution_corrupt`;`runtime_lost` 要求有它失去的 `runtime`,因它而阻塞的运行不能是 `running_attached`;`dispatch_unknown` 要求有 `dispatchId`。
|
||||
|
||||
**修订。** 同一运行的后一次修订必须满足以下全部条件:
|
||||
|
||||
- 每条状态线保持不变或前进一个允许的单步。执行首次出现时为 `intent`,交付首次出现时为 `planned`;一旦出现,两者都不能回到 null,交付也保持其目标。
|
||||
- `executionCallId`、`effectId`、`dispatchId`、`deliveryId` 和 `definition` 可以设置一次,之后不再改变。与 `runtime` 一样,`definition` 只有在前一次修订的执行为 null 或 `intent` 时才可以首次出现,因此运行不会在一个事后才固定的定义下执行。
|
||||
- `runtime` 随派发记录:只有在前一次修订的执行为 null 或 `intent` 时,它才可以首次出现。只有当 `outcome_unknown` 的执行在严格更晚的 generation 下重新变为 `running_attached` 时,它才会改变;此时 binding ID 也可以改变,因为 Broker 为每一代分配一个 binding ID。
|
||||
- 已结束的运行只能改变其交付,外加首个 Channel 交付的 `deliveryId`,以便运行 settled 之后仍能转交结果。
|
||||
|
||||
## 原因
|
||||
|
||||
| 恢复原因 | 含义 |
|
||||
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `outcome_unknown` | 工作已派发,其结果无法证明。 |
|
||||
| `execution_corrupt` | 物理账本或回执已损坏。 |
|
||||
| `runtime_lost` | 拥有该工作的 Runtime 绑定已丢失。阻塞的运行未能重新 attach;运行中或等待中的运行在重新 attach 后继续,间隔期间的结果可能缺失。 |
|
||||
| `dispatch_unknown` | 已向另一个 Session 发出派发,其准入情况未知。 |
|
||||
| `handler_unavailable` | 已登记的处理器(例如 function Hook)无法重建。 |
|
||||
|
||||
| 配额原因 | 对应参考设计第 12 节中的限额 |
|
||||
| ------------------ | ----------------------------------------------------- |
|
||||
| `count_limit` | 连接、绑定、在途操作、进程、monitor、并发子任务、积压 |
|
||||
| `rate_limit` | 每时段触发次数、事件速率 |
|
||||
| `depth_limit` | 子任务深度 |
|
||||
| `byte_limit` | 日志字节、暂存字节 |
|
||||
| `budget_exhausted` | 模型与工具预算 |
|
||||
| `duration_limit` | Runtime 时长 |
|
||||
|
||||
准入之前的容量拒绝不会产生记录;配额原因用来说明一次已准入或已预留、随后因限额而失败的运行。
|
||||
|
||||
## 定义固定
|
||||
|
||||
定义固定由 `definitionId`(id)、`definitionRevision`(revision)和 `definitionDigest`(digest)组成:运行实际使用的不可变定义,例如某个 schedule、Hook catalog、MCP server 或 AgentBundle 的修订。`definitionId` 与 `definitionRevision` 都相同的两个固定必须有相同的 digest,因为一个修订永远不会对应两份内容。运行最迟在派发时固定定义,并保持不变,因此定义更新只影响之后的运行。
|
||||
|
||||
## Monitor 运行
|
||||
|
||||
`monitor_run` 记录正文的 kind 为 `managed-monitor_run`、schema 版本为 1,恰好包含下列键,覆盖参考设计第 10 节列出的内容。这是 domain 记录携带的内容;`commitDomainRecord` 目前会在内容外加上 `operationId`、`revision` 和 `previousRecordRef`,由 H0c 决定如何把这层封装与内容分开。
|
||||
|
||||
| 键 | 规则 |
|
||||
| --------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `monitorId` | id |
|
||||
| `ownerScopeId` | id:拥有该 monitor 并接收其通知的 activation 作用域 |
|
||||
| `commandRef` | durable ref:启动调用的 `argsRef`,其中保存命令及其目录;其 digest 即命令/目标 digest |
|
||||
| `maxEvents` | 1 到 10,000 |
|
||||
| `idleTimeoutMs` | 1 到 600,000 |
|
||||
| `debounceMs` | 0 到 600,000:Runtime 聚合观测的时间窗口;过滤逻辑属于 `commandRef` 指向的命令 |
|
||||
| `startReceiptRef` | null 或 durable ref:当前 watch 的物理启动回执 |
|
||||
| `observationSequence` | 不超过 `maxEvents` 的 count:已接受的有意义观测数,而不是原始行数 |
|
||||
| `lastObservationRef` | null 或 durable ref:最后一次观测的摘要 |
|
||||
| `notifiedThrough` | 不超过 `observationSequence` 的 count:通知水位 |
|
||||
| `stopReason` | null,或 monitor 结束的原因 |
|
||||
| `outputRef` | null,或 `managed-tool-result-manifest` 版本 1 的引用:输出 Artifact |
|
||||
| `run` | 运行块 |
|
||||
|
||||
- **运行。** 运行只指明启动调用的 `executionCallId`:没有 `effectId`、`dispatchId`、`deliveryId`、`delivery` 或 `definition`。Monitor 通过其水位通知,而不是通过交付。
|
||||
- **观测。** `lastObservationRef` 恰好在 `observationSequence` 大于 0 时设置。
|
||||
- **启动回执。** watch 启动后即设置:只要有观测,或执行处于 `running_attached`。执行为 null、`intent`、`dispatch_started` 或 `not_started_proven` 时,它为 null。启动回执需要签发它的运行 Runtime 绑定。
|
||||
- **停止原因。** 恰好在运行结束时设置,并且必须与状态相符:`settled` 对应 `exited`、`max_events` 或 `idle_timeout`;`failed` 对应 `start_failed`、`watch_failed` 或 `quota_exceeded`;`cancelled` 对应 `stop_requested`。`settled` 的 monitor,其执行为 `settled` 且有启动回执,因为它的每个停止原因都需要一个运行过的 watch。`max_events` 要求 `observationSequence` 等于 `maxEvents`。`start_failed` 要求执行已被尝试且没有启动回执;`watch_failed` 表示 watch 运行后失败,要求有启动回执。停止原因恰好在运行原因是配额原因时为 `quota_exceeded`。
|
||||
- **修订。** 后一次修订保持 `monitorId`、`ownerScopeId`、`commandRef`、`maxEvents`、`idleTimeoutMs` 和 `debounceMs` 不变;其运行块是前一个的后继;`observationSequence` 与 `notifiedThrough` 从不减少;只有当前后任一修订的执行为 `running_attached` 时 `observationSequence` 才能增长,因为丢失或已结束的 watch 不会产生观测;`lastObservationRef` 只随新观测改变;`outputRef` 可以随 manifest 的新修订改变,但从不被移除;`startReceiptRef` 只设置一次,新的 Runtime 绑定必须带来新的启动回执,除此之外它不会改变。monitor 结束后只有 `notifiedThrough` 还能前进,以便最后一条待发通知仍能送达。
|
||||
- **重建。** Runtime 丢失的 watch 处于 `recovery_blocked`,原因为 `runtime_lost`,执行为 `outcome_unknown`。能够从持久定义重建的目标会在更晚的 generation 下回到 `running` 与 `running_attached`,获得新的启动回执,并从已提交的水位继续;运行可以保留 `runtime_lost` 作为原因,表明间隔期间的观测可能缺失。无法重建的目标保持阻塞。重建是 `OperationGrant` 下的维护 phase:其 intent 与派发提交在该 phase 的物理账本中,以 Session、效果、phase 和效果修订为键,monitor 记录只提交结果,即在新 generation 下的 attach。如果重建的结果未知,monitor 保持阻塞,也不会再启动另一个 watch。
|
||||
|
||||
## 共享 schema 与 fixtures
|
||||
|
||||
`packages/core/src/managed-runtime/contracts/managed-extension-record-v1.schema.json` 和 `.fixtures.json` 保存本契约,与其他 Managed Runtime 契约放在一起。
|
||||
|
||||
- domain 索引、限额、kind、状态线、交付目标、原因和 Monitor 停止原因,作为常量。
|
||||
- 一个规范的 grant、定义固定、运行块和 monitor 运行。
|
||||
- 520 个用例:grant(121,其中每个 domain 各有一个有效 grant)、grant 替换(21)、定义固定(25)、定义固定对(7)、运行块(144)、运行修订(57)、monitor 运行(107)和 monitor 修订(38)。每个无效用例都针对一条规则。
|
||||
|
||||
schema 固定每条记录的结构,以及它能清晰表达的所有规则,包括全部状态、原因和停止原因规则。它无法表达 UTF-8 字节上限、NFC、格式良好的 UTF-16、可读的 2^63−1 上界、由另一字段推导出的记录 kind,或依赖另一字段的上界;TypeScript 测试列出 schema 与模块结论不同的用例。它的正则表达式遵循 ECMA-262,这是 draft 2020-12 的规定;采用其他正则语义的 validator(例如 Java 的默认实现)可能接受末尾的换行,而两个模块都会拒绝。一个依据本文编写、独立于两种语言的 Python 实现为每个用例标注了结论,它与 `managed-tool-result/1` 的做法一样放在仓库之外,生成器在它与标注不一致时停止。
|
||||
|
||||
- **TypeScript。** `packages/core/src/managed-runtime/managed-extension-record.ts` 解析 grant、定义固定、运行块与 monitor 运行,并检查 grant 替换、定义固定对和修订。在 H0c 之前没有代码导入它。
|
||||
- **Java。** `managed-agent-server` 中的 `ManagedExtensionRecords` 在 Jackson 树上实现同样的规则。一个测试回放每个用例,检查每条线上的每一对状态,固定常量,并用 schema 校验 fixtures。
|
||||
|
||||
## 受影响的文件
|
||||
|
||||
- `packages/core/src/managed-runtime/contracts/managed-extension-record-v1.schema.json` 和 `.fixtures.json`(新增)。
|
||||
- `packages/core/src/managed-runtime/managed-extension-record.ts` 及其测试(新增)。
|
||||
- `packages/core/src/managed-runtime/managed-session-records.ts` 及其测试:`monitor_run` 加入 v1 索引。
|
||||
- `packages/sdk-java/managed-agent-server` 中的 `ManagedExtensionRecords` 与 `ManagedExtensionRecordContractTest`(新增)。
|
||||
- 本设计文档的两种语言版本(新增)。
|
||||
|
||||
authority、存储、Harness、Broker、worker、路由和 CI workflow 均无改动。
|
||||
|
||||
## 验证计划
|
||||
|
||||
- **TypeScript:** 用严格模式的 Ajv 按 schema 校验 fixtures;每个用例通过模块回放;每条线上的每一对状态都与 fixture 中的迁移表比对;schema 与每个记录用例比对。
|
||||
- **Java:** 每个用例通过 `ManagedExtensionRecords` 回放;检查每一对状态;固定常量;用 networknt validator 按 schema 校验 fixtures。
|
||||
- **变异检查:** 依次变异 TypeScript 模块中的每项检查并重跑测试。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 在两种语言中,fixtures 里的每种畸形结构和每个非法迁移都被拒绝,每个有效用例都被接受。
|
||||
- schema 与模块只在列出的、JSON Schema 无法表达的规则上结论不同。
|
||||
- `monitor_run` 位于 v1 domain 索引中,且提交时仍被拒绝。
|
||||
- 现有的记录、authority 与存储行为都不变。
|
||||
|
||||
## 开放问题
|
||||
|
||||
1. **phase。** grant 只检查 phase 的形式。各 H 切片应在本契约中登记其 domain 的 phase,还是随各自的记录正文登记?
|
||||
2. **降级。** `SessionTaskView` 有 `degraded` 状态。本契约允许运行中或等待中的运行携带恢复原因;H0c 是否应恰好把这种情况投影为 `degraded`?
|
||||
3. **修订链。** `commitDomainRecord` 每个 domain 只保留一条修订链,而 `monitor_run` 每个 monitor 一条记录。H0c 需要按记录维护修订链,Monitor 按 `monitorId` 区分,并且要把它加上的封装与封闭的记录正文分开。
|
||||
4. **损坏的执行。** 执行为 `corrupt` 的运行保持 `recovery_blocked`。后续切片是否应增加一个显式的运维命令来关闭这类运行,它又应记录什么证据?
|
||||
|
||||
## 后续工作
|
||||
|
||||
| 切片 | 范围 |
|
||||
| ----- | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| H0c | 随 Session authority 提交 domain 记录、outbox 与唤醒意图;签发并安装 grant;重启后据运行块重建 `SessionTaskView`。 |
|
||||
| H3 | 在各自的准入、回执、取消、配额与恢复检查下开放 `monitor_run` 与后台 Shell,并让 H0b 之前的 reader 远离含有这些记录的 Session。 |
|
||||
| H1–H6 | 其他 domain 的正文及其 phase,每条都内嵌运行块。 |
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,457 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
// Ajv exposes draft 2020-12 through this documented entry point.
|
||||
// eslint-disable-next-line import/no-internal-modules
|
||||
import { Ajv2020 } from 'ajv/dist/2020.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
MANAGED_EXTENSION_DELIVERY_TARGETS,
|
||||
MANAGED_EXTENSION_MONITOR_STOP_REASONS,
|
||||
MANAGED_EXTENSION_REASONS,
|
||||
MANAGED_EXTENSION_RECORD_KINDS,
|
||||
MANAGED_EXTENSION_RECORD_LIMITS,
|
||||
MANAGED_EXTENSION_STATE_LINES,
|
||||
isDefinitionPinConsistent,
|
||||
isExtensionRunSuccessor,
|
||||
isExtensionTransitionAllowed,
|
||||
isMonitorRunSuccessor,
|
||||
isOperationGrantSuccessor,
|
||||
parseDefinitionPin,
|
||||
parseExtensionRun,
|
||||
parseMonitorRun,
|
||||
parseOperationGrant,
|
||||
type ExtensionStateLine,
|
||||
} from './managed-extension-record.js';
|
||||
import {
|
||||
MANAGED_SESSION_DOMAINS,
|
||||
ManagedSessionRecordError,
|
||||
assertManagedSessionDomainEnabled,
|
||||
} from './managed-session-records.js';
|
||||
|
||||
interface Checked {
|
||||
readonly id: string;
|
||||
readonly valid: boolean;
|
||||
}
|
||||
|
||||
type Pair = Checked & { readonly previous: unknown; readonly next: unknown };
|
||||
|
||||
interface FixtureSuite {
|
||||
readonly contractVersion: 1;
|
||||
readonly domains: unknown;
|
||||
readonly limits: unknown;
|
||||
readonly kinds: unknown;
|
||||
readonly stateLines: Record<
|
||||
ExtensionStateLine,
|
||||
{
|
||||
readonly states: readonly string[];
|
||||
readonly transitions: Record<string, readonly string[]>;
|
||||
}
|
||||
>;
|
||||
readonly deliveryTargets: unknown;
|
||||
readonly reasons: unknown;
|
||||
readonly monitorStopReasons: unknown;
|
||||
readonly grant: Record<string, unknown>;
|
||||
readonly definitionPin: unknown;
|
||||
readonly run: unknown;
|
||||
readonly monitorRun: Record<string, unknown>;
|
||||
readonly grantCases: ReadonlyArray<Checked & { readonly grant: unknown }>;
|
||||
readonly grantSuccessorCases: readonly Pair[];
|
||||
readonly pinCases: ReadonlyArray<Checked & { readonly pin: unknown }>;
|
||||
readonly pinConsistencyCases: ReadonlyArray<
|
||||
Checked & { readonly first: unknown; readonly second: unknown }
|
||||
>;
|
||||
readonly runCases: ReadonlyArray<Checked & { readonly run: unknown }>;
|
||||
readonly runSuccessorCases: readonly Pair[];
|
||||
readonly monitorRunCases: ReadonlyArray<
|
||||
Checked & { readonly monitorRun: unknown }
|
||||
>;
|
||||
readonly monitorRunSuccessorCases: readonly Pair[];
|
||||
}
|
||||
|
||||
interface SchemaDefinition {
|
||||
readonly required?: readonly string[];
|
||||
readonly properties?: Record<string, unknown>;
|
||||
readonly additionalProperties?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture cases that the schema accepts although the contract refuses them.
|
||||
* JSON Schema cannot state UTF-8 byte limits, NFC, well-formed UTF-16, the
|
||||
* 2^63-1 bound readably, a kind derived from another field, or a bound that
|
||||
* depends on another field.
|
||||
*/
|
||||
const BEYOND_SCHEMA = {
|
||||
grant: [
|
||||
'generation-past-int64',
|
||||
'operation-id-lone-surrogate',
|
||||
'operation-id-not-nfc',
|
||||
'operation-id-over-512-bytes',
|
||||
'operation-id-two-byte-over-512-bytes',
|
||||
'owner-id-lone-surrogate',
|
||||
'owner-id-not-nfc',
|
||||
'owner-id-over-512-bytes',
|
||||
'owner-id-two-byte-over-512-bytes',
|
||||
'scope-record-of-another-domain',
|
||||
'tenant-id-lone-surrogate',
|
||||
'tenant-id-not-nfc',
|
||||
'tenant-id-over-512-bytes',
|
||||
'tenant-id-two-byte-over-512-bytes',
|
||||
],
|
||||
pin: [
|
||||
'definition-id-lone-surrogate',
|
||||
'definition-id-not-nfc',
|
||||
'definition-id-over-512-bytes',
|
||||
'definition-id-two-byte-over-512-bytes',
|
||||
],
|
||||
run: [
|
||||
'delivery-id-lone-surrogate',
|
||||
'delivery-id-not-nfc',
|
||||
'delivery-id-over-512-bytes',
|
||||
'delivery-id-two-byte-over-512-bytes',
|
||||
'dispatch-id-lone-surrogate',
|
||||
'dispatch-id-not-nfc',
|
||||
'dispatch-id-over-512-bytes',
|
||||
'dispatch-id-two-byte-over-512-bytes',
|
||||
'effect-id-lone-surrogate',
|
||||
'effect-id-not-nfc',
|
||||
'effect-id-over-512-bytes',
|
||||
'effect-id-two-byte-over-512-bytes',
|
||||
'execution-call-id-lone-surrogate',
|
||||
'execution-call-id-not-nfc',
|
||||
'execution-call-id-over-512-bytes',
|
||||
'execution-call-id-two-byte-over-512-bytes',
|
||||
'runtime-binding-id-lone-surrogate',
|
||||
'runtime-binding-id-not-nfc',
|
||||
'runtime-binding-id-over-512-bytes',
|
||||
'runtime-binding-id-two-byte-over-512-bytes',
|
||||
],
|
||||
monitorRun: [
|
||||
'max-events-short',
|
||||
'monitor-id-lone-surrogate',
|
||||
'monitor-id-not-nfc',
|
||||
'monitor-id-over-512-bytes',
|
||||
'monitor-id-two-byte-over-512-bytes',
|
||||
'notified-past-observations',
|
||||
'observations-past-max-events',
|
||||
'owner-scope-id-lone-surrogate',
|
||||
'owner-scope-id-not-nfc',
|
||||
'owner-scope-id-over-512-bytes',
|
||||
'owner-scope-id-two-byte-over-512-bytes',
|
||||
],
|
||||
};
|
||||
|
||||
const contractDirectory = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'contracts',
|
||||
);
|
||||
const fixtures = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(contractDirectory, 'managed-extension-record-v1.fixtures.json'),
|
||||
'utf8',
|
||||
),
|
||||
) as FixtureSuite;
|
||||
const schema = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(contractDirectory, 'managed-extension-record-v1.schema.json'),
|
||||
'utf8',
|
||||
),
|
||||
) as { readonly $id: string; readonly $defs: Record<string, SchemaDefinition> };
|
||||
const ajv = new Ajv2020({ strict: true });
|
||||
const validateSuite = ajv.compile(schema);
|
||||
|
||||
function schemaAccepts(definition: string, value: unknown): boolean {
|
||||
const validate = ajv.getSchema(`${schema.$id}#/$defs/${definition}`);
|
||||
if (!validate) {
|
||||
throw new Error(`The schema has no ${definition} definition.`);
|
||||
}
|
||||
return validate(value) as boolean;
|
||||
}
|
||||
|
||||
function throwsContractError(parse: () => unknown): boolean {
|
||||
try {
|
||||
parse();
|
||||
return false;
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedSessionRecordError) return true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isDeepFrozen(value: unknown): boolean {
|
||||
if (typeof value !== 'object' || value === null) return true;
|
||||
return (
|
||||
Object.isFrozen(value) &&
|
||||
Object.values(value).every((child) => isDeepFrozen(child))
|
||||
);
|
||||
}
|
||||
|
||||
function expectParsed(
|
||||
parse: (value: unknown) => unknown,
|
||||
fixture: Checked,
|
||||
value: unknown,
|
||||
): void {
|
||||
if (fixture.valid) {
|
||||
const parsed = parse(value);
|
||||
|
||||
expect(parsed).toStrictEqual(value);
|
||||
expect(isDeepFrozen(parsed)).toBe(true);
|
||||
} else {
|
||||
expect(throwsContractError(() => parse(value))).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
describe('Managed extension record contract', () => {
|
||||
it('validates the shared fixtures against the shared schema', () => {
|
||||
expect(validateSuite(fixtures)).toBe(true);
|
||||
expect(validateSuite.errors).toBeNull();
|
||||
});
|
||||
|
||||
it('closes every record definition of the schema', () => {
|
||||
const open = Object.entries(schema.$defs)
|
||||
.filter(
|
||||
([, definition]) =>
|
||||
definition.properties !== undefined &&
|
||||
(definition.additionalProperties !== false ||
|
||||
Object.keys(definition.properties).some(
|
||||
(key) => !definition.required?.includes(key),
|
||||
)),
|
||||
)
|
||||
.map(([name]) => name);
|
||||
|
||||
expect(open).toEqual([]);
|
||||
});
|
||||
|
||||
it('pins the domains, limits, kinds, state lines and reasons', () => {
|
||||
expect(fixtures.contractVersion).toBe(1);
|
||||
expect(fixtures.domains).toStrictEqual([...MANAGED_SESSION_DOMAINS]);
|
||||
expect(fixtures.limits).toStrictEqual({
|
||||
...MANAGED_EXTENSION_RECORD_LIMITS,
|
||||
});
|
||||
expect(fixtures.kinds).toStrictEqual({ ...MANAGED_EXTENSION_RECORD_KINDS });
|
||||
expect(fixtures.stateLines).toStrictEqual(
|
||||
JSON.parse(JSON.stringify(MANAGED_EXTENSION_STATE_LINES)),
|
||||
);
|
||||
expect(fixtures.deliveryTargets).toStrictEqual(
|
||||
JSON.parse(JSON.stringify(MANAGED_EXTENSION_DELIVERY_TARGETS)),
|
||||
);
|
||||
expect(fixtures.reasons).toStrictEqual(
|
||||
JSON.parse(JSON.stringify(MANAGED_EXTENSION_REASONS)),
|
||||
);
|
||||
expect(fixtures.monitorStopReasons).toStrictEqual(
|
||||
JSON.parse(JSON.stringify(MANAGED_EXTENSION_MONITOR_STOP_REASONS)),
|
||||
);
|
||||
});
|
||||
|
||||
it('registers monitor_run without enabling it for submission', () => {
|
||||
expect(MANAGED_SESSION_DOMAINS).toContain('monitor_run');
|
||||
expect(
|
||||
throwsContractError(() =>
|
||||
assertManagedSessionDomainEnabled('monitor_run'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('allows exactly the listed step between every pair of states', () => {
|
||||
const wrong: string[] = [];
|
||||
for (const [stateLine, { states, transitions }] of Object.entries(
|
||||
fixtures.stateLines,
|
||||
)) {
|
||||
for (const from of states) {
|
||||
for (const to of states) {
|
||||
if (
|
||||
isExtensionTransitionAllowed(
|
||||
stateLine as ExtensionStateLine,
|
||||
from,
|
||||
to,
|
||||
) !== transitions[from].includes(to)
|
||||
) {
|
||||
wrong.push(`${stateLine}: ${from} -> ${to}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(wrong).toEqual([]);
|
||||
expect(isExtensionTransitionAllowed('run', 'reserved', 'constructor')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isExtensionTransitionAllowed('run', 'toString', 'admitted')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isExtensionTransitionAllowed(
|
||||
'toString' as ExtensionStateLine,
|
||||
'reserved',
|
||||
'admitted',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('uses each case id once in each list', () => {
|
||||
for (const list of [
|
||||
fixtures.grantCases,
|
||||
fixtures.grantSuccessorCases,
|
||||
fixtures.pinCases,
|
||||
fixtures.pinConsistencyCases,
|
||||
fixtures.runCases,
|
||||
fixtures.runSuccessorCases,
|
||||
fixtures.monitorRunCases,
|
||||
fixtures.monitorRunSuccessorCases,
|
||||
]) {
|
||||
const ids = list.map((fixture) => fixture.id);
|
||||
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(fixtures.grantCases)('parses the $id grant', (fixture) => {
|
||||
expectParsed(parseOperationGrant, fixture, fixture.grant);
|
||||
});
|
||||
|
||||
it.each(fixtures.grantSuccessorCases)(
|
||||
'checks the $id grant successor',
|
||||
({ previous, next, valid }) => {
|
||||
expect(isOperationGrantSuccessor(previous, next)).toBe(valid);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(fixtures.pinCases)('parses the $id definition pin', (fixture) => {
|
||||
expectParsed((value) => parseDefinitionPin(value), fixture, fixture.pin);
|
||||
});
|
||||
|
||||
it.each(fixtures.pinConsistencyCases)(
|
||||
'checks the $id pin pair',
|
||||
({ first, second, valid }) => {
|
||||
expect(isDefinitionPinConsistent(first, second)).toBe(valid);
|
||||
expect(isDefinitionPinConsistent(second, first)).toBe(valid);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(fixtures.runCases)('parses the $id run', (fixture) => {
|
||||
expectParsed((value) => parseExtensionRun(value), fixture, fixture.run);
|
||||
});
|
||||
|
||||
it.each(fixtures.runSuccessorCases)(
|
||||
'checks the $id run successor',
|
||||
({ previous, next, valid }) => {
|
||||
expect(isExtensionRunSuccessor(previous, next)).toBe(valid);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(fixtures.monitorRunCases)('parses the $id monitor', (fixture) => {
|
||||
expectParsed(parseMonitorRun, fixture, fixture.monitorRun);
|
||||
});
|
||||
|
||||
it.each(fixtures.monitorRunSuccessorCases)(
|
||||
'checks the $id monitor successor',
|
||||
({ previous, next, valid }) => {
|
||||
expect(isMonitorRunSuccessor(previous, next)).toBe(valid);
|
||||
},
|
||||
);
|
||||
|
||||
it('agrees with the schema except where the schema cannot state a rule', () => {
|
||||
const lists = {
|
||||
grant: fixtures.grantCases.map((each) => ({
|
||||
...each,
|
||||
value: each.grant,
|
||||
definition: 'operationGrant',
|
||||
})),
|
||||
pin: fixtures.pinCases.map((each) => ({
|
||||
...each,
|
||||
value: each.pin,
|
||||
definition: 'definitionPin',
|
||||
})),
|
||||
run: fixtures.runCases.map((each) => ({
|
||||
...each,
|
||||
value: each.run,
|
||||
definition: 'extensionRun',
|
||||
})),
|
||||
monitorRun: fixtures.monitorRunCases.map((each) => ({
|
||||
...each,
|
||||
value: each.monitorRun,
|
||||
definition: 'monitorRun',
|
||||
})),
|
||||
};
|
||||
// A case the module accepts and the schema refuses would put the
|
||||
// schema in the wrong.
|
||||
const acceptedButSchemaInvalid: string[] = [];
|
||||
const disagreements = Object.fromEntries(
|
||||
Object.entries(lists).map(([name, cases]) => [
|
||||
name,
|
||||
cases
|
||||
.filter((fixture) => {
|
||||
const schemaValid = schemaAccepts(
|
||||
fixture.definition,
|
||||
fixture.value,
|
||||
);
|
||||
if (fixture.valid && !schemaValid) {
|
||||
acceptedButSchemaInvalid.push(fixture.id);
|
||||
}
|
||||
return schemaValid !== fixture.valid;
|
||||
})
|
||||
.map((fixture) => fixture.id)
|
||||
.sort(),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(acceptedButSchemaInvalid).toEqual([]);
|
||||
expect(disagreements).toEqual(BEYOND_SCHEMA);
|
||||
});
|
||||
|
||||
it('reads each monitor field once, so the checked value is kept', () => {
|
||||
for (const field of Object.keys(fixtures.monitorRun)) {
|
||||
let reads = 0;
|
||||
const copy = { ...fixtures.monitorRun };
|
||||
Object.defineProperty(copy, field, {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
reads++;
|
||||
return reads === 1 ? fixtures.monitorRun[field] : 'changed';
|
||||
},
|
||||
});
|
||||
|
||||
expect(parseMonitorRun(copy)).toStrictEqual(fixtures.monitorRun);
|
||||
expect(reads).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a sparse phase list, which JSON cannot carry', () => {
|
||||
const withPhases = (phases: unknown[]) => ({
|
||||
...fixtures.grant,
|
||||
resourceScope: {
|
||||
...(fixtures.grant['resourceScope'] as object),
|
||||
phases,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
throwsContractError(() => parseOperationGrant(withPhases(new Array(1)))),
|
||||
).toBe(true);
|
||||
expect(
|
||||
throwsContractError(() =>
|
||||
// eslint-disable-next-line no-sparse-arrays
|
||||
parseOperationGrant(withPhases(['send_segment', , 'query_receipt'])),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses objects that are not plain JSON objects', () => {
|
||||
const inherited = Object.assign(
|
||||
Object.create({ inherited: true }) as object,
|
||||
fixtures.grant,
|
||||
);
|
||||
const callable = Object.assign(() => undefined, fixtures.grant);
|
||||
|
||||
expect(throwsContractError(() => parseOperationGrant(inherited))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(throwsContractError(() => parseOperationGrant(callable))).toBe(true);
|
||||
});
|
||||
});
|
||||
1060
packages/core/src/managed-runtime/managed-extension-record.ts
Normal file
1060
packages/core/src/managed-runtime/managed-extension-record.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -687,8 +687,8 @@ describe('managed session per-kind rules', () => {
|
|||
});
|
||||
|
||||
it('registers the v1 domains including file history and session source', () => {
|
||||
expect(MANAGED_SESSION_DOMAINS).toHaveLength(32);
|
||||
expect(new Set(MANAGED_SESSION_DOMAINS).size).toBe(32);
|
||||
expect(MANAGED_SESSION_DOMAINS).toHaveLength(33);
|
||||
expect(new Set(MANAGED_SESSION_DOMAINS).size).toBe(33);
|
||||
});
|
||||
|
||||
it('validates the lifecycle target state', () => {
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ export const MANAGED_SESSION_DOMAINS = [
|
|||
'child_run',
|
||||
'child_acceptance',
|
||||
'memory_job',
|
||||
'monitor_run',
|
||||
'goal_state',
|
||||
'todo_state',
|
||||
'plan_mode',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,708 @@
|
|||
package com.alibaba.qwen.code.managedagent.store;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.text.Normalizer;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* The managed-extension-record/1 contract (H0b of #12827): the records that
|
||||
* the Stage H capabilities share, as the control plane reads them. The
|
||||
* shared schema and fixtures in packages/core pin the contract, and the
|
||||
* TypeScript module there replays the same cases. Nothing commits these
|
||||
* records until H0c.
|
||||
*/
|
||||
public final class ManagedExtensionRecords {
|
||||
public static final List<String> DOMAINS = List.of("config_install",
|
||||
"workspace_initialization", "skill_activation",
|
||||
"mcp_configuration", "mcp_operation", "hook_registration",
|
||||
"hook_execution", "tool_stage", "resource", "publication",
|
||||
"workspace_operation", "history_rewind", "history_copy",
|
||||
"history_maintenance", "channel_route", "channel_delivery",
|
||||
"schedule", "automation_run", "child_run", "child_acceptance",
|
||||
"memory_job", "monitor_run", "goal_state", "todo_state",
|
||||
"plan_mode", "team_state", "team_task", "team_message",
|
||||
"team_plan", "session_message", "session_metadata",
|
||||
"file_history", "session_source");
|
||||
public static final int MAX_ID_BYTES = 512;
|
||||
public static final int MAX_GRANT_PHASES = 16;
|
||||
public static final int MAX_PHASE_LENGTH = 64;
|
||||
public static final int MAX_MONITOR_EVENTS = 10_000;
|
||||
public static final int MAX_MONITOR_IDLE_TIMEOUT_MS = 600_000;
|
||||
public static final int MAX_MONITOR_DEBOUNCE_MS = 600_000;
|
||||
public static final String MONITOR_RUN_KIND = "managed-monitor_run";
|
||||
public static final String MONITOR_OUTPUT_KIND =
|
||||
"managed-tool-result-manifest";
|
||||
|
||||
public static final List<String> RUN_STATES = List.of("reserved",
|
||||
"admitted", "running", "waiting", "settled", "failed",
|
||||
"cancelled", "recovery_blocked");
|
||||
public static final List<String> EXECUTION_STATES = List.of("intent",
|
||||
"dispatch_started", "running_attached", "settled",
|
||||
"not_started_proven", "outcome_unknown", "corrupt");
|
||||
public static final List<String> DELIVERY_STATES = List.of("planned",
|
||||
"sending", "partial", "delivered", "accepting", "accepted",
|
||||
"consumed", "unknown", "rejected", "cancelled");
|
||||
/** The single steps each state line allows, by line and state. */
|
||||
public static final Map<String, Map<String, List<String>>> TRANSITIONS =
|
||||
Map.of("run", Map.of(
|
||||
"reserved", List.of("admitted", "failed", "cancelled"),
|
||||
"admitted", List.of("running", "waiting", "failed",
|
||||
"cancelled", "recovery_blocked"),
|
||||
"running", List.of("waiting", "settled", "failed",
|
||||
"cancelled", "recovery_blocked"),
|
||||
"waiting", List.of("running", "settled", "failed",
|
||||
"cancelled", "recovery_blocked"),
|
||||
"settled", List.of(),
|
||||
"failed", List.of(),
|
||||
"cancelled", List.of(),
|
||||
"recovery_blocked", List.of("running", "waiting",
|
||||
"settled", "failed", "cancelled")),
|
||||
"execution", Map.of(
|
||||
"intent", List.of("dispatch_started",
|
||||
"not_started_proven", "outcome_unknown",
|
||||
"corrupt"),
|
||||
"dispatch_started", List.of("running_attached",
|
||||
"settled", "not_started_proven",
|
||||
"outcome_unknown", "corrupt"),
|
||||
"running_attached", List.of("settled", "outcome_unknown",
|
||||
"corrupt"),
|
||||
"settled", List.of(),
|
||||
"not_started_proven", List.of(),
|
||||
"outcome_unknown", List.of("running_attached", "settled",
|
||||
"not_started_proven", "corrupt"),
|
||||
"corrupt", List.of()),
|
||||
"delivery", Map.of(
|
||||
"planned", List.of("sending", "accepting", "cancelled"),
|
||||
"sending", List.of("delivered", "partial", "unknown",
|
||||
"rejected"),
|
||||
"partial", List.of("sending", "unknown"),
|
||||
"delivered", List.of(),
|
||||
"accepting", List.of("accepted", "unknown", "rejected"),
|
||||
"accepted", List.of("consumed"),
|
||||
"consumed", List.of(),
|
||||
"unknown", List.of("delivered", "partial", "accepted",
|
||||
"rejected"),
|
||||
"rejected", List.of(),
|
||||
"cancelled", List.of()));
|
||||
public static final Map<String, List<String>> DELIVERY_TARGETS = Map.of(
|
||||
"channel", List.of("planned", "sending", "partial", "delivered",
|
||||
"unknown", "rejected", "cancelled"),
|
||||
"session", List.of("planned", "accepting", "accepted",
|
||||
"consumed", "unknown", "rejected", "cancelled"));
|
||||
public static final List<String> RECOVERY_REASONS = List.of(
|
||||
"outcome_unknown", "execution_corrupt", "runtime_lost",
|
||||
"dispatch_unknown", "handler_unavailable");
|
||||
public static final List<String> QUOTA_REASONS = List.of("count_limit",
|
||||
"rate_limit", "depth_limit", "byte_limit", "budget_exhausted",
|
||||
"duration_limit");
|
||||
/** Why a Monitor ended, by the state its run ended in. */
|
||||
public static final Map<String, List<String>> MONITOR_STOP_REASONS =
|
||||
Map.of("settled", List.of("exited", "max_events", "idle_timeout"),
|
||||
"failed", List.of("start_failed", "watch_failed",
|
||||
"quota_exceeded"),
|
||||
"cancelled", List.of("stop_requested"));
|
||||
|
||||
private static final Comparator<JsonNode> SAME_VALUE = (left, right) ->
|
||||
left.equals(right) || left.isNumber() && right.isNumber()
|
||||
&& left.decimalValue().compareTo(right.decimalValue()) == 0
|
||||
? 0 : 1;
|
||||
private static final long MAX_COUNT = 9_007_199_254_740_990L;
|
||||
private static final long MAX_TIME = 8_640_000_000_000_000L;
|
||||
private static final BigInteger MAX_GENERATION =
|
||||
BigInteger.valueOf(Long.MAX_VALUE);
|
||||
private static final Pattern GENERATION = Pattern.compile(
|
||||
"[1-9][0-9]{0,18}");
|
||||
private static final Pattern DIGEST = Pattern.compile("[0-9a-f]{64}");
|
||||
private static final Pattern PHASE = Pattern.compile(
|
||||
"[a-z][a-z0-9_]{0," + (MAX_PHASE_LENGTH - 1) + "}");
|
||||
private static final List<String> TERMINAL = List.of("settled", "failed",
|
||||
"cancelled");
|
||||
private static final Set<String> GRANT_KEYS = Set.of("sessionKey",
|
||||
"operationId", "domain", "operationRevision", "ownerId",
|
||||
"workspaceGeneration", "resourceScope", "leaseDurationMs",
|
||||
"expiresAt");
|
||||
private static final Set<String> RUN_KEYS = Set.of("state", "reason",
|
||||
"definition", "executionCallId", "effectId", "dispatchId",
|
||||
"deliveryId", "execution", "runtime", "delivery");
|
||||
private static final Set<String> MONITOR_KEYS = Set.of("monitorId",
|
||||
"ownerScopeId", "commandRef", "maxEvents", "idleTimeoutMs",
|
||||
"debounceMs", "startReceiptRef", "observationSequence",
|
||||
"lastObservationRef", "notifiedThrough", "stopReason",
|
||||
"outputRef", "run");
|
||||
private static final List<String> MONITOR_FIXED = List.of("monitorId",
|
||||
"ownerScopeId", "commandRef", "maxEvents", "idleTimeoutMs",
|
||||
"debounceMs");
|
||||
private static final List<String> RUN_IDENTITIES = List.of("definition",
|
||||
"executionCallId", "effectId", "dispatchId", "deliveryId");
|
||||
|
||||
private ManagedExtensionRecords() {
|
||||
}
|
||||
|
||||
/** A record that breaks the contract. */
|
||||
public static final class InvalidRecordException
|
||||
extends IllegalArgumentException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
InvalidRecordException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether one step of {@code line} may go from {@code from} to {@code to}. */
|
||||
public static boolean isTransitionAllowed(String line, String from,
|
||||
String to) {
|
||||
if (line == null || from == null || to == null) {
|
||||
return false;
|
||||
}
|
||||
Map<String, List<String>> transitions = TRANSITIONS.get(line);
|
||||
List<String> next = transitions == null ? null : transitions.get(from);
|
||||
return next != null && next.contains(to);
|
||||
}
|
||||
|
||||
public static void requireOperationGrant(JsonNode grant) {
|
||||
closed(grant, GRANT_KEYS, "grant");
|
||||
closed(grant.get("sessionKey"), Set.of("tenantId", "workspaceId",
|
||||
"sessionId"), "grant.sessionKey");
|
||||
grant.get("sessionKey").forEach(value -> id(value,
|
||||
"grant.sessionKey"));
|
||||
id(grant.get("operationId"), "grant.operationId");
|
||||
String domain = oneOf(grant.get("domain"), DOMAINS, "grant.domain");
|
||||
count(grant.get("operationRevision"), 1, MAX_COUNT,
|
||||
"grant.operationRevision");
|
||||
id(grant.get("ownerId"), "grant.ownerId");
|
||||
generation(grant.get("workspaceGeneration"),
|
||||
"grant.workspaceGeneration");
|
||||
JsonNode scope = grant.get("resourceScope");
|
||||
closed(scope, Set.of("recordRef", "phases"), "grant.resourceScope");
|
||||
JsonNode recordRef = scope.get("recordRef");
|
||||
durableRef(recordRef, "grant.resourceScope.recordRef");
|
||||
// The same pairing domain.committed requires of its recordRef.
|
||||
require(("managed-" + domain).equals(recordRef.get("kind")
|
||||
.textValue()) && recordRef.get("schemaVersion").asLong() == 1,
|
||||
"grant.resourceScope.recordRef must reference managed-"
|
||||
+ domain + " version 1");
|
||||
JsonNode phases = scope.get("phases");
|
||||
require(phases.isArray() && !phases.isEmpty()
|
||||
&& phases.size() <= MAX_GRANT_PHASES,
|
||||
"grant.resourceScope.phases must list 1 to "
|
||||
+ MAX_GRANT_PHASES + " phases");
|
||||
Set<String> seen = new HashSet<>();
|
||||
for (JsonNode phase : phases) {
|
||||
require(phase.isTextual() && PHASE.matcher(phase.textValue())
|
||||
.matches() && seen.add(phase.textValue()),
|
||||
"grant.resourceScope.phases must be distinct phases");
|
||||
}
|
||||
count(grant.get("leaseDurationMs"),
|
||||
ManagedSessionStoreModels.MIN_LEASE_MILLIS,
|
||||
ManagedSessionStoreModels.MAX_LEASE_MILLIS,
|
||||
"grant.leaseDurationMs");
|
||||
count(grant.get("expiresAt"), 0, MAX_TIME, "grant.expiresAt");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@code next} may replace {@code previous} at a Runtime's
|
||||
* per-operation gate: a renewal of the same revision that only extends
|
||||
* the lease, or a later revision of the same operation that does not go
|
||||
* back to an older Workspace generation.
|
||||
*/
|
||||
public static boolean isOperationGrantSuccessor(JsonNode previous,
|
||||
JsonNode next) {
|
||||
if (!accepts(() -> requireOperationGrant(previous))
|
||||
|| !accepts(() -> requireOperationGrant(next))) {
|
||||
return false;
|
||||
}
|
||||
for (String key : List.of("sessionKey", "operationId", "domain")) {
|
||||
if (!same(previous.get(key), next.get(key))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
long before = previous.get("operationRevision").asLong();
|
||||
long after = next.get("operationRevision").asLong();
|
||||
if (after == before) {
|
||||
return next.get("expiresAt").asLong()
|
||||
> previous.get("expiresAt").asLong()
|
||||
&& same(without(previous, "expiresAt"),
|
||||
without(next, "expiresAt"));
|
||||
}
|
||||
return after > before && generationOf(next, "workspaceGeneration")
|
||||
.compareTo(generationOf(previous, "workspaceGeneration")) >= 0;
|
||||
}
|
||||
|
||||
public static void requireDefinitionPin(JsonNode pin) {
|
||||
closed(pin, Set.of("definitionId", "definitionRevision",
|
||||
"definitionDigest"), "definition");
|
||||
id(pin.get("definitionId"), "definition.definitionId");
|
||||
count(pin.get("definitionRevision"), 1, MAX_COUNT,
|
||||
"definition.definitionRevision");
|
||||
digest(pin.get("definitionDigest"), "definition.definitionDigest");
|
||||
}
|
||||
|
||||
/** Whether two pins agree: a definition revision never names two digests. */
|
||||
public static boolean isDefinitionPinConsistent(JsonNode first,
|
||||
JsonNode second) {
|
||||
if (!accepts(() -> requireDefinitionPin(first))
|
||||
|| !accepts(() -> requireDefinitionPin(second))) {
|
||||
return false;
|
||||
}
|
||||
return !same(first.get("definitionId"), second.get("definitionId"))
|
||||
|| !same(first.get("definitionRevision"),
|
||||
second.get("definitionRevision"))
|
||||
|| same(first.get("definitionDigest"),
|
||||
second.get("definitionDigest"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the run block every Stage H record embeds: the three state
|
||||
* lines, the reason, the pinned definition, the stable identities and the
|
||||
* Runtime binding, together with the rules that tie them together.
|
||||
*/
|
||||
public static void requireRun(JsonNode run) {
|
||||
closed(run, RUN_KEYS, "run");
|
||||
String state = oneOf(run.get("state"), RUN_STATES, "run.state");
|
||||
String reason = nullableOneOf(run.get("reason"), concat(
|
||||
RECOVERY_REASONS, QUOTA_REASONS), "run.reason");
|
||||
if (!run.get("definition").isNull()) {
|
||||
requireDefinitionPin(run.get("definition"));
|
||||
}
|
||||
for (String key : List.of("executionCallId", "effectId",
|
||||
"dispatchId", "deliveryId")) {
|
||||
if (!run.get(key).isNull()) {
|
||||
id(run.get(key), "run." + key);
|
||||
}
|
||||
}
|
||||
String execution = nullableOneOf(run.get("execution"),
|
||||
EXECUTION_STATES, "run.execution");
|
||||
JsonNode runtime = run.get("runtime");
|
||||
if (!runtime.isNull()) {
|
||||
closed(runtime, Set.of("runtimeBindingId", "generation"),
|
||||
"run.runtime");
|
||||
id(runtime.get("runtimeBindingId"), "run.runtime.runtimeBindingId");
|
||||
generation(runtime.get("generation"), "run.runtime.generation");
|
||||
}
|
||||
JsonNode delivery = run.get("delivery");
|
||||
String target = null;
|
||||
String deliveryState = null;
|
||||
if (!delivery.isNull()) {
|
||||
closed(delivery, Set.of("target", "state"), "run.delivery");
|
||||
target = oneOf(delivery.get("target"), List.of("channel",
|
||||
"session"), "run.delivery.target");
|
||||
deliveryState = oneOf(delivery.get("state"),
|
||||
DELIVERY_TARGETS.get(target), "run.delivery.state");
|
||||
}
|
||||
boolean callId = !run.get("executionCallId").isNull();
|
||||
boolean effectId = !run.get("effectId").isNull();
|
||||
|
||||
require(!(callId && effectId),
|
||||
"run names one physical identity at most");
|
||||
require(execution == null || callId || effectId,
|
||||
"run.execution needs an executionCallId or an effectId");
|
||||
require(runtime.isNull() || execution != null,
|
||||
"run.runtime needs an execution");
|
||||
require(run.get("deliveryId").isNull() != "channel".equals(target),
|
||||
"run.deliveryId is set exactly for a channel delivery");
|
||||
require(!"reserved".equals(state)
|
||||
|| execution == null && delivery.isNull(),
|
||||
"run has nothing dispatched while reserved");
|
||||
require(!"outcome_unknown".equals(execution)
|
||||
&& !"corrupt".equals(execution)
|
||||
|| "recovery_blocked".equals(state),
|
||||
"run stays recovery_blocked while its execution is unproven");
|
||||
require(!TERMINAL.contains(state) || execution == null
|
||||
|| "settled".equals(execution)
|
||||
|| "not_started_proven".equals(execution),
|
||||
"run ends only with an execution proven to have ended");
|
||||
require(!"settled".equals(state)
|
||||
|| !"not_started_proven".equals(execution),
|
||||
"run cannot settle an execution that never started");
|
||||
require(!"session".equals(target) || "planned".equals(deliveryState)
|
||||
|| "cancelled".equals(deliveryState)
|
||||
|| TERMINAL.contains(state),
|
||||
"run.delivery cannot hand over a result before the run ends");
|
||||
|
||||
boolean recovery = reason != null && RECOVERY_REASONS.contains(reason);
|
||||
boolean quota = reason != null && QUOTA_REASONS.contains(reason);
|
||||
boolean fits = switch (state) {
|
||||
case "recovery_blocked" -> recovery;
|
||||
case "running", "waiting" -> reason == null || recovery;
|
||||
case "failed" -> reason == null || quota;
|
||||
default -> reason == null;
|
||||
};
|
||||
require(fits, "run.reason does not fit the " + state + " state");
|
||||
require(!"outcome_unknown".equals(reason)
|
||||
|| "outcome_unknown".equals(execution),
|
||||
"run.reason outcome_unknown needs an unknown execution");
|
||||
require("execution_corrupt".equals(reason)
|
||||
== "corrupt".equals(execution),
|
||||
"run.reason is execution_corrupt exactly when it is corrupt");
|
||||
require(!"runtime_lost".equals(reason) || !runtime.isNull(),
|
||||
"run.reason runtime_lost needs the Runtime binding it lost");
|
||||
require(!"runtime_lost".equals(reason)
|
||||
|| !"recovery_blocked".equals(state)
|
||||
|| !"running_attached".equals(execution),
|
||||
"run cannot be blocked on a lost Runtime while attached");
|
||||
require(!"dispatch_unknown".equals(reason)
|
||||
|| !run.get("dispatchId").isNull(),
|
||||
"run.reason dispatch_unknown needs a dispatchId");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@code next} may follow {@code previous} as the next revision
|
||||
* of one run: each state line stays or takes one allowed step, an
|
||||
* execution starts at intent and a delivery at planned, identities and
|
||||
* the pinned definition never change once set, the pin and the Runtime
|
||||
* binding are recorded by the dispatch, the binding changes only when an
|
||||
* unknown execution is attached again under a later generation, and a
|
||||
* run that ended changes only its delivery, with the delivery ID a first
|
||||
* Channel delivery brings.
|
||||
*/
|
||||
public static boolean isRunSuccessor(JsonNode previous, JsonNode next) {
|
||||
if (!accepts(() -> requireRun(previous))
|
||||
|| !accepts(() -> requireRun(next))
|
||||
|| !advances("run", text(previous, "state"),
|
||||
text(next, "state"))) {
|
||||
return false;
|
||||
}
|
||||
if (TERMINAL.contains(text(previous, "state"))
|
||||
&& !same(without(previous, "delivery", "deliveryId"),
|
||||
without(next, "delivery", "deliveryId"))) {
|
||||
return false;
|
||||
}
|
||||
for (String key : RUN_IDENTITIES) {
|
||||
if (!previous.get(key).isNull()
|
||||
&& !same(previous.get(key), next.get(key))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
String executionBefore = text(previous, "execution");
|
||||
String executionAfter = text(next, "execution");
|
||||
// A run is pinned to its definition and bound to its Runtime by the
|
||||
// time it dispatches, never later.
|
||||
boolean dispatched = executionBefore != null
|
||||
&& !"intent".equals(executionBefore);
|
||||
if (dispatched && previous.get("definition").isNull()
|
||||
&& !next.get("definition").isNull()) {
|
||||
return false;
|
||||
}
|
||||
if (executionBefore == null ? executionAfter != null
|
||||
&& !"intent".equals(executionAfter)
|
||||
: executionAfter == null || !advances("execution",
|
||||
executionBefore, executionAfter)) {
|
||||
return false;
|
||||
}
|
||||
JsonNode deliveryBefore = previous.get("delivery");
|
||||
JsonNode deliveryAfter = next.get("delivery");
|
||||
if (deliveryBefore.isNull() ? !deliveryAfter.isNull()
|
||||
&& !"planned".equals(text(deliveryAfter, "state"))
|
||||
: deliveryAfter.isNull()
|
||||
|| !same(deliveryBefore.get("target"),
|
||||
deliveryAfter.get("target"))
|
||||
|| !advances("delivery", text(deliveryBefore, "state"),
|
||||
text(deliveryAfter, "state"))) {
|
||||
return false;
|
||||
}
|
||||
JsonNode runtimeBefore = previous.get("runtime");
|
||||
JsonNode runtimeAfter = next.get("runtime");
|
||||
if (runtimeBefore.isNull()) {
|
||||
return runtimeAfter.isNull() || !dispatched;
|
||||
}
|
||||
if (same(runtimeBefore, runtimeAfter)) {
|
||||
return true;
|
||||
}
|
||||
return !runtimeAfter.isNull()
|
||||
&& "outcome_unknown".equals(executionBefore)
|
||||
&& "running_attached".equals(executionAfter)
|
||||
&& generationOf(runtimeAfter, "generation").compareTo(
|
||||
generationOf(runtimeBefore, "generation")) > 0;
|
||||
}
|
||||
|
||||
/** Checks the body of a managed-monitor_run domain record. */
|
||||
public static void requireMonitorRun(JsonNode monitor) {
|
||||
closed(monitor, MONITOR_KEYS, "monitorRun");
|
||||
JsonNode run = monitor.get("run");
|
||||
requireRun(run);
|
||||
// A Monitor is started by one tool call and delivers through its
|
||||
// notification watermark, not through a dispatch or a delivery.
|
||||
require(!run.get("executionCallId").isNull()
|
||||
&& run.get("effectId").isNull()
|
||||
&& run.get("dispatchId").isNull()
|
||||
&& run.get("deliveryId").isNull()
|
||||
&& run.get("delivery").isNull()
|
||||
&& run.get("definition").isNull(),
|
||||
"monitorRun.run must name its start call and nothing else");
|
||||
id(monitor.get("monitorId"), "monitorRun.monitorId");
|
||||
id(monitor.get("ownerScopeId"), "monitorRun.ownerScopeId");
|
||||
durableRef(monitor.get("commandRef"), "monitorRun.commandRef");
|
||||
long maxEvents = count(monitor.get("maxEvents"), 1,
|
||||
MAX_MONITOR_EVENTS, "monitorRun.maxEvents");
|
||||
count(monitor.get("idleTimeoutMs"), 1, MAX_MONITOR_IDLE_TIMEOUT_MS,
|
||||
"monitorRun.idleTimeoutMs");
|
||||
count(monitor.get("debounceMs"), 0, MAX_MONITOR_DEBOUNCE_MS,
|
||||
"monitorRun.debounceMs");
|
||||
JsonNode startReceipt = monitor.get("startReceiptRef");
|
||||
if (!startReceipt.isNull()) {
|
||||
durableRef(startReceipt, "monitorRun.startReceiptRef");
|
||||
}
|
||||
long observed = count(monitor.get("observationSequence"), 0,
|
||||
maxEvents, "monitorRun.observationSequence");
|
||||
JsonNode lastObservation = monitor.get("lastObservationRef");
|
||||
if (!lastObservation.isNull()) {
|
||||
durableRef(lastObservation, "monitorRun.lastObservationRef");
|
||||
}
|
||||
count(monitor.get("notifiedThrough"), 0, observed,
|
||||
"monitorRun.notifiedThrough");
|
||||
String stopReason = nullableOneOf(monitor.get("stopReason"),
|
||||
concat(concat(MONITOR_STOP_REASONS.get("settled"),
|
||||
MONITOR_STOP_REASONS.get("failed")),
|
||||
MONITOR_STOP_REASONS.get("cancelled")),
|
||||
"monitorRun.stopReason");
|
||||
JsonNode output = monitor.get("outputRef");
|
||||
if (!output.isNull()) {
|
||||
durableRef(output, "monitorRun.outputRef");
|
||||
require(MONITOR_OUTPUT_KIND.equals(output.get("kind").textValue())
|
||||
&& output.get("schemaVersion").asLong() == 1,
|
||||
"monitorRun.outputRef must reference "
|
||||
+ MONITOR_OUTPUT_KIND + " version 1");
|
||||
}
|
||||
String state = text(run, "state");
|
||||
String execution = text(run, "execution");
|
||||
String reason = text(run, "reason");
|
||||
|
||||
require(lastObservation.isNull() == (observed == 0),
|
||||
"monitorRun.lastObservationRef is set exactly after an "
|
||||
+ "observation");
|
||||
require(!startReceipt.isNull() || observed == 0
|
||||
&& !"running_attached".equals(execution),
|
||||
"monitorRun.startReceiptRef must be set once the watch "
|
||||
+ "started");
|
||||
require(startReceipt.isNull() || execution != null
|
||||
&& !"intent".equals(execution)
|
||||
&& !"dispatch_started".equals(execution)
|
||||
&& !"not_started_proven".equals(execution),
|
||||
"monitorRun.startReceiptRef must be null before the watch "
|
||||
+ "starts");
|
||||
require(startReceipt.isNull() || !run.get("runtime").isNull(),
|
||||
"monitorRun.startReceiptRef needs the Runtime binding that "
|
||||
+ "started it");
|
||||
require((stopReason == null) != TERMINAL.contains(state),
|
||||
"monitorRun.stopReason is set exactly when the run ends");
|
||||
require(stopReason == null
|
||||
|| MONITOR_STOP_REASONS.get(state).contains(stopReason),
|
||||
"monitorRun.stopReason does not fit the " + state + " state");
|
||||
require(!"settled".equals(state) || "settled".equals(execution)
|
||||
&& !startReceipt.isNull(),
|
||||
"monitorRun.run settles only with a watch that started and "
|
||||
+ "ended");
|
||||
require(!"max_events".equals(stopReason) || observed == maxEvents,
|
||||
"monitorRun.stopReason max_events needs maxEvents "
|
||||
+ "observations");
|
||||
require(!"start_failed".equals(stopReason)
|
||||
|| startReceipt.isNull() && execution != null,
|
||||
"monitorRun.stopReason start_failed needs a watch that "
|
||||
+ "never started");
|
||||
require(!"watch_failed".equals(stopReason) || !startReceipt.isNull(),
|
||||
"monitorRun.stopReason watch_failed needs a watch that "
|
||||
+ "started");
|
||||
require("quota_exceeded".equals(stopReason)
|
||||
== (reason != null && QUOTA_REASONS.contains(reason)),
|
||||
"monitorRun.stopReason is quota_exceeded exactly for a "
|
||||
+ "quota reason");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@code next} may follow {@code previous} as the next revision
|
||||
* of one monitor: its definition is fixed, its run moves forward, its
|
||||
* observation and notification watermarks never go back, a new Runtime
|
||||
* generation brings a new start receipt and nothing else does, and once
|
||||
* it ended only the notification watermark may still advance.
|
||||
*/
|
||||
public static boolean isMonitorRunSuccessor(JsonNode previous,
|
||||
JsonNode next) {
|
||||
if (!accepts(() -> requireMonitorRun(previous))
|
||||
|| !accepts(() -> requireMonitorRun(next))) {
|
||||
return false;
|
||||
}
|
||||
for (String key : MONITOR_FIXED) {
|
||||
if (!same(previous.get(key), next.get(key))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
long observedBefore = previous.get("observationSequence").asLong();
|
||||
long observedAfter = next.get("observationSequence").asLong();
|
||||
// Only an attached watch observes, so a lost or ended one adds
|
||||
// nothing.
|
||||
boolean attached = "running_attached".equals(text(previous.get("run"),
|
||||
"execution")) || "running_attached".equals(text(next.get("run"),
|
||||
"execution"));
|
||||
if (!isRunSuccessor(previous.get("run"), next.get("run"))
|
||||
|| observedAfter < observedBefore
|
||||
|| observedAfter > observedBefore && !attached
|
||||
|| next.get("notifiedThrough").asLong()
|
||||
< previous.get("notifiedThrough").asLong()
|
||||
|| observedAfter == observedBefore
|
||||
&& !same(previous.get("lastObservationRef"),
|
||||
next.get("lastObservationRef"))
|
||||
|| !previous.get("outputRef").isNull()
|
||||
&& next.get("outputRef").isNull()) {
|
||||
return false;
|
||||
}
|
||||
if (TERMINAL.contains(text(previous.get("run"), "state"))
|
||||
&& !same(without(previous, "notifiedThrough"),
|
||||
without(next, "notifiedThrough"))) {
|
||||
return false;
|
||||
}
|
||||
JsonNode runtimeBefore = previous.get("run").get("runtime");
|
||||
boolean rebuilt = !runtimeBefore.isNull()
|
||||
&& !same(runtimeBefore, next.get("run").get("runtime"));
|
||||
boolean sameReceipt = same(previous.get("startReceiptRef"),
|
||||
next.get("startReceiptRef"));
|
||||
return previous.get("startReceiptRef").isNull()
|
||||
|| (rebuilt ? !sameReceipt : sameReceipt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Equality that compares numbers by value, so a record built in Java,
|
||||
* where 4 may be a long, matches the same record parsed from JSON.
|
||||
*/
|
||||
private static boolean same(JsonNode left, JsonNode right) {
|
||||
return left.equals(SAME_VALUE, right);
|
||||
}
|
||||
|
||||
private static boolean advances(String line, String from, String to) {
|
||||
return from.equals(to) || isTransitionAllowed(line, from, to);
|
||||
}
|
||||
|
||||
private static boolean accepts(Runnable check) {
|
||||
try {
|
||||
check.run();
|
||||
return true;
|
||||
} catch (InvalidRecordException exception) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void require(boolean condition, String message) {
|
||||
if (!condition) {
|
||||
throw new InvalidRecordException(message + ".");
|
||||
}
|
||||
}
|
||||
|
||||
private static void closed(JsonNode node, Set<String> keys,
|
||||
String label) {
|
||||
require(node != null && node.isObject() && node.size() == keys.size(),
|
||||
label + " must be an object with exactly " + keys);
|
||||
node.fieldNames().forEachRemaining(name -> require(keys.contains(name),
|
||||
label + " must be an object with exactly " + keys));
|
||||
}
|
||||
|
||||
private static String id(JsonNode node, String label) {
|
||||
require(node != null && node.isTextual() && !node.textValue()
|
||||
.isEmpty(), label + " must be a non-empty string");
|
||||
String value = node.textValue();
|
||||
for (int index = 0; index < value.length(); index++) {
|
||||
char character = value.charAt(index);
|
||||
require(character > 0x1f && (character < 0x7f || character > 0x9f),
|
||||
label + " must not contain control characters");
|
||||
if (Character.isHighSurrogate(character)) {
|
||||
index++;
|
||||
require(index < value.length()
|
||||
&& Character.isLowSurrogate(value.charAt(index)),
|
||||
label + " must be well-formed text");
|
||||
} else {
|
||||
require(!Character.isLowSurrogate(character),
|
||||
label + " must be well-formed text");
|
||||
}
|
||||
}
|
||||
require(value.getBytes(StandardCharsets.UTF_8).length <= MAX_ID_BYTES,
|
||||
label + " exceeds " + MAX_ID_BYTES + " UTF-8 bytes");
|
||||
require(Normalizer.isNormalized(value, Normalizer.Form.NFC),
|
||||
label + " must use NFC normalization");
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* A JSON number equal to an integer in range. As in JSON Schema and in
|
||||
* JavaScript, 1.0 counts as 1; a number past the double range, which
|
||||
* Jackson reads as an infinity, counts as none.
|
||||
*/
|
||||
private static long count(JsonNode node, long min, long max,
|
||||
String label) {
|
||||
BigDecimal value = node != null && node.isNumber()
|
||||
&& Double.isFinite(node.doubleValue())
|
||||
? node.decimalValue() : null;
|
||||
require(value != null && value.stripTrailingZeros().scale() <= 0
|
||||
&& value.compareTo(BigDecimal.valueOf(min)) >= 0
|
||||
&& value.compareTo(BigDecimal.valueOf(Math.min(max,
|
||||
MAX_COUNT))) <= 0,
|
||||
label + " is out of range");
|
||||
return value.longValueExact();
|
||||
}
|
||||
|
||||
private static void digest(JsonNode node, String label) {
|
||||
require(node != null && node.isTextual()
|
||||
&& DIGEST.matcher(node.textValue()).matches(),
|
||||
label + " must be a lowercase SHA-256 hex digest");
|
||||
}
|
||||
|
||||
private static void generation(JsonNode node, String label) {
|
||||
require(node != null && node.isTextual()
|
||||
&& GENERATION.matcher(node.textValue()).matches()
|
||||
&& new BigInteger(node.textValue()).compareTo(MAX_GENERATION)
|
||||
<= 0,
|
||||
label + " must be canonical decimal text from 1 to 2^63-1");
|
||||
}
|
||||
|
||||
private static void durableRef(JsonNode node, String label) {
|
||||
closed(node, Set.of("resourceId", "kind", "schemaVersion",
|
||||
"byteLength", "digest"), label);
|
||||
id(node.get("resourceId"), label + ".resourceId");
|
||||
id(node.get("kind"), label + ".kind");
|
||||
count(node.get("schemaVersion"), 0, MAX_COUNT,
|
||||
label + ".schemaVersion");
|
||||
count(node.get("byteLength"), 0, MAX_COUNT, label + ".byteLength");
|
||||
digest(node.get("digest"), label + ".digest");
|
||||
}
|
||||
|
||||
private static String oneOf(JsonNode node, List<String> allowed,
|
||||
String label) {
|
||||
require(node != null && node.isTextual()
|
||||
&& allowed.contains(node.textValue()),
|
||||
label + " must be one of " + allowed);
|
||||
return node.textValue();
|
||||
}
|
||||
|
||||
private static String nullableOneOf(JsonNode node, List<String> allowed,
|
||||
String label) {
|
||||
return node != null && node.isNull() ? null
|
||||
: oneOf(node, allowed, label);
|
||||
}
|
||||
|
||||
private static List<String> concat(List<String> first,
|
||||
List<String> second) {
|
||||
return Stream.concat(first.stream(), second.stream()).toList();
|
||||
}
|
||||
|
||||
/** A checked field's text, or null when it holds JSON null. */
|
||||
private static String text(JsonNode record, String field) {
|
||||
return record.get(field).textValue();
|
||||
}
|
||||
|
||||
private static BigInteger generationOf(JsonNode record, String field) {
|
||||
return new BigInteger(record.get(field).textValue());
|
||||
}
|
||||
|
||||
private static JsonNode without(JsonNode record, String... fields) {
|
||||
ObjectNode copy = ((ObjectNode) record).deepCopy();
|
||||
copy.remove(List.of(fields));
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
package com.alibaba.qwen.code.managedagent;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedExtensionRecords;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedExtensionRecords.InvalidRecordException;
|
||||
import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.networknt.schema.JsonSchema;
|
||||
import com.networknt.schema.JsonSchemaFactory;
|
||||
import com.networknt.schema.SpecVersion.VersionFlag;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.BiPredicate;
|
||||
import java.util.function.Consumer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Replays the language-neutral managed-extension-record/1 fixtures (H0b)
|
||||
* that the TypeScript module replays too. The fixtures were generated and
|
||||
* labelled by an implementation independent of both languages.
|
||||
*/
|
||||
class ManagedExtensionRecordContractTest {
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final Path CONTRACT_DIR = findContractDirectory();
|
||||
private static final JsonSchemaFactory SCHEMAS =
|
||||
JsonSchemaFactory.getInstance(VersionFlag.V202012);
|
||||
|
||||
@Test
|
||||
void validatesTheFixturesAgainstTheSchema() throws IOException {
|
||||
JsonNode schema = read("managed-extension-record-v1.schema.json");
|
||||
|
||||
assertEquals(Set.of(), SCHEMAS.getSchema(schema).validate(fixtures()));
|
||||
// A case the validator accepts must satisfy the schema as well.
|
||||
List<String> wrong = new ArrayList<>();
|
||||
for (Map.Entry<String, String[]> list : Map.of(
|
||||
"grantCases", new String[] {"grant", "operationGrant"},
|
||||
"pinCases", new String[] {"pin", "definitionPin"},
|
||||
"runCases", new String[] {"run", "extensionRun"},
|
||||
"monitorRunCases", new String[] {"monitorRun", "monitorRun"})
|
||||
.entrySet()) {
|
||||
JsonSchema definition = definition(schema, list.getValue()[1]);
|
||||
for (JsonNode fixture : fixtures().required(list.getKey())) {
|
||||
if (fixture.required("valid").booleanValue()
|
||||
&& !definition.validate(fixture.required(
|
||||
list.getValue()[0])).isEmpty()) {
|
||||
wrong.add(list.getKey() + "/" + id(fixture));
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(List.of(), wrong);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pinsTheDomainsLimitsKindsStateLinesAndReasons() throws IOException {
|
||||
JsonNode fixtures = fixtures();
|
||||
ObjectNode limits = JSON.createObjectNode()
|
||||
.put("maxIdBytes", ManagedExtensionRecords.MAX_ID_BYTES)
|
||||
.put("minLeaseDurationMs", Math.toIntExact(
|
||||
ManagedSessionStoreModels.MIN_LEASE_MILLIS))
|
||||
.put("maxLeaseDurationMs", Math.toIntExact(
|
||||
ManagedSessionStoreModels.MAX_LEASE_MILLIS))
|
||||
.put("maxGrantPhases", ManagedExtensionRecords.MAX_GRANT_PHASES)
|
||||
.put("maxPhaseLength", ManagedExtensionRecords.MAX_PHASE_LENGTH)
|
||||
.put("maxMonitorEvents",
|
||||
ManagedExtensionRecords.MAX_MONITOR_EVENTS)
|
||||
.put("maxMonitorIdleTimeoutMs",
|
||||
ManagedExtensionRecords.MAX_MONITOR_IDLE_TIMEOUT_MS)
|
||||
.put("maxMonitorDebounceMs",
|
||||
ManagedExtensionRecords.MAX_MONITOR_DEBOUNCE_MS);
|
||||
ObjectNode kinds = JSON.createObjectNode()
|
||||
.put("monitorRun", ManagedExtensionRecords.MONITOR_RUN_KIND)
|
||||
.put("monitorOutput",
|
||||
ManagedExtensionRecords.MONITOR_OUTPUT_KIND);
|
||||
ObjectNode lines = JSON.createObjectNode();
|
||||
for (Map.Entry<String, List<String>> line : Map.of(
|
||||
"run", ManagedExtensionRecords.RUN_STATES,
|
||||
"execution", ManagedExtensionRecords.EXECUTION_STATES,
|
||||
"delivery", ManagedExtensionRecords.DELIVERY_STATES)
|
||||
.entrySet()) {
|
||||
lines.putObject(line.getKey())
|
||||
.<ObjectNode>set("states", JSON.valueToTree(
|
||||
line.getValue()))
|
||||
.set("transitions", JSON.valueToTree(
|
||||
ManagedExtensionRecords.TRANSITIONS.get(
|
||||
line.getKey())));
|
||||
}
|
||||
|
||||
// Whole JSON nodes, so 1.0 or a reordered list cannot pass.
|
||||
assertEquals(JSON.readTree("1"), fixtures.required("contractVersion"));
|
||||
assertEquals(JSON.valueToTree(ManagedExtensionRecords.DOMAINS),
|
||||
fixtures.required("domains"));
|
||||
assertEquals(limits, fixtures.required("limits"));
|
||||
assertEquals(kinds, fixtures.required("kinds"));
|
||||
assertEquals(lines, fixtures.required("stateLines"));
|
||||
assertEquals(JSON.valueToTree(
|
||||
ManagedExtensionRecords.DELIVERY_TARGETS),
|
||||
fixtures.required("deliveryTargets"));
|
||||
assertEquals(JSON.createObjectNode()
|
||||
.<ObjectNode>set("recovery", JSON.valueToTree(
|
||||
ManagedExtensionRecords.RECOVERY_REASONS))
|
||||
.set("quota", JSON.valueToTree(
|
||||
ManagedExtensionRecords.QUOTA_REASONS)),
|
||||
fixtures.required("reasons"));
|
||||
assertEquals(JSON.valueToTree(
|
||||
ManagedExtensionRecords.MONITOR_STOP_REASONS),
|
||||
fixtures.required("monitorStopReasons"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowsExactlyTheListedStepBetweenEveryPairOfStates()
|
||||
throws IOException {
|
||||
List<String> wrong = new ArrayList<>();
|
||||
int pairs = 0;
|
||||
for (Map.Entry<String, JsonNode> line
|
||||
: fixtures().required("stateLines").properties()) {
|
||||
JsonNode transitions = line.getValue().required("transitions");
|
||||
for (JsonNode from : line.getValue().required("states")) {
|
||||
for (JsonNode to : line.getValue().required("states")) {
|
||||
boolean listed = false;
|
||||
for (JsonNode allowed : transitions.required(
|
||||
from.textValue())) {
|
||||
listed |= allowed.equals(to);
|
||||
}
|
||||
if (ManagedExtensionRecords.isTransitionAllowed(
|
||||
line.getKey(), from.textValue(), to.textValue())
|
||||
!= listed) {
|
||||
wrong.add(line.getKey() + ": " + from + " -> " + to);
|
||||
}
|
||||
pairs++;
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(List.of(), wrong);
|
||||
assertEquals(8 * 8 + 7 * 7 + 10 * 10, pairs);
|
||||
assertFalse(ManagedExtensionRecords.isTransitionAllowed("run",
|
||||
"reserved", null));
|
||||
assertFalse(ManagedExtensionRecords.isTransitionAllowed("task",
|
||||
"reserved", "admitted"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaysEveryRecordCase() throws IOException {
|
||||
List<String> wrong = new ArrayList<>();
|
||||
wrong.addAll(replay("grantCases", "grant",
|
||||
ManagedExtensionRecords::requireOperationGrant));
|
||||
wrong.addAll(replay("pinCases", "pin",
|
||||
ManagedExtensionRecords::requireDefinitionPin));
|
||||
wrong.addAll(replay("runCases", "run",
|
||||
ManagedExtensionRecords::requireRun));
|
||||
wrong.addAll(replay("monitorRunCases", "monitorRun",
|
||||
ManagedExtensionRecords::requireMonitorRun));
|
||||
|
||||
assertEquals(List.of(), wrong);
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaysEveryPairCase() throws IOException {
|
||||
List<String> wrong = new ArrayList<>();
|
||||
wrong.addAll(replayPairs("grantSuccessorCases", "previous", "next",
|
||||
ManagedExtensionRecords::isOperationGrantSuccessor));
|
||||
wrong.addAll(replayPairs("pinConsistencyCases", "first", "second",
|
||||
ManagedExtensionRecords::isDefinitionPinConsistent));
|
||||
wrong.addAll(replayPairs("pinConsistencyCases", "second", "first",
|
||||
ManagedExtensionRecords::isDefinitionPinConsistent));
|
||||
wrong.addAll(replayPairs("runSuccessorCases", "previous", "next",
|
||||
ManagedExtensionRecords::isRunSuccessor));
|
||||
wrong.addAll(replayPairs("monitorRunSuccessorCases", "previous",
|
||||
"next", ManagedExtensionRecords::isMonitorRunSuccessor));
|
||||
|
||||
assertEquals(List.of(), wrong);
|
||||
}
|
||||
|
||||
@Test
|
||||
void comparesNumbersByValueInRecordsBuiltInJava() throws IOException {
|
||||
JsonNode grant = fixtures().required("grant");
|
||||
ObjectNode renewal = grant.deepCopy();
|
||||
renewal.put("operationRevision", 1L)
|
||||
.put("leaseDurationMs", 60_000L)
|
||||
.put("expiresAt", grant.required("expiresAt").longValue() + 1);
|
||||
JsonNode monitor = fixtures().required("monitorRun");
|
||||
ObjectNode sameMonitor = monitor.deepCopy();
|
||||
sameMonitor.put("maxEvents", 1000L).put("observationSequence", 3L);
|
||||
|
||||
assertTrue(ManagedExtensionRecords.isOperationGrantSuccessor(grant,
|
||||
renewal));
|
||||
assertTrue(ManagedExtensionRecords.isMonitorRunSuccessor(monitor,
|
||||
sameMonitor));
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesEachCaseIdOnceInEachList() throws IOException {
|
||||
int lists = 0;
|
||||
for (Map.Entry<String, JsonNode> field
|
||||
: fixtures().properties()) {
|
||||
if (!field.getKey().endsWith("Cases")) {
|
||||
continue;
|
||||
}
|
||||
Set<String> ids = new HashSet<>();
|
||||
for (JsonNode fixture : field.getValue()) {
|
||||
assertTrue(ids.add(id(fixture)), () -> "duplicate "
|
||||
+ field.getKey() + " id: " + id(fixture));
|
||||
}
|
||||
lists++;
|
||||
}
|
||||
assertEquals(8, lists);
|
||||
}
|
||||
|
||||
private static List<String> replay(String list, String field,
|
||||
Consumer<JsonNode> check) throws IOException {
|
||||
List<String> wrong = new ArrayList<>();
|
||||
for (JsonNode fixture : fixtures().required(list)) {
|
||||
boolean accepted;
|
||||
try {
|
||||
check.accept(fixture.required(field));
|
||||
accepted = true;
|
||||
} catch (InvalidRecordException refused) {
|
||||
accepted = false;
|
||||
}
|
||||
if (accepted != fixture.required("valid").booleanValue()) {
|
||||
wrong.add(list + "/" + id(fixture));
|
||||
}
|
||||
}
|
||||
return wrong;
|
||||
}
|
||||
|
||||
private static List<String> replayPairs(String list, String first,
|
||||
String second, BiPredicate<JsonNode, JsonNode> check)
|
||||
throws IOException {
|
||||
List<String> wrong = new ArrayList<>();
|
||||
for (JsonNode fixture : fixtures().required(list)) {
|
||||
if (check.test(fixture.required(first), fixture.required(second))
|
||||
!= fixture.required("valid").booleanValue()) {
|
||||
wrong.add(list + "/" + id(fixture));
|
||||
}
|
||||
}
|
||||
return wrong;
|
||||
}
|
||||
|
||||
private static JsonSchema definition(JsonNode schema, String name) {
|
||||
ObjectNode wrapper = JSON.createObjectNode()
|
||||
.put("$schema", schema.required("$schema").textValue())
|
||||
.put("$ref", "#/$defs/" + name);
|
||||
wrapper.set("$defs", schema.required("$defs"));
|
||||
return SCHEMAS.getSchema(wrapper);
|
||||
}
|
||||
|
||||
private static String id(JsonNode fixture) {
|
||||
return fixture.required("id").textValue();
|
||||
}
|
||||
|
||||
private static JsonNode fixtures() throws IOException {
|
||||
return read("managed-extension-record-v1.fixtures.json");
|
||||
}
|
||||
|
||||
private static JsonNode read(String name) throws IOException {
|
||||
Path path = CONTRACT_DIR.resolve(name);
|
||||
assertTrue(Files.isRegularFile(path),
|
||||
() -> "missing shared contract: " + path);
|
||||
return JSON.readTree(path.toFile());
|
||||
}
|
||||
|
||||
private static Path findContractDirectory() {
|
||||
Path current = Path.of(System.getProperty("user.dir"))
|
||||
.toAbsolutePath();
|
||||
for (int depth = 0; depth < 6 && current != null; depth++) {
|
||||
Path candidate = current.resolve(Path.of("packages", "core",
|
||||
"src", "managed-runtime", "contracts"));
|
||||
if (Files.isDirectory(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
current = current.getParent();
|
||||
}
|
||||
throw new AssertionError(
|
||||
"cannot locate the shared managed-runtime contracts");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue