@xema/omni-protocol 0.1.94 → 0.1.96
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/dist/index.d.ts +14 -20
- package/dist/testing.d.ts +4 -4
- package/dist/testing.js +15 -14
- package/dist/validation.d.ts +2 -2
- package/dist/validation.js +39 -43
- package/guide.md +457 -413
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -1217,9 +1217,9 @@ export type DialResult = {
|
|
|
1217
1217
|
status: "failed";
|
|
1218
1218
|
failure: ProtocolFailure;
|
|
1219
1219
|
};
|
|
1220
|
-
export type
|
|
1220
|
+
export type BreakStatus = "not-requested"
|
|
1221
1221
|
/** Somebody has to decide. The agent is waiting on a person. */
|
|
1222
|
-
| "awaiting-
|
|
1222
|
+
| "awaiting-approval"
|
|
1223
1223
|
/** Granted, and a promise to honour a later commit. */
|
|
1224
1224
|
| "granted"
|
|
1225
1225
|
/** Granted and begins when the current task ends. Nobody needs to act. */
|
|
@@ -1249,28 +1249,19 @@ export interface BreakReason {
|
|
|
1249
1249
|
alwaysAvailable?: true;
|
|
1250
1250
|
}
|
|
1251
1251
|
export interface BreakRequest {
|
|
1252
|
+
/** Optional explanatory text. Omit when no explanation is needed; separate from reasonId. */
|
|
1252
1253
|
reason?: string;
|
|
1253
1254
|
/** The chosen `BreakReason.id`, where the provider publishes codes. */
|
|
1254
1255
|
reasonId?: string;
|
|
1255
1256
|
}
|
|
1256
|
-
/**
|
|
1257
|
-
* A break forced on the agent rather than requested by them.
|
|
1258
|
-
*
|
|
1259
|
-
* `by` is required in both arms. Who put somebody off the floor survives whether or not the
|
|
1260
|
-
* break ends on a clock -- a forced break with no origin is a state the agent cannot reason
|
|
1261
|
-
* about, and one that ends on a condition is still somebody's decision.
|
|
1262
|
-
*/
|
|
1257
|
+
/** A break forced on the agent; the agent must explicitly resume when ready. */
|
|
1263
1258
|
export type ForcedBreak = {
|
|
1264
1259
|
by: UserId;
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
} | {
|
|
1268
|
-
by: UserId;
|
|
1269
|
-
endsAutomatically: false;
|
|
1270
|
-
endsAt?: never;
|
|
1260
|
+
/** Optional expected time on break, in milliseconds from actual start. Advisory only. */
|
|
1261
|
+
expectedDurationMs?: number;
|
|
1271
1262
|
};
|
|
1272
1263
|
export interface BreakState {
|
|
1273
|
-
|
|
1264
|
+
status: BreakStatus;
|
|
1274
1265
|
/** Whether the agent may ask at all. Distinct from the fate of a request already made. */
|
|
1275
1266
|
canRequestBreak: boolean;
|
|
1276
1267
|
/** Shown when `canRequestBreak` is false, such as "Busy hours". */
|
|
@@ -1321,7 +1312,7 @@ export interface TeamMember {
|
|
|
1321
1312
|
/** Omitted rather than invented: Omni renders it as a duration. */
|
|
1322
1313
|
since?: IsoTimestamp;
|
|
1323
1314
|
/** A request in flight or a grant not yet in effect. `not-requested` is absence, and `on-break` is `availability: "on-break"`. */
|
|
1324
|
-
break?: Extract<
|
|
1315
|
+
break?: Extract<BreakStatus, "awaiting-approval" | "granted" | "starting-after-task">;
|
|
1325
1316
|
}
|
|
1326
1317
|
/** A member asking this lead to join their call. */
|
|
1327
1318
|
export interface LeadRequest {
|
|
@@ -1398,13 +1389,16 @@ export type TeamBreakCommand = {
|
|
|
1398
1389
|
type: "set-break-policy";
|
|
1399
1390
|
policy: "approval-required" | "automatically-approved" | "requests-suspended";
|
|
1400
1391
|
}
|
|
1401
|
-
/** `reasonId`
|
|
1392
|
+
/** `reason` text is optional. `reasonId` selects a published `BreakReason.id`, required when reasons are published; the forced break carries it as `activeReasonId`. */
|
|
1402
1393
|
| {
|
|
1403
1394
|
type: "force-break";
|
|
1404
1395
|
memberId: UserId;
|
|
1405
1396
|
reasonId?: string;
|
|
1406
1397
|
reason?: string;
|
|
1407
|
-
|
|
1398
|
+
expectedDurationMs?: number;
|
|
1399
|
+
}
|
|
1400
|
+
/** Clears forced metadata only; preserves the committed break until the agent explicitly resumes. */
|
|
1401
|
+
| {
|
|
1408
1402
|
type: "end-forced-break";
|
|
1409
1403
|
memberId: UserId;
|
|
1410
1404
|
};
|
|
@@ -1693,7 +1687,7 @@ export interface Connection<C extends Channel = Channel> {
|
|
|
1693
1687
|
execute(request: TaskCommandRequest<C>): Promise<TaskCommandResult>;
|
|
1694
1688
|
disconnect(): Promise<void>;
|
|
1695
1689
|
/** Required of any adapter publishing a `UserId` -- on a forced break, a team member list, or history. */
|
|
1696
|
-
|
|
1690
|
+
getUserDetails?(ids: UserId[]): Promise<User[]>;
|
|
1697
1691
|
/** Required when the manifest declares `idleCapabilities.dial`. */
|
|
1698
1692
|
dial?(request: DialRequest): Promise<DialResult>;
|
|
1699
1693
|
/**
|
package/dist/testing.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Adapter, type AuthenticationState, type ProviderEventEnvelope, type Snapshot, type Task, type TaskCompletion, type
|
|
1
|
+
import { type Adapter, type AuthenticationState, type ProviderEventEnvelope, type Snapshot, type Task, type TaskCompletion, type BreakStatus, type BrowserSessionKeyInput, type Channel, type ConnectContext, type Host, type HostGuarantees, type HostMute, type LoginStore, type HostReport, type Manifest, type ProviderEvent } from "./index.js";
|
|
2
2
|
import { type ProtocolViolation } from "./validation.js";
|
|
3
3
|
export { ProtocolConformanceError, assertNoViolations, type ProtocolViolation } from "./validation.js";
|
|
4
4
|
/**
|
|
@@ -135,11 +135,11 @@ export declare function assertReconnectWithMissedAssignments<C extends Channel>(
|
|
|
135
135
|
* pending request nobody is coming to decide is worse than none. So the scenario is a request
|
|
136
136
|
* that goes back to not-requested, and a later one that is granted.
|
|
137
137
|
*
|
|
138
|
-
* A request shows as `awaiting-
|
|
138
|
+
* A request shows as `awaiting-approval` where a person decides, and as `granted` at once where
|
|
139
139
|
* the provider decides alone; either is the request being made, and the rule is the same for
|
|
140
140
|
* both.
|
|
141
141
|
*/
|
|
142
|
-
export declare function assertDeniedAndRetriedBreak(approvals: readonly
|
|
142
|
+
export declare function assertDeniedAndRetriedBreak(approvals: readonly BreakStatus[]): void;
|
|
143
143
|
/**
|
|
144
144
|
* The event kinds a snapshot restates, and so supersedes when delivered while it is read. Every
|
|
145
145
|
* other kind reports a transaction no snapshot carries, and a host applies it after the snapshot.
|
|
@@ -242,7 +242,7 @@ export interface BreakCandidate {
|
|
|
242
242
|
export declare function assertBreakAttemptProviders(candidates: readonly BreakCandidate[], asked: readonly string[]): void;
|
|
243
243
|
/** One published moment of a break asked for on a task: the approval, and how many tasks were outstanding. */
|
|
244
244
|
export interface BreakOnTaskStep {
|
|
245
|
-
approval:
|
|
245
|
+
approval: BreakStatus;
|
|
246
246
|
outstanding: number;
|
|
247
247
|
}
|
|
248
248
|
/**
|
package/dist/testing.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { browserSessionKey, effectiveLevels, sameCapabilities, } from "./index.js";
|
|
2
|
-
import { assertNoViolations, validateAuthenticationState, validateBreakTransition, validateEventEnvelope, validateHostGuarantees, validateHostReport,
|
|
2
|
+
import { assertNoViolations, validateAuthenticationState, validateBreakTransition, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateUserDetails, observeRules, ruleEvaluated, validateHostMute, validateHostRecording, validateLoginStore, validateInteractionReport, validateManifest, validateResult, validateSnapshot, validateTask, validateTimeZone, validatePhone, validateTaskCommand, isTimeZone, sameTimeZone, } from "./validation.js";
|
|
3
3
|
export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
|
|
4
4
|
/**
|
|
5
5
|
* A part of the contract a run may never reach: state nothing obliges an adapter to publish, so
|
|
@@ -356,7 +356,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
356
356
|
violations.push(...undeterminedTasks(eventTasks(envelope), "event"));
|
|
357
357
|
lockedPartySeen ||= eventTasks(envelope).some(locksParty);
|
|
358
358
|
if (eventNamesUsers(envelope))
|
|
359
|
-
requireMethod(live, "
|
|
359
|
+
requireMethod(live, "getUserDetails", "an event publishes a UserId");
|
|
360
360
|
// Work is pulled, never pushed: an offer before the host stated capacity is an allocation
|
|
361
361
|
// against nothing, and an offer beyond the count is one too many -- unless the task is the
|
|
362
362
|
// host's own dial arriving, which counts against nothing.
|
|
@@ -458,22 +458,22 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
458
458
|
violations.push(...undeterminedTasks(tasks, `${path}.tasks`));
|
|
459
459
|
lockedPartySeen ||= tasks.some(locksParty);
|
|
460
460
|
if (publishesUserIds(read)) {
|
|
461
|
-
requireMethod(source, "
|
|
461
|
+
requireMethod(source, "getUserDetails", "the snapshot publishes a UserId");
|
|
462
462
|
// Required by presence is not enough: the names the snapshot published are looked up, and what
|
|
463
463
|
// comes back is held to the shape -- an empty answer to a team member list of colleagues is named.
|
|
464
464
|
const named = userIdsIn(read);
|
|
465
|
-
if (typeof source.
|
|
465
|
+
if (typeof source.getUserDetails === "function" && named.length > 0) {
|
|
466
466
|
let described;
|
|
467
467
|
try {
|
|
468
|
-
described = await source.
|
|
469
|
-
violations.push(...
|
|
468
|
+
described = await source.getUserDetails(named);
|
|
469
|
+
violations.push(...validateUserDetails(described, named, "connection.getUserDetails"));
|
|
470
470
|
if (Array.isArray(described) && described.length === 0) {
|
|
471
|
-
violations.push({ rule: "connection.
|
|
471
|
+
violations.push({ rule: "connection.getUserDetails.empty", path: "connection.getUserDetails",
|
|
472
472
|
message: `asked about ${named.join(", ")}, whom the snapshot itself named, the provider described nobody` });
|
|
473
473
|
}
|
|
474
474
|
}
|
|
475
475
|
catch (error) {
|
|
476
|
-
violations.push({ rule: "connection.
|
|
476
|
+
violations.push({ rule: "connection.getUserDetails.rejected", path: "connection.getUserDetails", message: `getUserDetails rejected rather than answered: ${String(error)}` });
|
|
477
477
|
}
|
|
478
478
|
}
|
|
479
479
|
}
|
|
@@ -532,7 +532,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
532
532
|
violations.push(...malformed);
|
|
533
533
|
// The provider republishes the agent's day: once connected, the identity carries the zone the
|
|
534
534
|
// host sent. That proves the round trip, not the store -- an echo passes it -- and the name
|
|
535
|
-
// says only what it tests; storage is proved by a colleague's zone arriving from
|
|
535
|
+
// says only what it tests; storage is proved by a colleague's zone arriving from getUserDetails().
|
|
536
536
|
// A zone is judged by what it denotes: Asia/Kolkata and Asia/Calcutta are one zone, and a
|
|
537
537
|
// provider that keeps the canonical name has kept the zone.
|
|
538
538
|
if (isTimeZone(context.timeZone) && !sameTimeZone(current().identity.timeZone, context.timeZone)) {
|
|
@@ -938,12 +938,12 @@ export function assertReconnectWithMissedAssignments(before, reconnect, missedTa
|
|
|
938
938
|
* pending request nobody is coming to decide is worse than none. So the scenario is a request
|
|
939
939
|
* that goes back to not-requested, and a later one that is granted.
|
|
940
940
|
*
|
|
941
|
-
* A request shows as `awaiting-
|
|
941
|
+
* A request shows as `awaiting-approval` where a person decides, and as `granted` at once where
|
|
942
942
|
* the provider decides alone; either is the request being made, and the rule is the same for
|
|
943
943
|
* both.
|
|
944
944
|
*/
|
|
945
945
|
export function assertDeniedAndRetriedBreak(approvals) {
|
|
946
|
-
const asked = approvals.findIndex(approval => approval === "awaiting-
|
|
946
|
+
const asked = approvals.findIndex(approval => approval === "awaiting-approval" || approval === "granted");
|
|
947
947
|
if (asked < 0)
|
|
948
948
|
throw new Error("Break retry scenario requires an initial request");
|
|
949
949
|
const refused = approvals.indexOf("not-requested", asked + 1);
|
|
@@ -1865,9 +1865,10 @@ async function driveOneCall(drive) {
|
|
|
1865
1865
|
nothingLate();
|
|
1866
1866
|
return found;
|
|
1867
1867
|
}
|
|
1868
|
-
// A request goes not-requested -> awaiting-
|
|
1868
|
+
// A request goes not-requested -> awaiting-approval | granted; a commit goes granted ->
|
|
1869
1869
|
// starting-after-task | on-break; work ending goes starting-after-task -> on-break; a denial,
|
|
1870
|
-
// a cancel
|
|
1870
|
+
// a cancel or an explicit agent end goes back to not-requested; a lead lifting forced status
|
|
1871
|
+
// preserves the committed break. A forced break arrives on-break
|
|
1871
1872
|
// with `forced`. Nothing else is a move the guide describes.
|
|
1872
1873
|
/** What a stream has said about the agent's break, and the moves it may not make. */
|
|
1873
1874
|
export class BreakStream {
|
|
@@ -1994,7 +1995,7 @@ export function assertBreakAttemptProviders(candidates, asked) {
|
|
|
1994
1995
|
* outstanding -- never beside a task, and never later than the step that has none.
|
|
1995
1996
|
*/
|
|
1996
1997
|
export function assertBreakBeginsAfterTask(steps) {
|
|
1997
|
-
const asked = steps.findIndex(step => step.approval === "awaiting-
|
|
1998
|
+
const asked = steps.findIndex(step => step.approval === "awaiting-approval" || step.approval === "granted");
|
|
1998
1999
|
if (asked < 0)
|
|
1999
2000
|
throw new Error("Break-on-task scenario requires a request");
|
|
2000
2001
|
if ((steps[asked]?.outstanding ?? 0) < 1)
|
package/dist/validation.d.ts
CHANGED
|
@@ -150,11 +150,11 @@ export declare function sameTimeZone(a: unknown, b: unknown): boolean;
|
|
|
150
150
|
/** The zone a host sends at connect: required, and an IANA name. */
|
|
151
151
|
export declare function validateTimeZone(value: unknown, path?: string): ProtocolViolation[];
|
|
152
152
|
/**
|
|
153
|
-
* What `
|
|
153
|
+
* What `getUserDetails(ids)` answered: an array of users, each one asked for, each a valid identity.
|
|
154
154
|
* The provider may answer fewer than asked, which is what "unknown to it" looks like; it never
|
|
155
155
|
* answers somebody nobody asked about.
|
|
156
156
|
*/
|
|
157
|
-
export declare function
|
|
157
|
+
export declare function validateUserDetails(users: unknown, asked: readonly string[], path?: string): ProtocolViolation[];
|
|
158
158
|
/** What a login is validated against beyond its own shape. */
|
|
159
159
|
export interface LoginValidationContext {
|
|
160
160
|
/** The level ids in force, from the manifest. The defaults when absent. */
|
package/dist/validation.js
CHANGED
|
@@ -52,7 +52,7 @@ const AUTHENTICATION_STATUSES = membersOf({
|
|
|
52
52
|
"signed-out": true, authenticating: true, authenticated: true, refreshing: true, expired: true,
|
|
53
53
|
});
|
|
54
54
|
const BREAK_APPROVALS = membersOf({
|
|
55
|
-
"not-requested": true, "awaiting-
|
|
55
|
+
"not-requested": true, "awaiting-approval": true, granted: true, "starting-after-task": true, "on-break": true,
|
|
56
56
|
});
|
|
57
57
|
const TEAM_AVAILABILITIES = membersOf({
|
|
58
58
|
ready: true, "on-task": true, "on-break": true, reserved: true, "signed-out": true,
|
|
@@ -80,7 +80,7 @@ const SNAPSHOT_REASONS = membersOf({
|
|
|
80
80
|
});
|
|
81
81
|
const SESSION_CAPABILITIES = membersOf({ breaks: true, team: true, preferences: true });
|
|
82
82
|
const MEMBER_BREAKS = membersOf({
|
|
83
|
-
"awaiting-
|
|
83
|
+
"awaiting-approval": true, granted: true, "starting-after-task": true,
|
|
84
84
|
});
|
|
85
85
|
const OFFERABLE_PHASES = membersOf({
|
|
86
86
|
pending: true,
|
|
@@ -1298,19 +1298,12 @@ function validateForcedBreak(value, path, into) {
|
|
|
1298
1298
|
into.add("break.forced.shape", path, "a forced break must be an object");
|
|
1299
1299
|
return;
|
|
1300
1300
|
}
|
|
1301
|
-
// `by` is required either way. Who put somebody off the floor survives whether or not the
|
|
1302
|
-
// break ends on a clock -- a forced break with no origin is a state the agent cannot
|
|
1303
|
-
// reason about.
|
|
1304
1301
|
into.require(isUserId(value.by), "break.forced.by", `${path}.by`, "a forced break must say who forced it");
|
|
1305
|
-
|
|
1306
|
-
into.
|
|
1307
|
-
}
|
|
1308
|
-
else if (value.endsAutomatically === false) {
|
|
1309
|
-
into.require(value.endsAt === undefined, "break.forced.endsAt.unexpected", `${path}.endsAt`, "a break that does not end automatically must not carry an end time");
|
|
1310
|
-
}
|
|
1311
|
-
else {
|
|
1312
|
-
into.add("break.forced.endsAutomatically", `${path}.endsAutomatically`, "a forced break must say whether it ends automatically");
|
|
1302
|
+
for (const field of ["endsAutomatically", "endsAt"]) {
|
|
1303
|
+
into.require(!Object.hasOwn(value, field), "break.forced.manualResume", `${path}.${field}`, "automatic ending fields are unsupported; the agent must resume manually");
|
|
1313
1304
|
}
|
|
1305
|
+
if (value.expectedDurationMs !== undefined)
|
|
1306
|
+
into.require(typeof value.expectedDurationMs === "number" && Number.isFinite(value.expectedDurationMs) && value.expectedDurationMs > 0, "break.forced.expectedDurationMs", `${path}.expectedDurationMs`, "expectedDurationMs must be a positive finite number of milliseconds");
|
|
1314
1307
|
}
|
|
1315
1308
|
/** Checks break status against the full currently retained task set, after a transaction. */
|
|
1316
1309
|
export function validateBreakStatus(state, tasks, path = "snapshot") {
|
|
@@ -1329,11 +1322,11 @@ export function validateBreakStatus(state, tasks, path = "snapshot") {
|
|
|
1329
1322
|
// no task is a break waiting on work that does not exist, and an empty list reading as "still
|
|
1330
1323
|
// finishing" is the plausible nought.
|
|
1331
1324
|
ruleEvaluated("break.starting-after-task.tasks");
|
|
1332
|
-
if (isPlainObject(state) && state.
|
|
1325
|
+
if (isPlainObject(state) && state.status === "starting-after-task"
|
|
1333
1326
|
&& Array.isArray(tasks) && tasks.length === 0) {
|
|
1334
|
-
into.add("break.starting-after-task.tasks", `${path}.break.
|
|
1327
|
+
into.add("break.starting-after-task.tasks", `${path}.break.status`, "a break starting after the task waits on a task, and the snapshot carries none: with nothing outstanding the break is on-break");
|
|
1335
1328
|
}
|
|
1336
|
-
if (isPlainObject(state) && state.
|
|
1329
|
+
if (isPlainObject(state) && state.status === "on-break"
|
|
1337
1330
|
&& Array.isArray(tasks) && tasks.length > 0) {
|
|
1338
1331
|
const listening = tasks.every((task) => isPlainObject(task) && task.listening !== undefined);
|
|
1339
1332
|
if (!listening) {
|
|
@@ -1367,7 +1360,7 @@ export function validateTeamBreakCommand(request, context, path = "teamBreakComm
|
|
|
1367
1360
|
const command = request.command;
|
|
1368
1361
|
const allowed = {
|
|
1369
1362
|
"decide-break-request": ["type", "memberId", "decision", "reason"], "set-break-policy": ["type", "policy"],
|
|
1370
|
-
"force-break": ["type", "memberId", "reasonId", "reason"], "end-forced-break": ["type", "memberId"],
|
|
1363
|
+
"force-break": ["type", "memberId", "reasonId", "reason", "expectedDurationMs"], "end-forced-break": ["type", "memberId"],
|
|
1371
1364
|
};
|
|
1372
1365
|
const fields = typeof command.type === "string" && Object.hasOwn(allowed, command.type) ? allowed[command.type] : undefined;
|
|
1373
1366
|
if (!fields) {
|
|
@@ -1392,14 +1385,16 @@ export function validateTeamBreakCommand(request, context, path = "teamBreakComm
|
|
|
1392
1385
|
into.require(isPlainObject(member) && command.memberId !== self, "team.break.command.member", path, "the target must be another member of the current authorized team member list");
|
|
1393
1386
|
if (command.type === "decide-break-request") {
|
|
1394
1387
|
into.require(command.decision === "granted" || command.decision === "denied", "team.break.command.decision", path, "decide granted or denied");
|
|
1395
|
-
into.require(isPlainObject(member) && member.break === "awaiting-
|
|
1388
|
+
into.require(isPlainObject(member) && member.break === "awaiting-approval", "team.break.command.awaiting", path, "decide only a currently awaiting-approval request");
|
|
1396
1389
|
}
|
|
1397
1390
|
else {
|
|
1398
1391
|
validateBreakState(context.memberBreak, `${path}.memberBreak`, into);
|
|
1399
1392
|
const state = isPlainObject(context.memberBreak) ? context.memberBreak : {};
|
|
1400
1393
|
if (command.type === "end-forced-break")
|
|
1401
|
-
into.require(state.forced !== undefined && (state.
|
|
1394
|
+
into.require(state.forced !== undefined && (state.status === "on-break" || state.status === "starting-after-task"), "team.break.command.endForcedBreak", path, "lift a current forced-break restriction without resuming the agent");
|
|
1402
1395
|
if (command.type === "force-break") {
|
|
1396
|
+
if (command.expectedDurationMs !== undefined)
|
|
1397
|
+
into.require(typeof command.expectedDurationMs === "number" && Number.isFinite(command.expectedDurationMs) && command.expectedDurationMs > 0, "team.break.command.expectedDurationMs", `${path}.expectedDurationMs`, "expectedDurationMs must be positive finite milliseconds; it never resumes the agent");
|
|
1403
1398
|
if (command.reasonId !== undefined)
|
|
1404
1399
|
into.filled(command.reasonId, "team.break.command.reasonId", path, "reasonId must not be empty");
|
|
1405
1400
|
const reasons = Array.isArray(state.reasons) ? state.reasons : [];
|
|
@@ -1431,9 +1426,9 @@ export function validateBreakCommand(method, request, state, context, path = "br
|
|
|
1431
1426
|
}
|
|
1432
1427
|
if (!isPlainObject(state) || into.violations.length)
|
|
1433
1428
|
return into.violations;
|
|
1434
|
-
const
|
|
1429
|
+
const status = state.status;
|
|
1435
1430
|
if (method === "requestBreak") {
|
|
1436
|
-
into.require(
|
|
1431
|
+
into.require(status === "not-requested", "break.command.request.pending", path, "a new request starts only from not-requested");
|
|
1437
1432
|
if (!isPlainObject(request)) {
|
|
1438
1433
|
into.add("break.request.shape", path, "requestBreak takes a request object");
|
|
1439
1434
|
return into.violations;
|
|
@@ -1454,12 +1449,12 @@ export function validateBreakCommand(method, request, state, context, path = "br
|
|
|
1454
1449
|
else {
|
|
1455
1450
|
into.require(request === undefined, "break.command.arguments", path, "this break method takes no arguments");
|
|
1456
1451
|
if (method === "commitBreak")
|
|
1457
|
-
into.require(
|
|
1452
|
+
into.require(status === "granted" || status === "starting-after-task" || status === "on-break", "break.command.commit.grant", path, "commit requires a grant; repeated committed state is idempotent");
|
|
1458
1453
|
if (method === "cancelBreak")
|
|
1459
|
-
into.require(
|
|
1454
|
+
into.require(status === "awaiting-approval" || status === "granted", "break.command.cancel.precommit", path, "cancel only a pre-commit request; a raced commit requires commit recovery");
|
|
1460
1455
|
if (method === "endBreak") {
|
|
1461
|
-
into.require(
|
|
1462
|
-
|
|
1456
|
+
into.require(status === "starting-after-task" || status === "on-break", "break.command.end.started", path, "end a committed break, including a returning provider still finishing work");
|
|
1457
|
+
// A forced break also ends only when the agent explicitly resumes.
|
|
1463
1458
|
}
|
|
1464
1459
|
}
|
|
1465
1460
|
return into.violations;
|
|
@@ -1477,17 +1472,17 @@ export function validateBreakTransition(before, after, path = "break") {
|
|
|
1477
1472
|
validateBreakState(after, `${path}.after`, into);
|
|
1478
1473
|
if (into.violations.length || !isPlainObject(before) || !isPlainObject(after))
|
|
1479
1474
|
return into.violations;
|
|
1480
|
-
const from = before.
|
|
1481
|
-
const to = after.
|
|
1475
|
+
const from = before.status;
|
|
1476
|
+
const to = after.status;
|
|
1482
1477
|
const committed = to === "starting-after-task" || to === "on-break";
|
|
1483
|
-
if (committed && (from === "not-requested" || from === "awaiting-
|
|
1484
|
-
into.add("stream.breakState.commitBeforeGrant", `${path}.after.
|
|
1478
|
+
if (committed && (from === "not-requested" || from === "awaiting-approval") && after.forced === undefined) {
|
|
1479
|
+
into.add("stream.breakState.commitBeforeGrant", `${path}.after.status`, `${to} follows a commit, and a commit follows granted; the break stood at ${from}`);
|
|
1485
1480
|
}
|
|
1486
|
-
const backwards = (from === "on-break" && (to === "awaiting-
|
|
1487
|
-
(from === "starting-after-task" && (to === "awaiting-
|
|
1488
|
-
(from === "granted" && to === "awaiting-
|
|
1481
|
+
const backwards = (from === "on-break" && (to === "awaiting-approval" || to === "granted" || to === "starting-after-task")) ||
|
|
1482
|
+
(from === "starting-after-task" && (to === "awaiting-approval" || to === "granted")) ||
|
|
1483
|
+
(from === "granted" && to === "awaiting-approval");
|
|
1489
1484
|
if (backwards)
|
|
1490
|
-
into.add("stream.breakState.backwards", `${path}.after.
|
|
1485
|
+
into.add("stream.breakState.backwards", `${path}.after.status`, `a break does not go from ${from} back to ${to}; a new request passes through not-requested`);
|
|
1491
1486
|
return into.violations;
|
|
1492
1487
|
}
|
|
1493
1488
|
function validateBreakState(value, path, into) {
|
|
@@ -1496,7 +1491,8 @@ function validateBreakState(value, path, into) {
|
|
|
1496
1491
|
return;
|
|
1497
1492
|
}
|
|
1498
1493
|
into.require(!Object.hasOwn(value, "imposed"), "break.forced.renamed", `${path}.imposed`, "imposed was renamed to forced; use only forced");
|
|
1499
|
-
into.
|
|
1494
|
+
into.require(!Object.hasOwn(value, "approval"), "break.status.renamed", `${path}.approval`, "BreakState.approval was renamed to status; use only status");
|
|
1495
|
+
into.oneOf(value.status, BREAK_APPROVALS, "break.status", `${path}.status`);
|
|
1500
1496
|
into.require(!Object.hasOwn(value, "mayAsk"), "break.canRequestBreak.renamed", `${path}.mayAsk`, "mayAsk was renamed to canRequestBreak; use only canRequestBreak");
|
|
1501
1497
|
into.require(typeof value.canRequestBreak === "boolean", "break.canRequestBreak", `${path}.canRequestBreak`, "canRequestBreak says whether the agent may ask for a break: a boolean");
|
|
1502
1498
|
into.require(!Object.hasOwn(value, "refusedReason"), "break.requestUnavailableReason.renamed", `${path}.refusedReason`, "refusedReason was renamed to requestUnavailableReason; use only requestUnavailableReason");
|
|
@@ -1517,14 +1513,14 @@ function validateBreakState(value, path, into) {
|
|
|
1517
1513
|
into.filled(value.activeReasonId, "break.activeReasonId", `${path}.activeReasonId`, "activeReasonId must not be empty when present");
|
|
1518
1514
|
// A break nobody is on has no reason. Reporting one beside `not-requested` describes a
|
|
1519
1515
|
// break that is not happening.
|
|
1520
|
-
into.require(value.
|
|
1516
|
+
into.require(value.status !== "not-requested", "break.activeReasonId.status", `${path}.activeReasonId`, "activeReasonId must be omitted when no break is requested or in effect");
|
|
1521
1517
|
}
|
|
1522
1518
|
if (value.forced !== undefined) {
|
|
1523
1519
|
validateForcedBreak(value.forced, `${path}.forced`, into);
|
|
1524
1520
|
// A forced break is a break somebody forced; beside `not-requested` there is no break.
|
|
1525
1521
|
// A forced break is a break in progress or about to be: it travels with on-break or
|
|
1526
1522
|
// starting-after-task and nothing else. Beside granted, the host would commit a break nobody asked for.
|
|
1527
|
-
into.require(value.
|
|
1523
|
+
into.require(value.status === "on-break" || value.status === "starting-after-task", "break.forced.status", `${path}.forced`, `a forced break is in effect or starting after the task; ${describeValue(value.status)} says the agent asked, or that there is none`);
|
|
1528
1524
|
}
|
|
1529
1525
|
if (value.reasons === undefined)
|
|
1530
1526
|
return;
|
|
@@ -1563,8 +1559,8 @@ function validateBreakState(value, path, into) {
|
|
|
1563
1559
|
// forced one included, since the lead's force-break command named it. A break of no kind is a break whose
|
|
1564
1560
|
// rules -- who may listen through it, whether it counts -- nobody can apply.
|
|
1565
1561
|
ruleEvaluated("break.activeReasonId.required");
|
|
1566
|
-
if (seen.size > 0 && (value.
|
|
1567
|
-
into.require(typeof value.activeReasonId === "string" && value.activeReasonId.length > 0, "break.activeReasonId.required", `${path}.activeReasonId`, `a break ${describeValue(value.
|
|
1562
|
+
if (seen.size > 0 && (value.status === "on-break" || value.status === "starting-after-task")) {
|
|
1563
|
+
into.require(typeof value.activeReasonId === "string" && value.activeReasonId.length > 0, "break.activeReasonId.required", `${path}.activeReasonId`, `a break ${describeValue(value.status)} on a provider that publishes reasons names the one it is on`);
|
|
1568
1564
|
}
|
|
1569
1565
|
}
|
|
1570
1566
|
export function validateTeamMembers(teamMembers, path = "team", context = {}) {
|
|
@@ -2653,21 +2649,21 @@ export function validateTimeZone(value, path = "context.timeZone") {
|
|
|
2653
2649
|
return into.violations;
|
|
2654
2650
|
}
|
|
2655
2651
|
/**
|
|
2656
|
-
* What `
|
|
2652
|
+
* What `getUserDetails(ids)` answered: an array of users, each one asked for, each a valid identity.
|
|
2657
2653
|
* The provider may answer fewer than asked, which is what "unknown to it" looks like; it never
|
|
2658
2654
|
* answers somebody nobody asked about.
|
|
2659
2655
|
*/
|
|
2660
|
-
export function
|
|
2656
|
+
export function validateUserDetails(users, asked, path = "getUserDetails") {
|
|
2661
2657
|
const into = new Collector();
|
|
2662
2658
|
if (!Array.isArray(users)) {
|
|
2663
|
-
into.add("
|
|
2659
|
+
into.add("getUserDetails.shape", path, "getUserDetails answers an array of users, empty when it knows none of them");
|
|
2664
2660
|
return into.violations;
|
|
2665
2661
|
}
|
|
2666
2662
|
users.forEach((user, index) => {
|
|
2667
2663
|
const at = `${path}[${index}]`;
|
|
2668
|
-
validateUser(user, "
|
|
2664
|
+
validateUser(user, "getUserDetails.user", at, into);
|
|
2669
2665
|
if (isPlainObject(user) && typeof user.id === "string") {
|
|
2670
|
-
into.require(asked.includes(user.id), "
|
|
2666
|
+
into.require(asked.includes(user.id), "getUserDetails.unasked", `${at}.id`, `${user.id} was not among the ids asked for`);
|
|
2671
2667
|
}
|
|
2672
2668
|
});
|
|
2673
2669
|
return into.violations;
|