@dungle-scrubs/tether-protocol 0.1.0
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/event-builders.d.ts +244 -0
- package/dist/event-builders.js +259 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/records.d.ts +313 -0
- package/dist/records.js +72 -0
- package/dist/rest-schemas.d.ts +149 -0
- package/dist/rest-schemas.js +131 -0
- package/dist/task-contracts.d.ts +245 -0
- package/dist/task-contracts.js +256 -0
- package/dist/task-payload-display.d.ts +13 -0
- package/dist/task-payload-display.js +44 -0
- package/dist/websocket.d.ts +215 -0
- package/dist/websocket.js +177 -0
- package/package.json +43 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Approval behavior advertised for an orchestratable task contract. */
|
|
3
|
+
export type TaskContractApproval = "none" | "optional" | "required_for_mutation";
|
|
4
|
+
/** Lightweight JSON Schema object used for participant contract discovery. */
|
|
5
|
+
export type TaskContractJsonSchema = Readonly<Record<string, unknown>>;
|
|
6
|
+
/** Common Tether task contract envelope shared by orchestratable participants. */
|
|
7
|
+
export interface TaskContractEnvelope {
|
|
8
|
+
/** Whether this task can require an approval.recorded event before mutation. */
|
|
9
|
+
readonly approval: TaskContractApproval;
|
|
10
|
+
/** Stable reference to the participant-owned input schema. */
|
|
11
|
+
readonly inputSchemaRef: string;
|
|
12
|
+
/** Participant runtime kind that owns this task kind. */
|
|
13
|
+
readonly participantRuntimeKind: string;
|
|
14
|
+
/** Whether coordinators may create this task without expecting immediate mutation. */
|
|
15
|
+
readonly readOnlyByDefault: boolean;
|
|
16
|
+
/** Stable reference to the participant-owned result schema. */
|
|
17
|
+
readonly resultSchemaRef: string;
|
|
18
|
+
/** Durable Tether task kind. */
|
|
19
|
+
readonly taskKind: string;
|
|
20
|
+
/** Contract schema version. */
|
|
21
|
+
readonly version: string;
|
|
22
|
+
}
|
|
23
|
+
/** Compact task contract summary safe to publish in participant capabilities. */
|
|
24
|
+
export interface TaskContractSummary extends TaskContractEnvelope {
|
|
25
|
+
/** Short description for coordinators and clients. */
|
|
26
|
+
readonly description: string;
|
|
27
|
+
/** Optional participant-owned input JSON Schema for local validation and inspection. */
|
|
28
|
+
readonly inputJsonSchema?: TaskContractJsonSchema;
|
|
29
|
+
/** Optional participant-owned result JSON Schema for inspection and orchestration. */
|
|
30
|
+
readonly resultJsonSchema?: TaskContractJsonSchema;
|
|
31
|
+
/** Human-readable title. */
|
|
32
|
+
readonly title: string;
|
|
33
|
+
}
|
|
34
|
+
/** Full task contract advertisement with common envelope plus domain schemas. */
|
|
35
|
+
export interface TaskContractAdvertisement<TDomain extends object = Record<string, unknown>> {
|
|
36
|
+
/** Common Tether task contract envelope. */
|
|
37
|
+
readonly common: TaskContractEnvelope;
|
|
38
|
+
/** Participant-owned domain contract or schema bundle. */
|
|
39
|
+
readonly domain: TDomain;
|
|
40
|
+
}
|
|
41
|
+
/** Runtime validator for task contract approval behavior. */
|
|
42
|
+
export declare const taskContractApprovalSchema: z.ZodEnum<{
|
|
43
|
+
none: "none";
|
|
44
|
+
optional: "optional";
|
|
45
|
+
required_for_mutation: "required_for_mutation";
|
|
46
|
+
}>;
|
|
47
|
+
/** Runtime validator for a task contract envelope. */
|
|
48
|
+
export declare const taskContractEnvelopeSchema: z.ZodObject<{
|
|
49
|
+
approval: z.ZodEnum<{
|
|
50
|
+
none: "none";
|
|
51
|
+
optional: "optional";
|
|
52
|
+
required_for_mutation: "required_for_mutation";
|
|
53
|
+
}>;
|
|
54
|
+
inputSchemaRef: z.ZodString;
|
|
55
|
+
participantRuntimeKind: z.ZodString;
|
|
56
|
+
readOnlyByDefault: z.ZodBoolean;
|
|
57
|
+
resultSchemaRef: z.ZodString;
|
|
58
|
+
taskKind: z.ZodString;
|
|
59
|
+
version: z.ZodString;
|
|
60
|
+
}, z.core.$strip>;
|
|
61
|
+
/** Runtime validator for compact task contract summaries in capabilities. */
|
|
62
|
+
export declare const taskContractSummarySchema: z.ZodObject<{
|
|
63
|
+
approval: z.ZodEnum<{
|
|
64
|
+
none: "none";
|
|
65
|
+
optional: "optional";
|
|
66
|
+
required_for_mutation: "required_for_mutation";
|
|
67
|
+
}>;
|
|
68
|
+
inputSchemaRef: z.ZodString;
|
|
69
|
+
participantRuntimeKind: z.ZodString;
|
|
70
|
+
readOnlyByDefault: z.ZodBoolean;
|
|
71
|
+
resultSchemaRef: z.ZodString;
|
|
72
|
+
taskKind: z.ZodString;
|
|
73
|
+
version: z.ZodString;
|
|
74
|
+
description: z.ZodString;
|
|
75
|
+
inputJsonSchema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
76
|
+
resultJsonSchema: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
77
|
+
title: z.ZodString;
|
|
78
|
+
}, z.core.$strip>;
|
|
79
|
+
/** Runtime validator for full task contract advertisements. */
|
|
80
|
+
export declare const taskContractAdvertisementSchema: z.ZodObject<{
|
|
81
|
+
common: z.ZodObject<{
|
|
82
|
+
approval: z.ZodEnum<{
|
|
83
|
+
none: "none";
|
|
84
|
+
optional: "optional";
|
|
85
|
+
required_for_mutation: "required_for_mutation";
|
|
86
|
+
}>;
|
|
87
|
+
inputSchemaRef: z.ZodString;
|
|
88
|
+
participantRuntimeKind: z.ZodString;
|
|
89
|
+
readOnlyByDefault: z.ZodBoolean;
|
|
90
|
+
resultSchemaRef: z.ZodString;
|
|
91
|
+
taskKind: z.ZodString;
|
|
92
|
+
version: z.ZodString;
|
|
93
|
+
}, z.core.$strip>;
|
|
94
|
+
domain: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
95
|
+
}, z.core.$strip>;
|
|
96
|
+
/**
|
|
97
|
+
* Validates the conservative JSON Schema subset Tether uses for advertised
|
|
98
|
+
* task-contract input checks. This intentionally covers only the subset used
|
|
99
|
+
* by participant contracts; full domain validation remains participant-owned.
|
|
100
|
+
*/
|
|
101
|
+
export declare function validateJsonSchemaSubset(value: unknown, schema: Readonly<Record<string, unknown>>, path: string): readonly string[];
|
|
102
|
+
/**
|
|
103
|
+
* Current Schedule Window algorithm version. The algorithm version is part of
|
|
104
|
+
* scheduled task identity so a future windowing change produces a distinct task
|
|
105
|
+
* rather than colliding with runs bucketed by the previous algorithm.
|
|
106
|
+
*/
|
|
107
|
+
export declare const scheduleWindowAlgorithmVersion = 1;
|
|
108
|
+
/**
|
|
109
|
+
* Provider plus opaque configured-account identity for one mailbox. A
|
|
110
|
+
* provider-local message id never identifies work outside its Mailbox Scope.
|
|
111
|
+
*/
|
|
112
|
+
export interface MailboxScope {
|
|
113
|
+
/** Immutable opaque configured account identity within one provider. */
|
|
114
|
+
readonly accountId: string;
|
|
115
|
+
/** Provider that owns the account, such as `fastmail` or `gmail`. */
|
|
116
|
+
readonly provider: string;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Deterministic half-open UTC time bucket for recurring work identity. Version 1
|
|
120
|
+
* is `[floor(unix_ms / interval_ms) * interval_ms, start + interval_ms)`.
|
|
121
|
+
*/
|
|
122
|
+
export interface ScheduleWindow {
|
|
123
|
+
/** Windowing algorithm version that produced this bucket. */
|
|
124
|
+
readonly algorithmVersion: number;
|
|
125
|
+
/** Exclusive end of the half-open interval, in unix milliseconds. */
|
|
126
|
+
readonly endMs: number;
|
|
127
|
+
/** Configured interval width in milliseconds; part of task identity. */
|
|
128
|
+
readonly intervalMs: number;
|
|
129
|
+
/** Inclusive start of the half-open interval, in unix milliseconds. */
|
|
130
|
+
readonly startMs: number;
|
|
131
|
+
}
|
|
132
|
+
/** Inputs that deterministically identify one Scheduled Maintenance Run. */
|
|
133
|
+
export interface ScheduledMaintenanceIdentity {
|
|
134
|
+
/** Durable Tether task kind, such as `email_organization`. */
|
|
135
|
+
readonly kind: string;
|
|
136
|
+
/** Mailbox Scope the scheduled run is restricted to. */
|
|
137
|
+
readonly mailboxScope: MailboxScope;
|
|
138
|
+
/** Deterministic Schedule Window the run belongs to. */
|
|
139
|
+
readonly scheduleWindow: ScheduleWindow;
|
|
140
|
+
/** Durable Tether session that owns the scheduled run. */
|
|
141
|
+
readonly sessionId: string;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Computes the deterministic Schedule Window bucket for a timestamp. The
|
|
145
|
+
* interval must be a positive integer number of milliseconds; a rolled-back
|
|
146
|
+
* clock inside the same bucket resolves to the same window.
|
|
147
|
+
*/
|
|
148
|
+
export declare function computeScheduleWindow(unixMs: number, intervalMs: number, algorithmVersion?: number): ScheduleWindow;
|
|
149
|
+
/**
|
|
150
|
+
* Encodes a Schedule Window as a stable, human-inspectable bucket key. The key
|
|
151
|
+
* carries algorithm version, interval, and start so distinct configurations can
|
|
152
|
+
* never share one bucket string.
|
|
153
|
+
*/
|
|
154
|
+
export declare function scheduleWindowKey(window: ScheduleWindow): string;
|
|
155
|
+
/**
|
|
156
|
+
* Derives a stable, opaque scheduled task id from a Scheduled Maintenance
|
|
157
|
+
* Identity. Repeated derivation for one identity is deterministic; any change to
|
|
158
|
+
* session, kind, Mailbox Scope, interval, algorithm version, or window start
|
|
159
|
+
* yields a different id. Callers pass this into the existing task-creation
|
|
160
|
+
* idempotency seam instead of performing a read-then-create race.
|
|
161
|
+
*/
|
|
162
|
+
export declare function deriveScheduledTaskId(identity: ScheduledMaintenanceIdentity): string;
|
|
163
|
+
/** Reason a scheduled-supersession operation refuses to cancel a candidate task. */
|
|
164
|
+
export type ScheduledSupersessionRefusalReason = "claimed" | "current_window" | "manual" | "schedule_identity_mismatch" | "terminal";
|
|
165
|
+
/** Every scheduled-supersession refusal reason, for exhaustive validation. */
|
|
166
|
+
export declare const scheduledSupersessionRefusalReasons: readonly ["claimed", "current_window", "manual", "schedule_identity_mismatch", "terminal"];
|
|
167
|
+
/** Durable schedule identity attached to a scheduled task candidate. */
|
|
168
|
+
export interface CandidateScheduleIdentity {
|
|
169
|
+
readonly mailboxScope: MailboxScope;
|
|
170
|
+
readonly scheduleWindow: ScheduleWindow;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Minimal candidate view a supersession decision is made against. It mirrors the
|
|
174
|
+
* durable task columns that decide eligibility without pulling in the full
|
|
175
|
+
* task record shape.
|
|
176
|
+
*/
|
|
177
|
+
export interface ScheduledSupersessionCandidate {
|
|
178
|
+
readonly cancelledAt: string | null;
|
|
179
|
+
readonly claimedBy: string | null;
|
|
180
|
+
readonly completedAt: string | null;
|
|
181
|
+
readonly failedAt: string | null;
|
|
182
|
+
readonly kind: string;
|
|
183
|
+
/** Schedule identity, or null for a manual (non-scheduled) task. */
|
|
184
|
+
readonly schedule: CandidateScheduleIdentity | null;
|
|
185
|
+
readonly sessionId: string;
|
|
186
|
+
}
|
|
187
|
+
/** Outcome of classifying a candidate against a current scheduled identity. */
|
|
188
|
+
export type ScheduledSupersessionOutcome = {
|
|
189
|
+
readonly decision: "supersede";
|
|
190
|
+
} | {
|
|
191
|
+
readonly decision: "refuse";
|
|
192
|
+
readonly reason: ScheduledSupersessionRefusalReason;
|
|
193
|
+
};
|
|
194
|
+
/**
|
|
195
|
+
* Decides whether a candidate task may be atomically superseded by the current
|
|
196
|
+
* scheduled run. This is the authoritative refusal logic the atomic persistence
|
|
197
|
+
* predicate mirrors: only an older, matching, unclaimed, nonterminal scheduled
|
|
198
|
+
* run is superseded. A racing claim, a manual task, a terminal task, a task with
|
|
199
|
+
* a different schedule identity, or the current/newer window all refuse.
|
|
200
|
+
*/
|
|
201
|
+
export declare function classifyScheduledSupersession(candidate: ScheduledSupersessionCandidate, target: ScheduledMaintenanceIdentity): ScheduledSupersessionOutcome;
|
|
202
|
+
/** One refused candidate in a scheduled-supersession result. */
|
|
203
|
+
export interface ScheduledSupersessionRefusalRecord {
|
|
204
|
+
readonly reason: ScheduledSupersessionRefusalReason;
|
|
205
|
+
readonly taskId: string;
|
|
206
|
+
}
|
|
207
|
+
/** Typed result of a scheduled-supersession operation over one schedule identity. */
|
|
208
|
+
export interface ScheduledSupersessionResultRecord {
|
|
209
|
+
readonly refusals: readonly ScheduledSupersessionRefusalRecord[];
|
|
210
|
+
readonly supersededTaskIds: readonly string[];
|
|
211
|
+
}
|
|
212
|
+
/** Runtime validator for a mailbox scope. */
|
|
213
|
+
export declare const mailboxScopeSchema: z.ZodObject<{
|
|
214
|
+
accountId: z.ZodString;
|
|
215
|
+
provider: z.ZodString;
|
|
216
|
+
}, z.core.$strip>;
|
|
217
|
+
/** Runtime validator for a deterministic Schedule Window. */
|
|
218
|
+
export declare const scheduleWindowSchema: z.ZodObject<{
|
|
219
|
+
algorithmVersion: z.ZodNumber;
|
|
220
|
+
endMs: z.ZodNumber;
|
|
221
|
+
intervalMs: z.ZodNumber;
|
|
222
|
+
startMs: z.ZodNumber;
|
|
223
|
+
}, z.core.$strip>;
|
|
224
|
+
/** Runtime validator for a scheduled-supersession refusal reason. */
|
|
225
|
+
export declare const scheduledSupersessionRefusalReasonSchema: z.ZodEnum<{
|
|
226
|
+
claimed: "claimed";
|
|
227
|
+
current_window: "current_window";
|
|
228
|
+
manual: "manual";
|
|
229
|
+
schedule_identity_mismatch: "schedule_identity_mismatch";
|
|
230
|
+
terminal: "terminal";
|
|
231
|
+
}>;
|
|
232
|
+
/** Runtime validator for a scheduled-supersession result envelope. */
|
|
233
|
+
export declare const scheduledSupersessionResultSchema: z.ZodObject<{
|
|
234
|
+
refusals: z.ZodArray<z.ZodObject<{
|
|
235
|
+
reason: z.ZodEnum<{
|
|
236
|
+
claimed: "claimed";
|
|
237
|
+
current_window: "current_window";
|
|
238
|
+
manual: "manual";
|
|
239
|
+
schedule_identity_mismatch: "schedule_identity_mismatch";
|
|
240
|
+
terminal: "terminal";
|
|
241
|
+
}>;
|
|
242
|
+
taskId: z.ZodString;
|
|
243
|
+
}, z.core.$strip>>;
|
|
244
|
+
supersededTaskIds: z.ZodArray<z.ZodString>;
|
|
245
|
+
}, z.core.$strip>;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Runtime validator for task contract approval behavior. */
|
|
3
|
+
export const taskContractApprovalSchema = z.enum(["none", "optional", "required_for_mutation"]);
|
|
4
|
+
/** Runtime validator for a task contract envelope. */
|
|
5
|
+
export const taskContractEnvelopeSchema = z.object({
|
|
6
|
+
approval: taskContractApprovalSchema,
|
|
7
|
+
inputSchemaRef: z.string().min(1),
|
|
8
|
+
participantRuntimeKind: z.string().min(1),
|
|
9
|
+
readOnlyByDefault: z.boolean(),
|
|
10
|
+
resultSchemaRef: z.string().min(1),
|
|
11
|
+
taskKind: z.string().min(1),
|
|
12
|
+
version: z.string().min(1),
|
|
13
|
+
});
|
|
14
|
+
/** Runtime validator for compact task contract summaries in capabilities. */
|
|
15
|
+
export const taskContractSummarySchema = taskContractEnvelopeSchema.extend({
|
|
16
|
+
description: z.string().min(1),
|
|
17
|
+
inputJsonSchema: z.record(z.string(), z.unknown()).optional(),
|
|
18
|
+
resultJsonSchema: z.record(z.string(), z.unknown()).optional(),
|
|
19
|
+
title: z.string().min(1),
|
|
20
|
+
});
|
|
21
|
+
/** Runtime validator for full task contract advertisements. */
|
|
22
|
+
export const taskContractAdvertisementSchema = z.object({
|
|
23
|
+
common: taskContractEnvelopeSchema,
|
|
24
|
+
domain: z.record(z.string(), z.unknown()),
|
|
25
|
+
});
|
|
26
|
+
/**
|
|
27
|
+
* Validates the conservative JSON Schema subset Tether uses for advertised
|
|
28
|
+
* task-contract input checks. This intentionally covers only the subset used
|
|
29
|
+
* by participant contracts; full domain validation remains participant-owned.
|
|
30
|
+
*/
|
|
31
|
+
export function validateJsonSchemaSubset(value, schema, path) {
|
|
32
|
+
const issues = [];
|
|
33
|
+
const type = schema.type;
|
|
34
|
+
if (typeof type === "string" && !jsonSchemaTypeMatches(value, type)) {
|
|
35
|
+
return [`${path} must be ${type}`];
|
|
36
|
+
}
|
|
37
|
+
if (Array.isArray(type) && type.every((item) => typeof item === "string")) {
|
|
38
|
+
const allowedTypes = type.filter((item) => typeof item === "string");
|
|
39
|
+
if (!allowedTypes.some((allowedType) => jsonSchemaTypeMatches(value, allowedType))) {
|
|
40
|
+
return [`${path} must be one of ${allowedTypes.join(", ")}`];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const enumValues = schema.enum;
|
|
44
|
+
if (Array.isArray(enumValues) && !enumValues.some((item) => Object.is(item, value))) {
|
|
45
|
+
issues.push(`${path} must be one of ${enumValues.map(String).join(", ")}`);
|
|
46
|
+
}
|
|
47
|
+
if ("const" in schema && !Object.is(schema.const, value)) {
|
|
48
|
+
issues.push(`${path} must equal ${String(schema.const)}`);
|
|
49
|
+
}
|
|
50
|
+
if (typeof value === "number") {
|
|
51
|
+
const minimum = schema.minimum;
|
|
52
|
+
if (typeof minimum === "number" && value < minimum) {
|
|
53
|
+
issues.push(`${path} must be >= ${minimum}`);
|
|
54
|
+
}
|
|
55
|
+
const maximum = schema.maximum;
|
|
56
|
+
if (typeof maximum === "number" && value > maximum) {
|
|
57
|
+
issues.push(`${path} must be <= ${maximum}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (schema.type === "object" && isPlainRecord(value)) {
|
|
61
|
+
issues.push(...validateObjectSchemaSubset(value, schema, path));
|
|
62
|
+
}
|
|
63
|
+
if (schema.type === "array" && Array.isArray(value)) {
|
|
64
|
+
const items = schema.items;
|
|
65
|
+
if (isPlainRecord(items)) {
|
|
66
|
+
value.forEach((item, index) => {
|
|
67
|
+
issues.push(...validateJsonSchemaSubset(item, items, `${path}[${index}]`));
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return issues;
|
|
72
|
+
}
|
|
73
|
+
/** Validates object-specific JSON Schema subset fields. */
|
|
74
|
+
function validateObjectSchemaSubset(value, schema, path) {
|
|
75
|
+
const issues = [];
|
|
76
|
+
const required = schema.required;
|
|
77
|
+
if (Array.isArray(required)) {
|
|
78
|
+
for (const field of required) {
|
|
79
|
+
if (typeof field === "string" && !(field in value)) {
|
|
80
|
+
issues.push(`${path}.${field} is required`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const properties = isPlainRecord(schema.properties) ? schema.properties : {};
|
|
85
|
+
if (schema.additionalProperties === false) {
|
|
86
|
+
for (const field of Object.keys(value)) {
|
|
87
|
+
if (!(field in properties)) {
|
|
88
|
+
issues.push(`${path}.${field} is not allowed`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const [field, propertySchema] of Object.entries(properties)) {
|
|
93
|
+
if (field in value && isPlainRecord(propertySchema)) {
|
|
94
|
+
issues.push(...validateJsonSchemaSubset(value[field], propertySchema, `${path}.${field}`));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return issues;
|
|
98
|
+
}
|
|
99
|
+
/** Checks JSON Schema primitive type names against a runtime value. */
|
|
100
|
+
function jsonSchemaTypeMatches(value, type) {
|
|
101
|
+
switch (type) {
|
|
102
|
+
case "array":
|
|
103
|
+
return Array.isArray(value);
|
|
104
|
+
case "boolean":
|
|
105
|
+
return typeof value === "boolean";
|
|
106
|
+
case "integer":
|
|
107
|
+
return Number.isInteger(value);
|
|
108
|
+
case "null":
|
|
109
|
+
return value === null;
|
|
110
|
+
case "number":
|
|
111
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
112
|
+
case "object":
|
|
113
|
+
return isPlainRecord(value);
|
|
114
|
+
case "string":
|
|
115
|
+
return typeof value === "string";
|
|
116
|
+
default:
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Returns whether a value is a non-array JSON object. */
|
|
121
|
+
function isPlainRecord(value) {
|
|
122
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Current Schedule Window algorithm version. The algorithm version is part of
|
|
126
|
+
* scheduled task identity so a future windowing change produces a distinct task
|
|
127
|
+
* rather than colliding with runs bucketed by the previous algorithm.
|
|
128
|
+
*/
|
|
129
|
+
export const scheduleWindowAlgorithmVersion = 1;
|
|
130
|
+
/**
|
|
131
|
+
* Computes the deterministic Schedule Window bucket for a timestamp. The
|
|
132
|
+
* interval must be a positive integer number of milliseconds; a rolled-back
|
|
133
|
+
* clock inside the same bucket resolves to the same window.
|
|
134
|
+
*/
|
|
135
|
+
export function computeScheduleWindow(unixMs, intervalMs, algorithmVersion = scheduleWindowAlgorithmVersion) {
|
|
136
|
+
if (!Number.isSafeInteger(intervalMs) || intervalMs <= 0) {
|
|
137
|
+
throw new Error(`Schedule Window interval must be a positive integer, received ${intervalMs}`);
|
|
138
|
+
}
|
|
139
|
+
if (!Number.isFinite(unixMs)) {
|
|
140
|
+
throw new Error(`Schedule Window timestamp must be finite, received ${unixMs}`);
|
|
141
|
+
}
|
|
142
|
+
const startMs = Math.floor(unixMs / intervalMs) * intervalMs;
|
|
143
|
+
return { algorithmVersion, endMs: startMs + intervalMs, intervalMs, startMs };
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Encodes a Schedule Window as a stable, human-inspectable bucket key. The key
|
|
147
|
+
* carries algorithm version, interval, and start so distinct configurations can
|
|
148
|
+
* never share one bucket string.
|
|
149
|
+
*/
|
|
150
|
+
export function scheduleWindowKey(window) {
|
|
151
|
+
return `v${window.algorithmVersion}:${window.intervalMs}:${window.startMs}`;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Derives a stable, opaque scheduled task id from a Scheduled Maintenance
|
|
155
|
+
* Identity. Repeated derivation for one identity is deterministic; any change to
|
|
156
|
+
* session, kind, Mailbox Scope, interval, algorithm version, or window start
|
|
157
|
+
* yields a different id. Callers pass this into the existing task-creation
|
|
158
|
+
* idempotency seam instead of performing a read-then-create race.
|
|
159
|
+
*/
|
|
160
|
+
export function deriveScheduledTaskId(identity) {
|
|
161
|
+
const canonical = JSON.stringify([
|
|
162
|
+
identity.scheduleWindow.algorithmVersion,
|
|
163
|
+
identity.sessionId,
|
|
164
|
+
identity.kind,
|
|
165
|
+
identity.mailboxScope.provider,
|
|
166
|
+
identity.mailboxScope.accountId,
|
|
167
|
+
identity.scheduleWindow.intervalMs,
|
|
168
|
+
identity.scheduleWindow.startMs,
|
|
169
|
+
]);
|
|
170
|
+
return `task_sched_${fnv1a64Hex(canonical)}`;
|
|
171
|
+
}
|
|
172
|
+
/** Every scheduled-supersession refusal reason, for exhaustive validation. */
|
|
173
|
+
export const scheduledSupersessionRefusalReasons = [
|
|
174
|
+
"claimed",
|
|
175
|
+
"current_window",
|
|
176
|
+
"manual",
|
|
177
|
+
"schedule_identity_mismatch",
|
|
178
|
+
"terminal",
|
|
179
|
+
];
|
|
180
|
+
/**
|
|
181
|
+
* Decides whether a candidate task may be atomically superseded by the current
|
|
182
|
+
* scheduled run. This is the authoritative refusal logic the atomic persistence
|
|
183
|
+
* predicate mirrors: only an older, matching, unclaimed, nonterminal scheduled
|
|
184
|
+
* run is superseded. A racing claim, a manual task, a terminal task, a task with
|
|
185
|
+
* a different schedule identity, or the current/newer window all refuse.
|
|
186
|
+
*/
|
|
187
|
+
export function classifyScheduledSupersession(candidate, target) {
|
|
188
|
+
if (candidate.schedule === null) {
|
|
189
|
+
return { decision: "refuse", reason: "manual" };
|
|
190
|
+
}
|
|
191
|
+
if (!scheduleIdentityMatches(candidate, target)) {
|
|
192
|
+
return { decision: "refuse", reason: "schedule_identity_mismatch" };
|
|
193
|
+
}
|
|
194
|
+
if (candidate.completedAt !== null ||
|
|
195
|
+
candidate.failedAt !== null ||
|
|
196
|
+
candidate.cancelledAt !== null) {
|
|
197
|
+
return { decision: "refuse", reason: "terminal" };
|
|
198
|
+
}
|
|
199
|
+
if (candidate.claimedBy !== null) {
|
|
200
|
+
return { decision: "refuse", reason: "claimed" };
|
|
201
|
+
}
|
|
202
|
+
if (candidate.schedule.scheduleWindow.startMs >= target.scheduleWindow.startMs) {
|
|
203
|
+
return { decision: "refuse", reason: "current_window" };
|
|
204
|
+
}
|
|
205
|
+
return { decision: "supersede" };
|
|
206
|
+
}
|
|
207
|
+
/** Returns whether a candidate shares the exact schedule identity of the target. */
|
|
208
|
+
function scheduleIdentityMatches(candidate, target) {
|
|
209
|
+
const schedule = candidate.schedule;
|
|
210
|
+
if (schedule === null) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
return (candidate.sessionId === target.sessionId &&
|
|
214
|
+
candidate.kind === target.kind &&
|
|
215
|
+
schedule.mailboxScope.provider === target.mailboxScope.provider &&
|
|
216
|
+
schedule.mailboxScope.accountId === target.mailboxScope.accountId &&
|
|
217
|
+
schedule.scheduleWindow.algorithmVersion === target.scheduleWindow.algorithmVersion &&
|
|
218
|
+
schedule.scheduleWindow.intervalMs === target.scheduleWindow.intervalMs);
|
|
219
|
+
}
|
|
220
|
+
/** Runtime validator for a mailbox scope. */
|
|
221
|
+
export const mailboxScopeSchema = z.object({
|
|
222
|
+
accountId: z.string().min(1),
|
|
223
|
+
provider: z.string().min(1),
|
|
224
|
+
});
|
|
225
|
+
/** Runtime validator for a deterministic Schedule Window. */
|
|
226
|
+
export const scheduleWindowSchema = z.object({
|
|
227
|
+
algorithmVersion: z.number().int().positive(),
|
|
228
|
+
endMs: z.number().int(),
|
|
229
|
+
intervalMs: z.number().int().positive(),
|
|
230
|
+
startMs: z.number().int().nonnegative(),
|
|
231
|
+
});
|
|
232
|
+
/** Runtime validator for a scheduled-supersession refusal reason. */
|
|
233
|
+
export const scheduledSupersessionRefusalReasonSchema = z.enum(scheduledSupersessionRefusalReasons);
|
|
234
|
+
/** Runtime validator for a scheduled-supersession result envelope. */
|
|
235
|
+
export const scheduledSupersessionResultSchema = z.object({
|
|
236
|
+
refusals: z.array(z.object({
|
|
237
|
+
reason: scheduledSupersessionRefusalReasonSchema,
|
|
238
|
+
taskId: z.string().min(1),
|
|
239
|
+
})),
|
|
240
|
+
supersededTaskIds: z.array(z.string().min(1)),
|
|
241
|
+
});
|
|
242
|
+
/**
|
|
243
|
+
* 64-bit FNV-1a hash rendered as lowercase hex. Deterministic and dependency
|
|
244
|
+
* free so scheduled task ids are identical across every runtime that derives
|
|
245
|
+
* them from the same identity.
|
|
246
|
+
*/
|
|
247
|
+
function fnv1a64Hex(input) {
|
|
248
|
+
const prime = 0x100000001b3n;
|
|
249
|
+
const mask = 0xffffffffffffffffn;
|
|
250
|
+
let hash = 0xcbf29ce484222325n;
|
|
251
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
252
|
+
hash ^= BigInt(input.charCodeAt(index));
|
|
253
|
+
hash = (hash * prime) & mask;
|
|
254
|
+
}
|
|
255
|
+
return hash.toString(16).padStart(16, "0");
|
|
256
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable task payload display prepared for client surfaces.
|
|
3
|
+
*/
|
|
4
|
+
export interface TaskPayloadDisplay {
|
|
5
|
+
/** Stable label for the payload section. */
|
|
6
|
+
readonly label: "Failure" | "Result";
|
|
7
|
+
/** Lines that can be rendered directly by text-based clients. */
|
|
8
|
+
readonly lines: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
/** Formats a successful task result payload for text clients. */
|
|
11
|
+
export declare function formatTaskResultPayload(payload: Record<string, unknown> | null): TaskPayloadDisplay;
|
|
12
|
+
/** Formats a failed task payload for text clients. */
|
|
13
|
+
export declare function formatTaskFailurePayload(payload: Record<string, unknown> | null): TaskPayloadDisplay;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Formats a successful task result payload for text clients. */
|
|
2
|
+
export function formatTaskResultPayload(payload) {
|
|
3
|
+
return formatTaskPayload("Result", payload);
|
|
4
|
+
}
|
|
5
|
+
/** Formats a failed task payload for text clients. */
|
|
6
|
+
export function formatTaskFailurePayload(payload) {
|
|
7
|
+
return formatTaskPayload("Failure", payload);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Formats a task payload using common agent result conventions with a stable
|
|
11
|
+
* JSON fallback for unknown shapes.
|
|
12
|
+
*/
|
|
13
|
+
function formatTaskPayload(label, payload) {
|
|
14
|
+
if (payload === null) {
|
|
15
|
+
return { label, lines: ["none"] };
|
|
16
|
+
}
|
|
17
|
+
const lines = [
|
|
18
|
+
...formatStringField(payload, "summary"),
|
|
19
|
+
...formatStringField(payload, "output"),
|
|
20
|
+
...formatStringArrayField(payload, "actions"),
|
|
21
|
+
...formatStringField(payload, "error"),
|
|
22
|
+
];
|
|
23
|
+
if (lines.length > 0) {
|
|
24
|
+
return { label, lines };
|
|
25
|
+
}
|
|
26
|
+
return { label, lines: [JSON.stringify(payload)] };
|
|
27
|
+
}
|
|
28
|
+
/** Formats one string field when present. */
|
|
29
|
+
function formatStringField(payload, field) {
|
|
30
|
+
const value = payload[field];
|
|
31
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
return [value];
|
|
35
|
+
}
|
|
36
|
+
/** Formats one string-array field as bullet lines when present. */
|
|
37
|
+
function formatStringArrayField(payload, field) {
|
|
38
|
+
const value = payload[field];
|
|
39
|
+
if (!Array.isArray(value)) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
const items = value.filter((item) => typeof item === "string" && item.length > 0);
|
|
43
|
+
return items.map((item) => `- ${item}`);
|
|
44
|
+
}
|