@xema/omni-protocol 0.1.8 → 0.1.10
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 +11 -0
- package/dist/testing.js +5 -2
- package/dist/validation.d.ts +15 -4
- package/dist/validation.js +12 -9
- package/guide.md +14 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,6 +40,17 @@ assertNoViolations(violations);
|
|
|
40
40
|
A violation carries a stable `rule` id such as `task.browser.url.scheme`, the `path` it was found
|
|
41
41
|
at such as `snapshot.tasks[0].browsers[1].url`, and a `message`.
|
|
42
42
|
|
|
43
|
+
One rule needs more than the object in hand. A roster never carries the agent it is published to
|
|
44
|
+
— a lead does not report to themself — and a validator cannot know who that is from the roster
|
|
45
|
+
alone. `validateTeamRoster`, `validateSnapshot`, and `validateEventEnvelope` each take an optional
|
|
46
|
+
final `{ self }`, the signed-in agent's `AuthenticationState.identity.id`; given it, they report
|
|
47
|
+
`team.member.self` and `team.request.self`. Without it that rule is not checked. `exerciseAdapter`
|
|
48
|
+
always passes it.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
validateSnapshot(snapshot, manifest, "snapshot", { self: authentication.identity.id });
|
|
52
|
+
```
|
|
53
|
+
|
|
43
54
|
## Conformance
|
|
44
55
|
|
|
45
56
|
`exerciseAdapter` validates the manifest, opens an authenticated session, connects, checks
|
package/dist/testing.js
CHANGED
|
@@ -34,6 +34,9 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
34
34
|
if (authenticationState.status !== "authenticated") {
|
|
35
35
|
throw new Error(`Adapter contract exercise requires authenticated test state, received ${authenticationState.status}`);
|
|
36
36
|
}
|
|
37
|
+
// The harness knows who is signed in, so it can hold the adapter to a rule the structural
|
|
38
|
+
// validators cannot check alone: a roster never carries the agent it is published to.
|
|
39
|
+
const reader = { self: authenticationState.identity.id };
|
|
37
40
|
connection = await adapter.connect(context);
|
|
38
41
|
const live = connection;
|
|
39
42
|
// The optional methods are optional only until something declares a need for them. Each
|
|
@@ -57,7 +60,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
57
60
|
requireMethod("openMedia", "the manifest channel is voice");
|
|
58
61
|
const eventIds = new Set();
|
|
59
62
|
unsubscribe = connection.subscribe(envelope => {
|
|
60
|
-
violations.push(...validateEventEnvelope(envelope, adapter.manifest));
|
|
63
|
+
violations.push(...validateEventEnvelope(envelope, adapter.manifest, "event", reader));
|
|
61
64
|
if (typeof envelope?.id === "string") {
|
|
62
65
|
if (eventIds.has(envelope.id))
|
|
63
66
|
return;
|
|
@@ -66,7 +69,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
66
69
|
events.push(envelope);
|
|
67
70
|
});
|
|
68
71
|
const snapshot = await connection.snapshot();
|
|
69
|
-
violations.push(...validateSnapshot(snapshot, adapter.manifest));
|
|
72
|
+
violations.push(...validateSnapshot(snapshot, adapter.manifest, "snapshot", reader));
|
|
70
73
|
if (snapshot?.sessionCapabilities?.breaks === true) {
|
|
71
74
|
// The four stand or fall together: `granted` is a promise to honour a later commit, and
|
|
72
75
|
// an adapter with requestBreak but no commitBreak leaves an agent a break that never starts.
|
package/dist/validation.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ProtocolViolation } from "./index.js";
|
|
1
|
+
import { type ProtocolViolation, type UserId } from "./index.js";
|
|
2
2
|
export type { ProtocolViolation } from "./index.js";
|
|
3
3
|
export declare class ProtocolConformanceError extends Error {
|
|
4
4
|
readonly violations: readonly ProtocolViolation[];
|
|
@@ -15,7 +15,18 @@ export interface TaskValidationContext {
|
|
|
15
15
|
channel: string;
|
|
16
16
|
}
|
|
17
17
|
export declare function validateTask(task: unknown, context: TaskValidationContext, path?: string): ProtocolViolation[];
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Who is reading what the adapter published. The validators check structure without it; given
|
|
20
|
+
* `self`, they also check that a roster never carries the agent it is published to.
|
|
21
|
+
*/
|
|
22
|
+
export interface ReaderContext {
|
|
23
|
+
/**
|
|
24
|
+
* The signed-in agent, `AuthenticationState.identity.id`. A lead does not report to themself:
|
|
25
|
+
* a roster that lists them in `members`, or their own ask in `requests`, is a violation.
|
|
26
|
+
*/
|
|
27
|
+
self?: UserId;
|
|
28
|
+
}
|
|
29
|
+
export declare function validateTeamRoster(roster: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
30
|
+
export declare function validateSnapshot(snapshot: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
31
|
+
export declare function validateEventEnvelope(envelope: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
21
32
|
export declare function validateAuthenticationState(state: unknown, path?: string): ProtocolViolation[];
|
package/dist/validation.js
CHANGED
|
@@ -705,12 +705,12 @@ function validateBreakState(value, path, into) {
|
|
|
705
705
|
}
|
|
706
706
|
});
|
|
707
707
|
}
|
|
708
|
-
export function validateTeamRoster(roster, path = "team") {
|
|
708
|
+
export function validateTeamRoster(roster, path = "team", context = {}) {
|
|
709
709
|
const into = new Collector();
|
|
710
|
-
validateTeamRosterInto(roster, path, into);
|
|
710
|
+
validateTeamRosterInto(roster, path, context, into);
|
|
711
711
|
return into.violations;
|
|
712
712
|
}
|
|
713
|
-
function validateTeamRosterInto(roster, path, into) {
|
|
713
|
+
function validateTeamRosterInto(roster, path, context, into) {
|
|
714
714
|
if (!isPlainObject(roster)) {
|
|
715
715
|
into.add("team.shape", path, "a team roster must be an object");
|
|
716
716
|
return;
|
|
@@ -740,7 +740,9 @@ function validateTeamRosterInto(roster, path, into) {
|
|
|
740
740
|
into.add("team.request.unique", `${at}.id`, `duplicate request id: ${request.id}`);
|
|
741
741
|
seenRequests.add(request.id);
|
|
742
742
|
}
|
|
743
|
-
into.require(isUserId(request.memberId), "team.request.memberId", `${at}.memberId`, "a request names the member asking")
|
|
743
|
+
if (into.require(isUserId(request.memberId), "team.request.memberId", `${at}.memberId`, "a request names the member asking")) {
|
|
744
|
+
into.require(request.memberId !== context.self, "team.request.self", `${at}.memberId`, "the roster carries the reader's own ask: an agent's request for a lead goes to whoever leads them");
|
|
745
|
+
}
|
|
744
746
|
into.require(isTaskId(request.taskId), "team.request.taskId", `${at}.taskId`, "a request names the task the lead would join");
|
|
745
747
|
if (request.note !== undefined)
|
|
746
748
|
into.filled(request.note, "team.request.note", `${at}.note`, "a note must not be empty when present");
|
|
@@ -763,6 +765,7 @@ function validateTeamRosterInto(roster, path, into) {
|
|
|
763
765
|
if (seen.has(member.id))
|
|
764
766
|
into.add("team.member.unique", `${at}.id`, `duplicate roster member: ${member.id}`);
|
|
765
767
|
seen.add(member.id);
|
|
768
|
+
into.require(member.id !== context.self, "team.member.self", `${at}.id`, "the roster carries the agent it is published to: a lead does not report to themself");
|
|
766
769
|
}
|
|
767
770
|
into.oneOf(member.availability, TEAM_AVAILABILITIES, "team.member.availability", `${at}.availability`);
|
|
768
771
|
if (member.since !== undefined)
|
|
@@ -771,7 +774,7 @@ function validateTeamRosterInto(roster, path, into) {
|
|
|
771
774
|
into.oneOf(member.break, BREAK_APPROVALS, "team.member.break", `${at}.break`);
|
|
772
775
|
});
|
|
773
776
|
}
|
|
774
|
-
export function validateSnapshot(snapshot, manifest, path = "snapshot") {
|
|
777
|
+
export function validateSnapshot(snapshot, manifest, path = "snapshot", context = {}) {
|
|
775
778
|
const into = new Collector();
|
|
776
779
|
if (!isPlainObject(snapshot)) {
|
|
777
780
|
into.add("snapshot.shape", path, "a snapshot must be an object");
|
|
@@ -839,7 +842,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot") {
|
|
|
839
842
|
}
|
|
840
843
|
}
|
|
841
844
|
if (snapshot.team !== undefined)
|
|
842
|
-
validateTeamRosterInto(snapshot.team, `${path}.team`, into);
|
|
845
|
+
validateTeamRosterInto(snapshot.team, `${path}.team`, context, into);
|
|
843
846
|
return into.violations;
|
|
844
847
|
}
|
|
845
848
|
// ---------------------------------------------------------------------------
|
|
@@ -912,7 +915,7 @@ function validateProviderSummary(value, path, into) {
|
|
|
912
915
|
into.require(typeof metric.value === "string", "event.summary.metric.value", `${at}.value`, "a metric value must be a string; the provider decides how it reads");
|
|
913
916
|
});
|
|
914
917
|
}
|
|
915
|
-
export function validateEventEnvelope(envelope, manifest, path = "event") {
|
|
918
|
+
export function validateEventEnvelope(envelope, manifest, path = "event", context = {}) {
|
|
916
919
|
const into = new Collector();
|
|
917
920
|
if (!isPlainObject(envelope)) {
|
|
918
921
|
into.add("event.shape", path, "an event envelope must be an object");
|
|
@@ -931,7 +934,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event") {
|
|
|
931
934
|
switch (event.type) {
|
|
932
935
|
case "snapshot":
|
|
933
936
|
into.oneOf(event.reason, SNAPSHOT_REASONS, "event.snapshot.reason", `${at}.reason`);
|
|
934
|
-
into.violations.push(...validateSnapshot(event.snapshot, manifest, `${at}.snapshot
|
|
937
|
+
into.violations.push(...validateSnapshot(event.snapshot, manifest, `${at}.snapshot`, context));
|
|
935
938
|
break;
|
|
936
939
|
case "provider-status":
|
|
937
940
|
into.oneOf(event.status, CONNECTION_STATUSES, "event.providerStatus.status", `${at}.status`);
|
|
@@ -975,7 +978,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event") {
|
|
|
975
978
|
validateProviderSummary(event.summary, `${at}.summary`, into);
|
|
976
979
|
break;
|
|
977
980
|
case "team-updated":
|
|
978
|
-
validateTeamRosterInto(event.team, `${at}.team`, into);
|
|
981
|
+
validateTeamRosterInto(event.team, `${at}.team`, context, into);
|
|
979
982
|
break;
|
|
980
983
|
case "contacts-updated":
|
|
981
984
|
if (!Array.isArray(event.contacts)) {
|
package/guide.md
CHANGED
|
@@ -926,6 +926,11 @@ snapshot is the answer; Omni waits for it rather than calling `snapshot()` itsel
|
|
|
926
926
|
transport is up, a result says the provider accepted the command, and the event that follows —
|
|
927
927
|
`task-updated`, `break-state` — says what it did.
|
|
928
928
|
|
|
929
|
+
**Classify on the rejection, never on a connection status published separately from it.** The
|
|
930
|
+
status is a report about the wire and races the rejection; the rejection is the event. A rejection
|
|
931
|
+
is the provider's answer only when it carries the provider's answer — a failure the provider
|
|
932
|
+
named. Every other rejection is transport loss, whatever the last `provider-status` said.
|
|
933
|
+
|
|
929
934
|
A command therefore carries no key. The provider names its own records — a task, a lead request, a
|
|
930
935
|
member — and Omni refers to them by those names; **Omni never asks a provider to remember a name
|
|
931
936
|
Omni made up.**
|
|
@@ -2585,6 +2590,15 @@ decides who leads a team: no roster means nothing is shown, which is the correct
|
|
|
2585
2590
|
agent who leads nobody. The same rule governs `TeamRoster.breakControl` — present when this lead
|
|
2586
2591
|
decides their team's breaks, absent when they do not.
|
|
2587
2592
|
|
|
2593
|
+
**The roster never carries the agent it is published to — not in `members`, and not in
|
|
2594
|
+
`requests`.** A lead does not report to themself: their own break request and their own ask for a
|
|
2595
|
+
lead go up to whoever leads them and appear on *that* person's roster, while the requester sees
|
|
2596
|
+
only their own `BreakState` and their task's `lead` move. An adapter whose platform lists the lead
|
|
2597
|
+
among their own members filters the signed-in identity out before publishing. **Entitlement is a
|
|
2598
|
+
role the provider knows, never inferred from who is listed:** a lead with nobody in their team
|
|
2599
|
+
publishes `[]`, an agent with no such role publishes nothing, and no member count can tell those
|
|
2600
|
+
two apart.
|
|
2601
|
+
|
|
2588
2602
|
### Lead commands
|
|
2589
2603
|
|
|
2590
2604
|
One method, `executeTeamBreak`, taking a discriminated command exactly as `execute` takes a
|