@miadi/connector 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/dist/episode-files.d.ts +9 -0
- package/dist/episode-files.js +13 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/mission-status.d.ts +121 -15
- package/dist/mission-status.js +198 -27
- package/dist/model-preflight.d.ts +144 -0
- package/dist/model-preflight.js +323 -0
- package/dist/providers.d.ts +29 -3
- package/dist/providers.js +32 -11
- package/dist/server.d.ts +5 -1
- package/dist/server.js +2 -0
- package/dist/tools.d.ts +12 -1
- package/dist/tools.js +171 -24
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,13 +20,14 @@ Call `get_capabilities` first. It names the signed-in person, their Miadi API ti
|
|
|
20
20
|
|
|
21
21
|
| kind | tools |
|
|
22
22
|
|---|---|
|
|
23
|
-
| reads | `get_capabilities` `get_context` `list_circles` `get_circle` `list_sessions` `get_session` `get_evidence` `read_episode_file` `get_evaluations` `get_narrative_beats` `review_lookup` `review_versions` `handoff_status` `list_records` `get_run_result` `mission_status` `provider_capabilities` `search` `fetch` |
|
|
23
|
+
| reads | `get_capabilities` `get_context` `list_circles` `get_circle` `list_sessions` `get_session` `get_evidence` `read_episode_file` `get_evaluations` `get_narrative_beats` `review_lookup` `review_versions` `handoff_status` `list_records` `get_run_result` `mission_status` `provider_capabilities` `mission_model_preflight` `search` `fetch` |
|
|
24
24
|
| contributions | `append_observation` `open_inquiry` `link_records` `propose_narrative_beat` `record_evaluation` `register_source_intent` `record_handoff` `update_handoff` `prepare_calendar_event` `save_research` |
|
|
25
25
|
| requests (approved by a person) | `propose_work` `propose_session_completion` `request_authorized_run` `request_session_steer` `request_review_operation` `request_deep_diver` `link_review_to_episode` `request_inquiry_episode` |
|
|
26
26
|
| decisions in the conversation | `show_decisions` (cards), `decide_request` (called by the card only) |
|
|
27
27
|
|
|
28
28
|
- **Reads** need Miadi's read tier and stay within it. A person Miadi gives no API tier reaches only `get_capabilities`. Sessions need the write tier, because a session's name and pane are operator data. For the same reason, below the write tier a record someone else made shows no session, working folder or seat, and no long bodies.
|
|
29
29
|
- **Episode files.** `get_evidence` lists an episode's files; each text file Miadi can preview carries a `read_ref` (`miadi-chronicle://<n>/<path>`). `read_episode_file` (or `fetch` with that ref) returns the file's whole UTF-8 text, read as the person through Miadi's file door, so Miadi's audience and room rules apply. The answer names the episode, the path, the content type, the byte count and the sha256, and `verified` says whether the text received hashes to what Miadi read. Only md, txt, yaml, yml and json files up to 512 KiB are read. A traversal, absolute, hidden or encoded path is refused before any URL is built. A longer text comes in pages cut on character boundaries: pass `page.next` back, and the joined pages hash to `sha256`. Other kinds answer `unsupported`, a file over the limit `too_large`, a missing one `not_found`, a ceremony note below the write gate `forbidden`.
|
|
30
|
+
- **One narrow grant.** A person with no Miadi API tier who holds `chronicle_foundations_read` (ticked by an admin under /admin → People → Additional permissions; admins hold it through their role) reaches `read_episode_file` for foundation documents only: `foundations/<topic>/*.md`, read through Miadi's `GET /api/chronicle/episodes/<ref>/foundations/<topic>/<file>.md`, which checks the permission on every read. Naming the episode alone lists them. Every other file and every other tool stays refused, and the permission gives no API tier. Unticking it refuses the next read. `get_capabilities` reports it under `foundation_read`, from Miadi's answer at that moment.
|
|
30
31
|
- **Contributions** need Miadi's read tier and the `miadi:contribute` scope, chosen at sign-in. A record is moved on (`update_handoff`) only by its author or the write tier, and a request's receipt comes only from its decision. They append a record to the ledger and change nothing else, except `save_research`: it saves a report on the inquiry shelf as a new folder (the Markdown exactly as given, owner and visibility recorded, private by default), opens the folder's inquiry issue, and commits and pushes the folder in the shelf's repository. A long report is sent in parts (`part`, `parts`, `upload_id`) and saved when the last part arrives; the answer returns its bytes, sha256 and first and last lines.
|
|
31
32
|
- **Requests** append a record that waits for a decision. The answer gives an `approval_url`; Miadi's `/research` page offers the same decisions to a signed-in person with the write tier, and `show_decisions` shows them as cards inside the conversation (below). The person approves, approves with changes (their instructions are carried into what runs), sends it back for revision with instructions (`revision_requested`, nothing runs, `get_run_result` returns the instructions), or declines. Only the approval door runs a request, once, as that person; a second decision on the same request, at the same time or later, is refused:
|
|
32
33
|
- a run becomes a brief in the factory queue, which the intake delivers to a session;
|
|
@@ -45,9 +46,13 @@ The model that asked cannot approve its own request.
|
|
|
45
46
|
|
|
46
47
|
`mission_status` joins one mission read-only: its source request and decision, the sessions bound to it (each with how it is bound — a signed `session:` ref, the delivered brief's target, or the requested name), its issues and PRs, its steering grant, and where it stands stage by stage — approved, queued, delivered, running, merged, released, closed — each by its evidence, so an idle pane or a merge is never read as completion. Live panes and Miadi's session view are joined only for the write tier.
|
|
47
48
|
|
|
49
|
+
A mission is found by the issue it implements (jgwill/Miadi#913). `request_authorized_run` and `propose_work` take `primary_issue`; older requests are read by their first issue ref, as an inference. An issue held as primary by several requests answers `ambiguous_mission` with the candidates, an issue only cited answers `related_only`, and only a record id or a declared primary that no older request holds by inference (`contested`) authorizes a steer, release or closure (`identity.authorizes_actions`). A release receipt names artifact, target, production check and check time; record it as `completion_receipt.release` on a handoff that cites the mission. `released` is `verified` only when the connector confirms it itself (the target is `miadi-connector` and the running version is the one released); any other complete receipt within seven days is `receipt_recorded`, with who recorded it and whether that was the approver. Old checks are `stale`; a version string, a future date or an incomplete receipt is `claimed_unverified`. `closed` needs an approved `propose_session_completion` ending (`exit` or `complete_then_exit`) that names the mission in its `mission` field and ends one of the mission's sessions; a completed request is `request_completed`. A GitHub reference the connector cannot read is `unverified`, with the reason (`no_github_token`, `not_visible_to_token`…), never `not_found`.
|
|
50
|
+
|
|
48
51
|
`request_session_steer` sends one authorized follow-up to one existing live agent, within the steering grant an approved run carries (`steering: { max_attempts, expires_at, pane }` on `request_authorized_run`). It peeks at the bound pane — the grant's pane or the session's first pane, never whichever pane is active — refuses a busy agent or a pane that is not an agent, types the exact instruction literally, and re-peeks to verify the agent accepted it (`accepted`) or only holds it at the prompt (`submitted`). Every attempt leaves a durable artifact with the instruction's sha256, its grant, its target and both captures. A steer the grant does not cover waits for one explicit scoped human decision on its `approval_url`, which runs exactly that instruction once.
|
|
49
52
|
|
|
50
|
-
`request_authorized_run` may name a `provider` (`codex`, `pi`, `copilot`, `claude`). The connector verifies it is installed on the host before recording anything; an unknown or unavailable provider answers `provider_unavailable` and the mission stays unstarted. With no provider named, the first installed provider in the preference order is resolved and named in the brief; Claude Code is never chosen silently — it runs only when named. `provider_capabilities` (write tier) reports what the host has installed, the sign-in evidence, the preference order, and the read-only Jev
|
|
53
|
+
`request_authorized_run` may name a `provider` (`codex`, `pi`, `copilot`, `claude`). The connector verifies it is installed on the host before recording anything; an unknown or unavailable provider answers `provider_unavailable` and the mission stays unstarted. With no provider named, the first installed provider in the preference order is resolved and named in the brief; Claude Code is never chosen silently — it runs only when named. `provider_capabilities` (write tier) reports what the host has installed, the sign-in evidence, the preference order, and the read-only Jev check: `unconfigured` without `JEV_AI_API_KEY` or `TYPESAFE_API_KEY` in the connector's environment, else the models Jev lists for the key (no charge). A provider name inside a task's text selects nothing.
|
|
54
|
+
|
|
55
|
+
`mission_model_preflight` (write tier) answers, for one mission, which provider and model fit its task type and criticality, and how that is known: contract `miadi.mission_model_preflight` v1, candidates with their configured default model, sign-in evidence (config directory only), quota `unknown`, policy eligibility, a review floor, and a recommendation or an abstention with reasons. Its `decider.source` is `static_fallback`, `jev` or `synthetic_fixture`. Jev is asked only with `consult_jev: true` and a key on the host; it is sent task type, criticality and candidates, never task text, mission id or repository, and its answer is advisory: uncalibrated, below 0.8 or outside the contract, the preflight abstains. A reported `launch` on another provider than the approved one answers `provider_mismatch` before anything is asked. It authorizes nothing. `bench/model-preflight-bench.mjs` compares the 0.1.12 baseline, the static preflight and Jev on synthetic scenarios; `bench/results/` keeps the runs.
|
|
51
56
|
|
|
52
57
|
### Deciding inside the conversation
|
|
53
58
|
|
package/dist/episode-files.d.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
/** The extensions Miadi's vessel reads as text (@medicine-wheel/episode-vessel TEXT_EXTENSIONS). */
|
|
2
2
|
export declare const TEXT_TYPES: Record<string, string>;
|
|
3
|
+
/**
|
|
4
|
+
* The one Miadi permission that reads foundation documents without an API
|
|
5
|
+
* tier (jgwill/Miadi#905). Miadi checks it on every read at
|
|
6
|
+
* GET /api/chronicle/episodes/<ref>/foundations/<topic>/<file>.md.
|
|
7
|
+
*/
|
|
8
|
+
export declare const FOUNDATIONS_PERMISSION = "chronicle_foundations_read";
|
|
9
|
+
/** `foundations/<topic>/…/<file>.md`: what that permission reads, and nothing else. */
|
|
10
|
+
export declare function isFoundationDoc(segments: string[]): boolean;
|
|
11
|
+
export declare function holdsFoundationRead(permissions: readonly string[] | undefined): boolean;
|
|
3
12
|
/** The vessel's own limit for reading text whole (MAX_TEXT_BYTES). */
|
|
4
13
|
export declare const MAX_TEXT_BYTES: number;
|
|
5
14
|
export declare const DEFAULT_PAGE_BYTES = 100000;
|
package/dist/episode-files.js
CHANGED
|
@@ -21,6 +21,19 @@ export const TEXT_TYPES = {
|
|
|
21
21
|
".yml": "application/yaml; charset=utf-8",
|
|
22
22
|
".json": "application/json; charset=utf-8",
|
|
23
23
|
};
|
|
24
|
+
/**
|
|
25
|
+
* The one Miadi permission that reads foundation documents without an API
|
|
26
|
+
* tier (jgwill/Miadi#905). Miadi checks it on every read at
|
|
27
|
+
* GET /api/chronicle/episodes/<ref>/foundations/<topic>/<file>.md.
|
|
28
|
+
*/
|
|
29
|
+
export const FOUNDATIONS_PERMISSION = "chronicle_foundations_read";
|
|
30
|
+
/** `foundations/<topic>/…/<file>.md`: what that permission reads, and nothing else. */
|
|
31
|
+
export function isFoundationDoc(segments) {
|
|
32
|
+
return segments.length >= 3 && segments[0] === "foundations" && extensionOf(segments.join("/")) === ".md";
|
|
33
|
+
}
|
|
34
|
+
export function holdsFoundationRead(permissions) {
|
|
35
|
+
return Boolean(permissions?.includes("*") || permissions?.includes(FOUNDATIONS_PERMISSION));
|
|
36
|
+
}
|
|
24
37
|
/** The vessel's own limit for reading text whole (MAX_TEXT_BYTES). */
|
|
25
38
|
export const MAX_TEXT_BYTES = 512 * 1024;
|
|
26
39
|
export const DEFAULT_PAGE_BYTES = 100_000;
|
package/dist/index.d.ts
CHANGED
|
@@ -5,7 +5,8 @@ export { MiadiOAuthProvider, SCOPES, SCOPE_READ, SCOPE_CONTRIBUTE, type Person }
|
|
|
5
5
|
export { CIRCLES, CIRCLE_IDS, CLAIM_CATEGORIES, DOMAINS, HANDOFF_STATES, RECORD_KINDS, current, type ConnectorRecord, type CircleId, type ClaimCategory, type RecordKind, } from "./records.js";
|
|
6
6
|
export { TOOL_STATUS } from "./tools.js";
|
|
7
7
|
export { runBrief, readQueueState } from "./approve.js";
|
|
8
|
-
export { PROVIDERS, discoverProviders, resolveProvider, routerPreflight, type ProviderAvailability, type ProviderId, type ProviderResolution, type RouterPreflight, } from "./providers.js";
|
|
9
|
-
export { missionStatus, resolveMissionSubject, type MissionStatus, type MissionStatusDeps } from "./mission-status.js";
|
|
8
|
+
export { PROVIDERS, discoverProviders, jevConfigOf, resolveProvider, routerPreflight, type JevConfig, type ProviderAvailability, type ProviderId, type ProviderResolution, type RouterPreflight, } from "./providers.js";
|
|
9
|
+
export { missionStatus, primaryIssueOf, resolveMissionSubject, type MissionStatus, type MissionStatusDeps, type MissionIdentity } from "./mission-status.js";
|
|
10
|
+
export { modelPreflight, candidatesFor, buildJevRequest, decisionState, jevAdapter, reviewFloor, PREFLIGHT_CONTRACT, PREFLIGHT_VERSION, JEV_CONFIDENCE_FLOOR, type ModelPreflight, type ModelCandidate, type JevAdapter, type JevRequest, } from "./model-preflight.js";
|
|
10
11
|
export { AGENT_COMMANDS, BUSY_PATTERNS, STEER_STATUSES, checkGrant, isBusy, readSteerArtifact, steerAttempts, steerSession, steeringGrantOf, tmuxAdapter, type SteerDeps, type SteerResult, type SteerStatus, type SteeringGrant, type TmuxLike, } from "./steer.js";
|
|
11
12
|
export { VERSION } from "./version.js";
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,8 @@ export { MiadiOAuthProvider, SCOPES, SCOPE_READ, SCOPE_CONTRIBUTE } from "./oaut
|
|
|
5
5
|
export { CIRCLES, CIRCLE_IDS, CLAIM_CATEGORIES, DOMAINS, HANDOFF_STATES, RECORD_KINDS, current, } from "./records.js";
|
|
6
6
|
export { TOOL_STATUS } from "./tools.js";
|
|
7
7
|
export { runBrief, readQueueState } from "./approve.js";
|
|
8
|
-
export { PROVIDERS, discoverProviders, resolveProvider, routerPreflight, } from "./providers.js";
|
|
9
|
-
export { missionStatus, resolveMissionSubject } from "./mission-status.js";
|
|
8
|
+
export { PROVIDERS, discoverProviders, jevConfigOf, resolveProvider, routerPreflight, } from "./providers.js";
|
|
9
|
+
export { missionStatus, primaryIssueOf, resolveMissionSubject } from "./mission-status.js";
|
|
10
|
+
export { modelPreflight, candidatesFor, buildJevRequest, decisionState, jevAdapter, reviewFloor, PREFLIGHT_CONTRACT, PREFLIGHT_VERSION, JEV_CONFIDENCE_FLOOR, } from "./model-preflight.js";
|
|
10
11
|
export { AGENT_COMMANDS, BUSY_PATTERNS, STEER_STATUSES, checkGrant, isBusy, readSteerArtifact, steerAttempts, steerSession, steeringGrantOf, tmuxAdapter, } from "./steer.js";
|
|
11
12
|
export { VERSION } from "./version.js";
|
package/dist/mission-status.d.ts
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Read-only mission status (jgwill/Miadi#893, Phase 1A): where
|
|
3
|
-
* stands, joined from the connector ledger, the factory queue, tmux,
|
|
4
|
-
* session view and GitHub — with every stage named by its evidence, so an
|
|
5
|
-
* pane or a
|
|
2
|
+
* Read-only mission status (jgwill/Miadi#893, Phase 1A; corrected by #913): where
|
|
3
|
+
* one mission stands, joined from the connector ledger, the factory queue, tmux,
|
|
4
|
+
* Miadi's session view and GitHub — with every stage named by its evidence, so an
|
|
5
|
+
* idle pane, a merge, a version string or a completed request is never read as
|
|
6
|
+
* release or closure.
|
|
7
|
+
*
|
|
8
|
+
* Mission identity (#913 A). A record's *primary* issue is the one it implements:
|
|
9
|
+
* `body.primary_issue` when the requester declared it (provenance `declared`),
|
|
10
|
+
* else, for records made before that field, the first owner/repo#n in its refs
|
|
11
|
+
* (provenance `first_issue_ref`, an inference). Every other issue it cites is
|
|
12
|
+
* *related*. An issue or session reference resolves to one mission only when one
|
|
13
|
+
* candidate holds it as primary; several are `ambiguous_mission`, and a reference
|
|
14
|
+
* cited only as related is `related_only`. Newest-first selection is gone.
|
|
15
|
+
* Only `exact` (a record id) and `declared_primary` authorize a steer, release or
|
|
16
|
+
* closure against the answer; an inference names its candidate and authorizes nothing.
|
|
6
17
|
*
|
|
7
18
|
* The caller (tools.ts) decides the viewer's tier first: without the write
|
|
8
19
|
* tier there are no operator details (live panes, session internals); the
|
|
@@ -19,20 +30,96 @@ export interface MissionSession {
|
|
|
19
30
|
/** Present when Miadi's session view was joined (write tier). */
|
|
20
31
|
miadi?: unknown;
|
|
21
32
|
}
|
|
33
|
+
/** What the connector could establish about a GitHub reference. `unverified` is never `not_found`: without access the connector cannot tell. */
|
|
34
|
+
export interface IssueLookup {
|
|
35
|
+
state: string;
|
|
36
|
+
title?: string;
|
|
37
|
+
merged?: boolean;
|
|
38
|
+
/** Why the state is `unverified`: no_github_token, not_visible_to_token, token_rejected, forbidden_or_rate_limited, github_<status>, unreachable. */
|
|
39
|
+
access?: string;
|
|
40
|
+
}
|
|
41
|
+
/** A release receipt: what was released, where, and how production was checked, when. */
|
|
42
|
+
export interface ReleaseEvidence {
|
|
43
|
+
artifact: string;
|
|
44
|
+
target: string;
|
|
45
|
+
verified_at: string;
|
|
46
|
+
verification: string;
|
|
47
|
+
/** The record that carries it and who made it. */
|
|
48
|
+
record: string;
|
|
49
|
+
by: string;
|
|
50
|
+
/** The receipt was recorded by the person who approved the mission. */
|
|
51
|
+
by_approver: boolean;
|
|
52
|
+
/** The version the connector itself is running, when the receipt's target is the connector. */
|
|
53
|
+
live_version?: string;
|
|
54
|
+
}
|
|
55
|
+
export interface ClosureEvidence {
|
|
56
|
+
record: string;
|
|
57
|
+
ending: string;
|
|
58
|
+
session_id: string;
|
|
59
|
+
/** The ending names one of the mission's own sessions. False when the mission names none to compare. */
|
|
60
|
+
session_bound: boolean;
|
|
61
|
+
decided_by?: string;
|
|
62
|
+
decided_at?: string;
|
|
63
|
+
}
|
|
22
64
|
export interface MissionStageTruth {
|
|
23
65
|
approved: boolean;
|
|
66
|
+
/** The decision as taken: approve or approve_with_changes, the approver's instructions and what is still outstanding. */
|
|
67
|
+
approval: {
|
|
68
|
+
state: string;
|
|
69
|
+
choice?: string;
|
|
70
|
+
instructions?: string;
|
|
71
|
+
outstanding: string[];
|
|
72
|
+
};
|
|
24
73
|
queued: boolean;
|
|
25
74
|
delivered: boolean;
|
|
26
75
|
/** "evidence" only while a bound session is live; an idle pane proves nothing. */
|
|
27
76
|
running: "evidence" | "no_evidence";
|
|
28
|
-
|
|
29
|
-
|
|
77
|
+
/** The run_request or work_proposal finished. This is the request, not the mission. */
|
|
78
|
+
request_completed: boolean;
|
|
79
|
+
/** "unverified": GitHub could not be read, so no merge state is claimed either way. */
|
|
80
|
+
pr_merged: "merged" | "open_or_absent" | "no_pr" | "unverified";
|
|
81
|
+
/**
|
|
82
|
+
* verified: the connector itself confirmed it: the receipt targets miadi-connector and the running version
|
|
83
|
+
* is the one released. receipt_recorded: a complete, fresh receipt (artifact, target, production check, time)
|
|
84
|
+
* recorded by the named person; the connector cannot check it. stale: the check is older than the freshness
|
|
85
|
+
* window. live_mismatch: the connector runs another version than its receipt names. claimed_unverified: a
|
|
86
|
+
* version, a published flag, an incomplete or future-dated release object. no_evidence: nothing.
|
|
87
|
+
*/
|
|
88
|
+
released: "verified" | "receipt_recorded" | "stale" | "live_mismatch" | "claimed_unverified" | "no_evidence";
|
|
89
|
+
release_evidence?: ReleaseEvidence;
|
|
90
|
+
/** True only with an approved ending (exit or complete_then_exit) naming this mission in its `mission` field, for one of its sessions. A completed request is not a closure. */
|
|
30
91
|
closed: boolean;
|
|
92
|
+
closure_evidence?: ClosureEvidence;
|
|
93
|
+
}
|
|
94
|
+
export type MissionResolution = "exact" | "declared_primary" | "inferred_primary" | "ambiguous_mission" | "related_only";
|
|
95
|
+
export interface MissionCandidate {
|
|
96
|
+
id: string;
|
|
97
|
+
kind: string;
|
|
98
|
+
created_at: string;
|
|
99
|
+
/** primary: the reference is this record's primary issue. related: an issue it cites and does not implement. cited: a session it cites. */
|
|
100
|
+
relation: "primary" | "related" | "cited";
|
|
101
|
+
/** declared: body.primary_issue. first_issue_ref: inferred from the first issue in refs. cited_ref: the reference is in its refs. */
|
|
102
|
+
provenance: "declared" | "first_issue_ref" | "cited_ref";
|
|
103
|
+
primary_issue?: string;
|
|
104
|
+
}
|
|
105
|
+
export interface MissionIdentity {
|
|
106
|
+
resolution: MissionResolution;
|
|
107
|
+
/** A declared primary that another request also holds by inference: reported, not authorizing. */
|
|
108
|
+
contested?: boolean;
|
|
109
|
+
/** True only for exact and an uncontested declared_primary: a steer, release or closure may name this mission. */
|
|
110
|
+
authorizes_actions: boolean;
|
|
111
|
+
primary_issue?: {
|
|
112
|
+
ref: string;
|
|
113
|
+
provenance: "declared" | "first_issue_ref";
|
|
114
|
+
};
|
|
115
|
+
/** Every request citing the reference, newest first, with its relation. Empty for an exact id. */
|
|
116
|
+
candidates: MissionCandidate[];
|
|
31
117
|
}
|
|
32
118
|
export interface MissionStatus {
|
|
33
119
|
ref: string;
|
|
34
120
|
found: boolean;
|
|
35
121
|
via?: string;
|
|
122
|
+
identity?: MissionIdentity;
|
|
36
123
|
subject?: {
|
|
37
124
|
id: string;
|
|
38
125
|
kind: string;
|
|
@@ -48,9 +135,11 @@ export interface MissionStatus {
|
|
|
48
135
|
sessions?: MissionSession[];
|
|
49
136
|
issues?: {
|
|
50
137
|
ref: string;
|
|
138
|
+
relation: "primary" | "related";
|
|
51
139
|
state: string;
|
|
52
140
|
title?: string;
|
|
53
141
|
merged?: boolean;
|
|
142
|
+
access?: string;
|
|
54
143
|
}[];
|
|
55
144
|
steering?: {
|
|
56
145
|
grant: "none" | "active" | "expired_grant" | "exhausted";
|
|
@@ -72,16 +161,33 @@ export interface MissionStatusDeps {
|
|
|
72
161
|
paneBusy?: (session: string) => "busy" | "idle" | "unknown";
|
|
73
162
|
/** Miadi's session view by session:<uuid> (write tier only). */
|
|
74
163
|
sessionOf?: (id: string) => Promise<unknown>;
|
|
75
|
-
issueOf?: (owner: string, repo: string, n: string) => Promise<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
164
|
+
issueOf?: (owner: string, repo: string, n: string) => Promise<IssueLookup>;
|
|
165
|
+
/** The version a package runs at on this host, when the connector can know it (its own VERSION). */
|
|
166
|
+
liveVersionOf?: (pkg: string) => string | undefined;
|
|
167
|
+
/** How old a production check may be and still count as verified. Default seven days. */
|
|
168
|
+
releaseFreshnessMs?: number;
|
|
80
169
|
now?: () => Date;
|
|
81
170
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
171
|
+
export declare const RELEASE_FRESHNESS_MS: number;
|
|
172
|
+
/** The issue a mission implements, and how that is known. */
|
|
173
|
+
export declare function primaryIssueOf(record: ConnectorRecord): {
|
|
174
|
+
ref: string;
|
|
175
|
+
provenance: "declared" | "first_issue_ref";
|
|
176
|
+
} | undefined;
|
|
177
|
+
export interface ResolvedSubject {
|
|
178
|
+
record?: ConnectorRecord;
|
|
85
179
|
via: string;
|
|
86
|
-
|
|
180
|
+
resolution: MissionResolution;
|
|
181
|
+
contested?: boolean;
|
|
182
|
+
candidates: MissionCandidate[];
|
|
183
|
+
}
|
|
184
|
+
/** Whether a resolved reference may be acted on: an exact id, or a declared primary nobody else holds. */
|
|
185
|
+
export declare function authorizesActions(found: Pick<ResolvedSubject, "resolution" | "contested">): boolean;
|
|
186
|
+
/**
|
|
187
|
+
* The record a mission reference resolves to, how it was found, and every other
|
|
188
|
+
* candidate. `record` is absent when the reference is ambiguous or only related.
|
|
189
|
+
* Expects the folded ledger (`current()`), so a superseding record never stands
|
|
190
|
+
* as a mission of its own.
|
|
191
|
+
*/
|
|
192
|
+
export declare function resolveMissionSubject(ref: string, records: ConnectorRecord[]): ResolvedSubject | null;
|
|
87
193
|
export declare function missionStatus(ref: string, records: ConnectorRecord[], deps: MissionStatusDeps): Promise<MissionStatus>;
|
package/dist/mission-status.js
CHANGED
|
@@ -1,27 +1,69 @@
|
|
|
1
1
|
import { steerAttempts, steeringGrantOf, checkGrant } from "./steer.js";
|
|
2
2
|
const ISSUE_REF = /^([\w.-]+)\/([\w.-]+)#(\d+)$/;
|
|
3
3
|
const SESSION_REF = /^session:([0-9a-f-]{36})$/;
|
|
4
|
-
|
|
4
|
+
const MISSION_KINDS = new Set(["run_request", "work_proposal"]);
|
|
5
|
+
const CLOSING_ENDINGS = new Set(["exit", "complete_then_exit"]);
|
|
6
|
+
export const RELEASE_FRESHNESS_MS = 7 * 24 * 3600_000;
|
|
7
|
+
const CLOCK_SKEW_MS = 5 * 60_000;
|
|
8
|
+
/** A release receipt whose target the connector can check itself. */
|
|
9
|
+
const SELF_TARGET = /miadi-connector/;
|
|
10
|
+
/** The issue a mission implements, and how that is known. */
|
|
11
|
+
export function primaryIssueOf(record) {
|
|
12
|
+
const declared = record.body?.primary_issue;
|
|
13
|
+
if (typeof declared === "string" && ISSUE_REF.test(declared))
|
|
14
|
+
return { ref: declared, provenance: "declared" };
|
|
15
|
+
const first = record.refs.find((r) => ISSUE_REF.test(r));
|
|
16
|
+
return first ? { ref: first, provenance: "first_issue_ref" } : undefined;
|
|
17
|
+
}
|
|
18
|
+
const newestFirst = (a, b) => b.created_at.localeCompare(a.created_at);
|
|
19
|
+
function candidate(r, relation, provenance) {
|
|
20
|
+
const primary = primaryIssueOf(r);
|
|
21
|
+
return { id: r.id, kind: r.kind, created_at: r.created_at, relation, provenance, ...(primary ? { primary_issue: primary.ref } : {}) };
|
|
22
|
+
}
|
|
23
|
+
/** Whether a resolved reference may be acted on: an exact id, or a declared primary nobody else holds. */
|
|
24
|
+
export function authorizesActions(found) {
|
|
25
|
+
return found.resolution === "exact" || (found.resolution === "declared_primary" && !found.contested);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The record a mission reference resolves to, how it was found, and every other
|
|
29
|
+
* candidate. `record` is absent when the reference is ambiguous or only related.
|
|
30
|
+
* Expects the folded ledger (`current()`), so a superseding record never stands
|
|
31
|
+
* as a mission of its own.
|
|
32
|
+
*/
|
|
5
33
|
export function resolveMissionSubject(ref, records) {
|
|
6
34
|
const exact = records.find((r) => r.id === ref);
|
|
7
35
|
if (exact)
|
|
8
|
-
return { record: exact, via: "record_id" };
|
|
9
|
-
const
|
|
10
|
-
if (
|
|
11
|
-
const citing =
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
if (citing.length)
|
|
15
|
-
return { record: citing[0], via: "session_ref" };
|
|
16
|
-
return
|
|
36
|
+
return { record: exact, via: "record_id", resolution: "exact", candidates: [] };
|
|
37
|
+
const missions = records.filter((r) => MISSION_KINDS.has(r.kind)).sort(newestFirst);
|
|
38
|
+
if (SESSION_REF.test(ref)) {
|
|
39
|
+
const citing = missions.filter((r) => r.refs.includes(ref));
|
|
40
|
+
if (!citing.length)
|
|
41
|
+
return null;
|
|
42
|
+
if (citing.length === 1)
|
|
43
|
+
return { record: citing[0], via: "session_ref", resolution: "inferred_primary", candidates: [candidate(citing[0], "cited", "cited_ref")] };
|
|
44
|
+
return { via: "session_ref", resolution: "ambiguous_mission", candidates: citing.map((r) => candidate(r, "cited", "cited_ref")) };
|
|
17
45
|
}
|
|
18
46
|
if (ISSUE_REF.test(ref)) {
|
|
19
|
-
const citing =
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
47
|
+
const citing = missions.filter((r) => r.refs.includes(ref) || r.evidence_refs.includes(ref));
|
|
48
|
+
if (!citing.length)
|
|
49
|
+
return null;
|
|
50
|
+
const cands = citing.map((r) => {
|
|
51
|
+
const primary = primaryIssueOf(r);
|
|
52
|
+
return primary?.ref === ref ? candidate(r, "primary", primary.provenance) : candidate(r, "related", "cited_ref");
|
|
53
|
+
});
|
|
54
|
+
const declared = cands.filter((c) => c.relation === "primary" && c.provenance === "declared");
|
|
55
|
+
const inferred = cands.filter((c) => c.relation === "primary" && c.provenance === "first_issue_ref");
|
|
56
|
+
const pick = (c) => citing.find((r) => r.id === c.id);
|
|
57
|
+
if (declared.length === 1) {
|
|
58
|
+
return { record: pick(declared[0]), via: "issue_ref", resolution: "declared_primary", ...(inferred.length ? { contested: true } : {}), candidates: cands };
|
|
59
|
+
}
|
|
60
|
+
if (declared.length > 1)
|
|
61
|
+
return { via: "issue_ref", resolution: "ambiguous_mission", candidates: cands };
|
|
62
|
+
if (inferred.length === 1)
|
|
63
|
+
return { record: pick(inferred[0]), via: "issue_ref", resolution: "inferred_primary", candidates: cands };
|
|
64
|
+
if (inferred.length > 1)
|
|
65
|
+
return { via: "issue_ref", resolution: "ambiguous_mission", candidates: cands };
|
|
66
|
+
return { via: "issue_ref", resolution: "related_only", candidates: cands };
|
|
25
67
|
}
|
|
26
68
|
return null;
|
|
27
69
|
}
|
|
@@ -48,12 +90,108 @@ function nextAction(record, standing, queue) {
|
|
|
48
90
|
: "The request is done. A mission closes when its session's ending is decided, not when this request completes.";
|
|
49
91
|
}
|
|
50
92
|
}
|
|
93
|
+
const str = (v) => (typeof v === "string" ? v.trim() : "");
|
|
94
|
+
/** name@version from "npm:@scope/name@1.2.3" or "@scope/name@1.2.3". */
|
|
95
|
+
function artifactVersion(artifact) {
|
|
96
|
+
const m = /^(?:npm:)?((?:@[\w.-]+\/)?[\w.-]+)@(\d[\w.+-]*)$/.exec(artifact.trim());
|
|
97
|
+
return m ? { pkg: m[1], version: m[2] } : undefined;
|
|
98
|
+
}
|
|
99
|
+
/** Records that carry evidence about this mission: itself, then any record citing its id, newest first. */
|
|
100
|
+
function evidenceRecords(record, records) {
|
|
101
|
+
const citing = records.filter((r) => r.id !== record.id && (r.refs.includes(record.id) || r.evidence_refs.includes(record.id))).sort(newestFirst);
|
|
102
|
+
return [record, ...citing];
|
|
103
|
+
}
|
|
104
|
+
function releaseTruth(record, records, deps, now) {
|
|
105
|
+
let claimed = false;
|
|
106
|
+
for (const r of evidenceRecords(record, records)) {
|
|
107
|
+
const receipt = (r.completion_receipt ?? {});
|
|
108
|
+
const rel = (receipt.release ?? r.body?.release);
|
|
109
|
+
if (rel && typeof rel === "object") {
|
|
110
|
+
const ev = { artifact: str(rel.artifact), target: str(rel.target), verified_at: str(rel.verified_at), verification: str(rel.verification) };
|
|
111
|
+
const at = Date.parse(ev.verified_at);
|
|
112
|
+
if (!ev.artifact || !ev.target || !ev.verification || Number.isNaN(at) || at > now.getTime() + CLOCK_SKEW_MS) {
|
|
113
|
+
claimed = true;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const approver = str((record.completion_receipt ?? {}).decided_by?.person_id);
|
|
117
|
+
const evidence = { ...ev, record: r.id, by: r.actor.name, by_approver: Boolean(approver) && r.actor.person_id === approver };
|
|
118
|
+
const av = artifactVersion(ev.artifact);
|
|
119
|
+
const self = av && SELF_TARGET.test(ev.target) ? deps.liveVersionOf?.(av.pkg) : undefined;
|
|
120
|
+
if (av && self !== undefined) {
|
|
121
|
+
evidence.live_version = self;
|
|
122
|
+
if (self !== av.version)
|
|
123
|
+
return { released: "live_mismatch", release_evidence: evidence };
|
|
124
|
+
}
|
|
125
|
+
if (now.getTime() - at > (deps.releaseFreshnessMs ?? RELEASE_FRESHNESS_MS))
|
|
126
|
+
return { released: "stale", release_evidence: evidence };
|
|
127
|
+
return { released: self !== undefined ? "verified" : "receipt_recorded", release_evidence: evidence };
|
|
128
|
+
}
|
|
129
|
+
if (r.id === record.id && (receipt.published || receipt.version))
|
|
130
|
+
claimed = true;
|
|
131
|
+
}
|
|
132
|
+
return { released: claimed ? "claimed_unverified" : "no_evidence" };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The approved ending that closes this mission: it names the mission in its own `mission` field (one ending,
|
|
136
|
+
* one mission; a citation in refs is not enough) and, when the mission names sessions, ends one of them.
|
|
137
|
+
*/
|
|
138
|
+
function closureTruth(record, records, missionSessions) {
|
|
139
|
+
const endings = records
|
|
140
|
+
.filter((r) => {
|
|
141
|
+
const b = (r.body ?? {});
|
|
142
|
+
if (r.kind !== "session_completion_proposal" || r.approval_state !== "approved" || b.mission !== record.id)
|
|
143
|
+
return false;
|
|
144
|
+
if (!CLOSING_ENDINGS.has(str(b.ending)))
|
|
145
|
+
return false;
|
|
146
|
+
return missionSessions.size === 0 || missionSessions.has(str(b.session_id));
|
|
147
|
+
})
|
|
148
|
+
.sort(newestFirst);
|
|
149
|
+
const e = endings[0];
|
|
150
|
+
if (!e)
|
|
151
|
+
return undefined;
|
|
152
|
+
const receipt = (e.completion_receipt ?? {});
|
|
153
|
+
const sessionId = str(e.body.session_id);
|
|
154
|
+
return {
|
|
155
|
+
record: e.id,
|
|
156
|
+
ending: str(e.body.ending),
|
|
157
|
+
session_id: sessionId,
|
|
158
|
+
session_bound: missionSessions.has(sessionId),
|
|
159
|
+
...(str(receipt.decided_by?.name) ? { decided_by: str(receipt.decided_by.name) } : {}),
|
|
160
|
+
...(str(receipt.decided_at) ? { decided_at: str(receipt.decided_at) } : {}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
51
163
|
export async function missionStatus(ref, records, deps) {
|
|
52
164
|
const notes = [];
|
|
53
165
|
const found = resolveMissionSubject(ref, records);
|
|
54
166
|
if (!found)
|
|
55
167
|
return { ref, found: false, notes: ["No mission record resolves from this reference."] };
|
|
168
|
+
if (!found.record) {
|
|
169
|
+
const ambiguous = found.resolution === "ambiguous_mission";
|
|
170
|
+
return {
|
|
171
|
+
ref,
|
|
172
|
+
found: false,
|
|
173
|
+
via: found.via,
|
|
174
|
+
identity: { resolution: found.resolution, authorizes_actions: false, candidates: found.candidates },
|
|
175
|
+
next_authorized_action: "Name the mission by its record id. Nothing is steered, released or closed against this reference.",
|
|
176
|
+
notes: [
|
|
177
|
+
ambiguous
|
|
178
|
+
? "Several requests hold this reference as their primary mission; none is chosen for you, the newest included."
|
|
179
|
+
: "Requests cite this reference only as related; none implements it, so it names no mission.",
|
|
180
|
+
],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
56
183
|
const { record, via } = found;
|
|
184
|
+
const primary = primaryIssueOf(record);
|
|
185
|
+
const identity = {
|
|
186
|
+
resolution: found.resolution,
|
|
187
|
+
...(found.contested ? { contested: true } : {}),
|
|
188
|
+
authorizes_actions: authorizesActions(found),
|
|
189
|
+
...(primary ? { primary_issue: primary } : {}),
|
|
190
|
+
candidates: found.candidates,
|
|
191
|
+
};
|
|
192
|
+
if (found.resolution === "inferred_primary") {
|
|
193
|
+
notes.push(`Resolved by inference (${via === "session_ref" ? "the only request citing this session" : "the first issue in the request's refs"}). Name ${record.id} before any steer, release or closure.`);
|
|
194
|
+
}
|
|
57
195
|
const standing = deps.projectOf(record) ?? undefined;
|
|
58
196
|
const queue = deps.queueOf?.(record);
|
|
59
197
|
const now = deps.now?.() ?? new Date();
|
|
@@ -64,7 +202,6 @@ export async function missionStatus(ref, records, deps) {
|
|
|
64
202
|
const named = String(record.body.session ?? "") || undefined;
|
|
65
203
|
const seen = new Set();
|
|
66
204
|
const addSession = (session, association) => {
|
|
67
|
-
const key = `${association}:${session}`;
|
|
68
205
|
if (seen.has(session))
|
|
69
206
|
return;
|
|
70
207
|
seen.add(session);
|
|
@@ -74,7 +211,6 @@ export async function missionStatus(ref, records, deps) {
|
|
|
74
211
|
live: live ? (live.has(session) ? "running" : "absent") : "unknown",
|
|
75
212
|
...(deps.paneBusy && live?.has(session) ? { activity: deps.paneBusy(session) } : {}),
|
|
76
213
|
});
|
|
77
|
-
void key;
|
|
78
214
|
};
|
|
79
215
|
if (typeof briefTo === "string" && briefTo)
|
|
80
216
|
addSession(briefTo.replace(/^new:/, ""), "brief_target");
|
|
@@ -91,14 +227,22 @@ export async function missionStatus(ref, records, deps) {
|
|
|
91
227
|
notes.push("Live session state was not joined (the viewer's tier or the host does not allow it).");
|
|
92
228
|
const issues = [];
|
|
93
229
|
if (deps.issueOf) {
|
|
94
|
-
for (const r of [...record.refs, ...record.evidence_refs]) {
|
|
230
|
+
for (const r of [...new Set([...record.refs, ...record.evidence_refs])]) {
|
|
95
231
|
const m = ISSUE_REF.exec(r);
|
|
96
232
|
if (!m)
|
|
97
233
|
continue;
|
|
98
|
-
const issue = await deps
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
234
|
+
const issue = await deps.issueOf(m[1], m[2], m[3]).catch(() => ({ state: "unverified", access: "unreachable" }));
|
|
235
|
+
issues.push({
|
|
236
|
+
ref: r,
|
|
237
|
+
relation: primary?.ref === r ? "primary" : "related",
|
|
238
|
+
state: issue.state,
|
|
239
|
+
...(issue.title ? { title: issue.title } : {}),
|
|
240
|
+
...(issue.merged !== undefined ? { merged: issue.merged } : {}),
|
|
241
|
+
...(issue.access ? { access: issue.access } : {}),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
if (issues.some((i) => i.state === "unverified")) {
|
|
245
|
+
notes.push("Some GitHub references could not be read by the connector; they are unverified, not missing.");
|
|
102
246
|
}
|
|
103
247
|
}
|
|
104
248
|
const grant = steeringGrantOf(record);
|
|
@@ -108,22 +252,47 @@ export async function missionStatus(ref, records, deps) {
|
|
|
108
252
|
const receipt = (record.completion_receipt ?? {});
|
|
109
253
|
const anyLive = sessions.some((s) => s.live === "running");
|
|
110
254
|
const prs = issues.filter((i) => i.merged !== undefined);
|
|
255
|
+
const unverifiedRefs = issues.some((i) => i.state === "unverified");
|
|
256
|
+
const release = releaseTruth(record, records, deps, now);
|
|
257
|
+
const missionSessions = new Set(sessions.map((x) => x.session.replace(/^session:/, "")));
|
|
258
|
+
const closure = closureTruth(record, records, missionSessions);
|
|
259
|
+
const choice = standing?.decision?.choice ?? (str(receipt.decision) || undefined);
|
|
260
|
+
const instructions = standing?.decision?.instructions ?? (str(receipt.instructions) || undefined);
|
|
111
261
|
const stageTruth = {
|
|
112
262
|
approved: record.approval_state === "approved",
|
|
263
|
+
approval: {
|
|
264
|
+
state: record.approval_state ?? "not_required",
|
|
265
|
+
...(choice ? { choice } : {}),
|
|
266
|
+
...(instructions ? { instructions } : {}),
|
|
267
|
+
outstanding: standing?.outstanding ?? [],
|
|
268
|
+
},
|
|
113
269
|
queued: queue?.state === "queued" || queue?.state === "delivered",
|
|
114
270
|
delivered: queue?.state === "delivered",
|
|
115
271
|
running: anyLive ? "evidence" : "no_evidence",
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
272
|
+
request_completed: record.state === "completed",
|
|
273
|
+
pr_merged: unverifiedRefs ? "unverified" : prs.length ? (prs.every((p) => p.merged) ? "merged" : "open_or_absent") : "no_pr",
|
|
274
|
+
released: release.released,
|
|
275
|
+
...(release.release_evidence ? { release_evidence: release.release_evidence } : {}),
|
|
276
|
+
closed: Boolean(closure),
|
|
277
|
+
...(closure ? { closure_evidence: closure } : {}),
|
|
119
278
|
};
|
|
120
279
|
if (stageTruth.running === "no_evidence" && record.approval_state === "approved") {
|
|
121
280
|
notes.push("No bound session shows live; an idle or absent pane is not proof of completion or failure.");
|
|
122
281
|
}
|
|
282
|
+
if (stageTruth.request_completed && !stageTruth.closed) {
|
|
283
|
+
notes.push("The request completed; the mission stays open until an approved session ending cites it.");
|
|
284
|
+
}
|
|
285
|
+
if (stageTruth.pr_merged === "merged" && stageTruth.released !== "verified" && stageTruth.released !== "receipt_recorded") {
|
|
286
|
+
notes.push("Merged is not released: no fresh production check is recorded for this mission.");
|
|
287
|
+
}
|
|
288
|
+
if (found.contested) {
|
|
289
|
+
notes.push("This request declares the issue, and an earlier request holds it by inference; neither is acted on by issue. Name the record id.");
|
|
290
|
+
}
|
|
123
291
|
return {
|
|
124
292
|
ref,
|
|
125
293
|
found: true,
|
|
126
294
|
via,
|
|
295
|
+
identity,
|
|
127
296
|
subject: {
|
|
128
297
|
id: record.id,
|
|
129
298
|
kind: record.kind,
|
|
@@ -138,7 +307,9 @@ export async function missionStatus(ref, records, deps) {
|
|
|
138
307
|
issues,
|
|
139
308
|
steering: { grant: grantState, attempts_used: attempts, ...(grant?.max_attempts !== undefined ? { max_attempts: grant.max_attempts } : {}) },
|
|
140
309
|
stage_truth: stageTruth,
|
|
141
|
-
next_authorized_action:
|
|
310
|
+
next_authorized_action: identity.authorizes_actions
|
|
311
|
+
? nextAction(record, standing ?? null, queue)
|
|
312
|
+
: `Not authorizing (${found.contested ? "contested" : "inferred"}): call mission_status with ${record.id} before acting. ${nextAction(record, standing ?? null, queue)}`,
|
|
142
313
|
notes,
|
|
143
314
|
};
|
|
144
315
|
}
|