@frockbot/plugin-authoring 0.0.0 → 0.1.1
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/frockbot.json +18 -0
- package/package.json +31 -6
- package/src/agent.test.ts +162 -0
- package/src/agent.ts +190 -0
- package/src/index.ts +5 -0
- package/src/manifest.ts +3 -0
- package/src/quota.test.ts +213 -0
- package/src/quota.ts +308 -0
- package/src/records.test.ts +90 -0
- package/src/records.ts +172 -0
- package/src/shared.test.ts +138 -0
- package/src/shared.ts +303 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/src/quota.ts
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// D7 quotas, as durable per-User configuration in the User Durable Object.
|
|
2
|
+
//
|
|
3
|
+
// "Generation creation rate, artifact size, retained generations ... are
|
|
4
|
+
// bounded by durable per-User quotas; exceeding a quota refuses the operation
|
|
5
|
+
// and records a visible failure." The User's Durable Object is the authority
|
|
6
|
+
// for User-scoped quotas, so the counter and the configured limits live there
|
|
7
|
+
// and the Bot's Durable Object reserves a unit over a narrow RPC before it
|
|
8
|
+
// records an authorship intent.
|
|
9
|
+
//
|
|
10
|
+
// Reservation is idempotent on the authoring `effectId`: a resumed Turn that
|
|
11
|
+
// re-executes the same tool call must not consume a second unit.
|
|
12
|
+
|
|
13
|
+
export const AUTHORING_QUOTA_CONFIG_KEY = "quota:authoring";
|
|
14
|
+
export const AUTHORING_QUOTA_COUNTER_PREFIX = "quota:generations:";
|
|
15
|
+
export const AUTHORING_QUOTA_RESERVATION_PREFIX = "quota:reservation:";
|
|
16
|
+
|
|
17
|
+
/** D7 defaults. Durable per-User config overrides them. */
|
|
18
|
+
export const AUTHORING_QUOTA_DEFAULTS_V1: AuthoringQuotaConfigV1 = {
|
|
19
|
+
schemaVersion: 1,
|
|
20
|
+
retainedGenerationsPerBot: 50,
|
|
21
|
+
authoredPerUserPerDay: 100,
|
|
22
|
+
maxSourceBytes: 256 * 1024,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export interface AuthoringQuotaConfigV1 {
|
|
26
|
+
schemaVersion: 1;
|
|
27
|
+
retainedGenerationsPerBot: number;
|
|
28
|
+
authoredPerUserPerDay: number;
|
|
29
|
+
maxSourceBytes: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type AuthoringQuotaLimitV1 =
|
|
33
|
+
"source-bytes" | "retained-generations" | "authored-per-day";
|
|
34
|
+
|
|
35
|
+
export interface AuthoringQuotaRequestV1 {
|
|
36
|
+
schemaVersion: 1;
|
|
37
|
+
userId: string;
|
|
38
|
+
botId: string;
|
|
39
|
+
/** The authoring effect this unit is reserved for; reservation is per effect. */
|
|
40
|
+
effectId: string;
|
|
41
|
+
/** `yyyy-mm-dd`, resolved by the caller from its own clock. */
|
|
42
|
+
day: string;
|
|
43
|
+
sourceBytes: number;
|
|
44
|
+
retainedGenerations: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type AuthoringQuotaReceiptV1 =
|
|
48
|
+
| {
|
|
49
|
+
schemaVersion: 1;
|
|
50
|
+
status: "reserved";
|
|
51
|
+
effectId: string;
|
|
52
|
+
day: string;
|
|
53
|
+
used: number;
|
|
54
|
+
limit: number;
|
|
55
|
+
}
|
|
56
|
+
| {
|
|
57
|
+
schemaVersion: 1;
|
|
58
|
+
status: "refused";
|
|
59
|
+
effectId: string;
|
|
60
|
+
day: string;
|
|
61
|
+
limitName: AuthoringQuotaLimitV1;
|
|
62
|
+
reason: string;
|
|
63
|
+
used: number;
|
|
64
|
+
limit: number;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const AUTHORING_QUOTA_DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
68
|
+
|
|
69
|
+
export function authoringQuotaCounterKey(day: string): string {
|
|
70
|
+
if (!AUTHORING_QUOTA_DAY.test(day)) {
|
|
71
|
+
throw new Error("authoring quota day must be yyyy-mm-dd");
|
|
72
|
+
}
|
|
73
|
+
return `${AUTHORING_QUOTA_COUNTER_PREFIX}${day}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function authoringQuotaReservationKey(effectId: string): string {
|
|
77
|
+
return `${AUTHORING_QUOTA_RESERVATION_PREFIX}${effectId}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `yyyy-mm-dd` in UTC; the counter key is a calendar day, not a rolling window. */
|
|
81
|
+
export function authoringQuotaDayV1(at: Date): string {
|
|
82
|
+
return at.toISOString().slice(0, 10);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The narrow storage surface this module needs from the User Durable Object. */
|
|
86
|
+
export interface AuthoringQuotaTransaction {
|
|
87
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
88
|
+
put(key: string, value: unknown): Promise<void>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The User Durable Object's storage. `transaction` is required because the
|
|
93
|
+
* daily counter is a read-modify-write that spans awaits: two reservations
|
|
94
|
+
* racing at the limit would otherwise both read the same count and both admit.
|
|
95
|
+
*/
|
|
96
|
+
export interface AuthoringQuotaStorage extends AuthoringQuotaTransaction {
|
|
97
|
+
transaction<T>(
|
|
98
|
+
callback: (storage: AuthoringQuotaTransaction) => Promise<T>,
|
|
99
|
+
): Promise<T>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function decodeAuthoringQuotaConfigV1(
|
|
103
|
+
input: unknown,
|
|
104
|
+
): AuthoringQuotaConfigV1 {
|
|
105
|
+
if (input === undefined) return { ...AUTHORING_QUOTA_DEFAULTS_V1 };
|
|
106
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
107
|
+
throw new Error("authoring quota configuration is invalid");
|
|
108
|
+
}
|
|
109
|
+
const value = input as Record<string, unknown>;
|
|
110
|
+
const keys = [
|
|
111
|
+
"schemaVersion",
|
|
112
|
+
"retainedGenerationsPerBot",
|
|
113
|
+
"authoredPerUserPerDay",
|
|
114
|
+
"maxSourceBytes",
|
|
115
|
+
];
|
|
116
|
+
if (
|
|
117
|
+
value.schemaVersion !== 1 ||
|
|
118
|
+
Object.keys(value).length !== keys.length ||
|
|
119
|
+
!keys.every((key) => Object.hasOwn(value, key))
|
|
120
|
+
) {
|
|
121
|
+
throw new Error("authoring quota configuration is invalid");
|
|
122
|
+
}
|
|
123
|
+
const bounded = (name: keyof AuthoringQuotaConfigV1, maximum: number) => {
|
|
124
|
+
const candidate = value[name];
|
|
125
|
+
if (
|
|
126
|
+
!Number.isSafeInteger(candidate) ||
|
|
127
|
+
(candidate as number) < 1 ||
|
|
128
|
+
(candidate as number) > maximum
|
|
129
|
+
) {
|
|
130
|
+
throw new Error(`authoring quota ${String(name)} is invalid`);
|
|
131
|
+
}
|
|
132
|
+
return candidate as number;
|
|
133
|
+
};
|
|
134
|
+
return {
|
|
135
|
+
schemaVersion: 1,
|
|
136
|
+
retainedGenerationsPerBot: bounded("retainedGenerationsPerBot", 10_000),
|
|
137
|
+
authoredPerUserPerDay: bounded("authoredPerUserPerDay", 100_000),
|
|
138
|
+
maxSourceBytes: bounded("maxSourceBytes", 8 * 1024 * 1024),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function counterValue(input: unknown): number {
|
|
143
|
+
if (input === undefined) return 0;
|
|
144
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
145
|
+
throw new Error("authoring quota counter is invalid");
|
|
146
|
+
}
|
|
147
|
+
const value = (input as { count?: unknown }).count;
|
|
148
|
+
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
|
149
|
+
throw new Error("authoring quota counter is invalid");
|
|
150
|
+
}
|
|
151
|
+
return value as number;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function refusal(
|
|
155
|
+
request: AuthoringQuotaRequestV1,
|
|
156
|
+
limitName: AuthoringQuotaLimitV1,
|
|
157
|
+
reason: string,
|
|
158
|
+
used: number,
|
|
159
|
+
limit: number,
|
|
160
|
+
): AuthoringQuotaReceiptV1 {
|
|
161
|
+
return {
|
|
162
|
+
schemaVersion: 1,
|
|
163
|
+
status: "refused",
|
|
164
|
+
effectId: request.effectId,
|
|
165
|
+
day: request.day,
|
|
166
|
+
limitName,
|
|
167
|
+
reason,
|
|
168
|
+
used,
|
|
169
|
+
limit,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Reserves one authored-generation unit for the day, or refuses. Never throws
|
|
175
|
+
* for a breach: a quota breach is an observable outcome the Bot's tool result
|
|
176
|
+
* reports and the durable failure record preserves.
|
|
177
|
+
*/
|
|
178
|
+
export async function reserveAuthoringQuotaV1(
|
|
179
|
+
storage: AuthoringQuotaStorage,
|
|
180
|
+
request: AuthoringQuotaRequestV1,
|
|
181
|
+
): Promise<AuthoringQuotaReceiptV1> {
|
|
182
|
+
// One transaction from the reservation lookup to the counter write: the
|
|
183
|
+
// read-modify-write spans awaits, so two concurrent reservations at the
|
|
184
|
+
// limit must not both see the same count.
|
|
185
|
+
return storage.transaction(async (transaction) => {
|
|
186
|
+
const reservationKey = authoringQuotaReservationKey(request.effectId);
|
|
187
|
+
const existing =
|
|
188
|
+
await transaction.get<AuthoringQuotaReceiptV1>(reservationKey);
|
|
189
|
+
if (existing) return existing;
|
|
190
|
+
|
|
191
|
+
const config = decodeAuthoringQuotaConfigV1(
|
|
192
|
+
await transaction.get<unknown>(AUTHORING_QUOTA_CONFIG_KEY),
|
|
193
|
+
);
|
|
194
|
+
const counterKey = authoringQuotaCounterKey(request.day);
|
|
195
|
+
const used = counterValue(await transaction.get<unknown>(counterKey));
|
|
196
|
+
|
|
197
|
+
let receipt: AuthoringQuotaReceiptV1;
|
|
198
|
+
if (request.sourceBytes > config.maxSourceBytes) {
|
|
199
|
+
receipt = refusal(
|
|
200
|
+
request,
|
|
201
|
+
"source-bytes",
|
|
202
|
+
`Package source is ${request.sourceBytes} bytes; this User's quota allows ${config.maxSourceBytes}`,
|
|
203
|
+
request.sourceBytes,
|
|
204
|
+
config.maxSourceBytes,
|
|
205
|
+
);
|
|
206
|
+
} else if (
|
|
207
|
+
request.retainedGenerations >= config.retainedGenerationsPerBot
|
|
208
|
+
) {
|
|
209
|
+
receipt = refusal(
|
|
210
|
+
request,
|
|
211
|
+
"retained-generations",
|
|
212
|
+
`this Bot retains ${request.retainedGenerations} Composition generations; this User's quota allows ${config.retainedGenerationsPerBot}`,
|
|
213
|
+
request.retainedGenerations,
|
|
214
|
+
config.retainedGenerationsPerBot,
|
|
215
|
+
);
|
|
216
|
+
} else if (used >= config.authoredPerUserPerDay) {
|
|
217
|
+
receipt = refusal(
|
|
218
|
+
request,
|
|
219
|
+
"authored-per-day",
|
|
220
|
+
`this User has authored ${used} generations on ${request.day}; the daily quota is ${config.authoredPerUserPerDay}`,
|
|
221
|
+
used,
|
|
222
|
+
config.authoredPerUserPerDay,
|
|
223
|
+
);
|
|
224
|
+
} else {
|
|
225
|
+
receipt = {
|
|
226
|
+
schemaVersion: 1,
|
|
227
|
+
status: "reserved",
|
|
228
|
+
effectId: request.effectId,
|
|
229
|
+
day: request.day,
|
|
230
|
+
used: used + 1,
|
|
231
|
+
limit: config.authoredPerUserPerDay,
|
|
232
|
+
};
|
|
233
|
+
await transaction.put(counterKey, { day: request.day, count: used + 1 });
|
|
234
|
+
}
|
|
235
|
+
// Refusals are recorded too: a replayed effect must get the same answer.
|
|
236
|
+
await transaction.put(reservationKey, receipt);
|
|
237
|
+
return receipt;
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function decodeAuthoringQuotaReceiptV1(
|
|
242
|
+
input: unknown,
|
|
243
|
+
label = "authoring quota receipt",
|
|
244
|
+
): AuthoringQuotaReceiptV1 {
|
|
245
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
246
|
+
throw new Error(`${label} must be an object`);
|
|
247
|
+
}
|
|
248
|
+
const value = input as Record<string, unknown>;
|
|
249
|
+
if (value.schemaVersion !== 1) {
|
|
250
|
+
throw new Error(`${label}.schemaVersion is unsupported`);
|
|
251
|
+
}
|
|
252
|
+
const text = (name: string, maximum: number): string => {
|
|
253
|
+
const candidate = value[name];
|
|
254
|
+
if (
|
|
255
|
+
typeof candidate !== "string" ||
|
|
256
|
+
candidate.length === 0 ||
|
|
257
|
+
candidate.length > maximum
|
|
258
|
+
) {
|
|
259
|
+
throw new Error(`${label}.${name} is invalid`);
|
|
260
|
+
}
|
|
261
|
+
return candidate;
|
|
262
|
+
};
|
|
263
|
+
const integer = (name: string): number => {
|
|
264
|
+
const candidate = value[name];
|
|
265
|
+
if (!Number.isSafeInteger(candidate) || (candidate as number) < 0) {
|
|
266
|
+
throw new Error(`${label}.${name} is invalid`);
|
|
267
|
+
}
|
|
268
|
+
return candidate as number;
|
|
269
|
+
};
|
|
270
|
+
const effectId = text("effectId", 200);
|
|
271
|
+
const day = text("day", 10);
|
|
272
|
+
if (value.status === "reserved") {
|
|
273
|
+
return {
|
|
274
|
+
schemaVersion: 1,
|
|
275
|
+
status: "reserved",
|
|
276
|
+
effectId,
|
|
277
|
+
day,
|
|
278
|
+
used: integer("used"),
|
|
279
|
+
limit: integer("limit"),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
if (value.status === "refused") {
|
|
283
|
+
const limitName = value.limitName;
|
|
284
|
+
if (
|
|
285
|
+
limitName !== "source-bytes" &&
|
|
286
|
+
limitName !== "retained-generations" &&
|
|
287
|
+
limitName !== "authored-per-day"
|
|
288
|
+
) {
|
|
289
|
+
throw new Error(`${label}.limitName is invalid`);
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
schemaVersion: 1,
|
|
293
|
+
status: "refused",
|
|
294
|
+
effectId,
|
|
295
|
+
day,
|
|
296
|
+
limitName,
|
|
297
|
+
reason: text("reason", 1_024),
|
|
298
|
+
used: integer("used"),
|
|
299
|
+
limit: integer("limit"),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
throw new Error(`${label}.status is invalid`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** The narrow RPC the Bot Durable Object calls on the User Durable Object. */
|
|
306
|
+
export interface AuthoringQuotaBinding {
|
|
307
|
+
reserve(request: AuthoringQuotaRequestV1): Promise<AuthoringQuotaReceiptV1>;
|
|
308
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
artifactKey,
|
|
4
|
+
artifactR2KeyV1,
|
|
5
|
+
authorshipArtifactKey,
|
|
6
|
+
authorshipIntentKey,
|
|
7
|
+
classifyAuthoringEffectV1,
|
|
8
|
+
type AuthoringEffectOutcomeV1,
|
|
9
|
+
type AuthorshipIntentV1,
|
|
10
|
+
} from "./records.ts";
|
|
11
|
+
|
|
12
|
+
const INTENT: AuthorshipIntentV1 = {
|
|
13
|
+
schemaVersion: 1,
|
|
14
|
+
effectId: "author-0123456789abcdef",
|
|
15
|
+
botId: "bot-1",
|
|
16
|
+
sessionId: "user-1:bot-1",
|
|
17
|
+
runId: "run-1",
|
|
18
|
+
turnId: "run-1",
|
|
19
|
+
packageId: "weather-lookup",
|
|
20
|
+
version: "0.0.1",
|
|
21
|
+
sourceHash: "a".repeat(64),
|
|
22
|
+
sourceBytes: 64,
|
|
23
|
+
recordedAt: "2026-08-31T00:00:00.000Z",
|
|
24
|
+
status: "recorded",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const BUNDLED: AuthoringEffectOutcomeV1 = {
|
|
28
|
+
schemaVersion: 1,
|
|
29
|
+
status: "bundled",
|
|
30
|
+
effectId: INTENT.effectId,
|
|
31
|
+
contentHash: "b".repeat(64),
|
|
32
|
+
version: "0.0.1",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe("authoring effect recovery", () => {
|
|
36
|
+
test("no intent means the effect never started", () => {
|
|
37
|
+
expect(
|
|
38
|
+
classifyAuthoringEffectV1({ intent: undefined, outcome: undefined }),
|
|
39
|
+
).toEqual({ kind: "fresh" });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("an intent with no outcome is unknown, never re-bundled", () => {
|
|
43
|
+
const classification = classifyAuthoringEffectV1({
|
|
44
|
+
intent: INTENT,
|
|
45
|
+
outcome: undefined,
|
|
46
|
+
});
|
|
47
|
+
expect(classification.kind).toBe("unknown");
|
|
48
|
+
expect(
|
|
49
|
+
classification.kind === "unknown" ? classification.reason : "",
|
|
50
|
+
).toContain("will not be bundled again");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a recorded outcome settles the effect on its content address", () => {
|
|
54
|
+
expect(
|
|
55
|
+
classifyAuthoringEffectV1({ intent: INTENT, outcome: BUNDLED }),
|
|
56
|
+
).toEqual({ kind: "settled", outcome: BUNDLED });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a recorded bundler refusal replays as the same refusal", () => {
|
|
60
|
+
expect(
|
|
61
|
+
classifyAuthoringEffectV1({
|
|
62
|
+
intent: INTENT,
|
|
63
|
+
outcome: {
|
|
64
|
+
schemaVersion: 1,
|
|
65
|
+
status: "failed",
|
|
66
|
+
effectId: INTENT.effectId,
|
|
67
|
+
failureId: "authoring-failure-1",
|
|
68
|
+
reason: "the Package bundler rejected this source: bundle-failed",
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
).toMatchObject({ kind: "failed", failureId: "authoring-failure-1" });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("an outcome with no intent is unknown, not settled", () => {
|
|
75
|
+
expect(
|
|
76
|
+
classifyAuthoringEffectV1({ intent: undefined, outcome: BUNDLED }).kind,
|
|
77
|
+
).toBe("unknown");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("keys are the plan's records", () => {
|
|
81
|
+
expect(authorshipIntentKey("author-a")).toBe("authorship:intent:author-a");
|
|
82
|
+
expect(authorshipArtifactKey("author-a")).toBe(
|
|
83
|
+
"authorship:artifact:author-a",
|
|
84
|
+
);
|
|
85
|
+
expect(artifactKey("b".repeat(64))).toBe(`artifact:${"b".repeat(64)}`);
|
|
86
|
+
expect(artifactR2KeyV1("b".repeat(64))).toBe(
|
|
87
|
+
`packages/${"b".repeat(64)}.mjs`,
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
});
|
package/src/records.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// The durable records one authoring effect leaves in the Bot Durable Object,
|
|
2
|
+
// and the classifier that reads them after an eviction.
|
|
3
|
+
//
|
|
4
|
+
// Constitution, Durable effects: "A mutation ... records intent and an effect
|
|
5
|
+
// identifier ... before it runs, so recovery can read its outcome or classify
|
|
6
|
+
// it as unknown without repeating it." Bundling is that mutation. The intent is
|
|
7
|
+
// written first; the artifact record and the effect index are written together
|
|
8
|
+
// afterwards; recovery reads both and never re-bundles on a guess.
|
|
9
|
+
|
|
10
|
+
export const AUTHORSHIP_INTENT_PREFIX = "authorship:intent:";
|
|
11
|
+
export const AUTHORSHIP_ARTIFACT_PREFIX = "authorship:artifact:";
|
|
12
|
+
export const AUTHORSHIP_FAILURE_PREFIX = "authorship:failure:";
|
|
13
|
+
export const AUTHORSHIP_PACKAGE_PREFIX = "authorship:package:";
|
|
14
|
+
export const ARTIFACT_PREFIX = "artifact:";
|
|
15
|
+
|
|
16
|
+
export function authorshipIntentKey(effectId: string): string {
|
|
17
|
+
return `${AUTHORSHIP_INTENT_PREFIX}${effectId}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** effectId → contentHash. Written in the same transaction as `artifact:<hash>`. */
|
|
21
|
+
export function authorshipArtifactKey(effectId: string): string {
|
|
22
|
+
return `${AUTHORSHIP_ARTIFACT_PREFIX}${effectId}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function authorshipFailureKey(failureId: string): string {
|
|
26
|
+
return `${AUTHORSHIP_FAILURE_PREFIX}${failureId}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function authorshipPackageKey(packageId: string): string {
|
|
30
|
+
return `${AUTHORSHIP_PACKAGE_PREFIX}${packageId}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function artifactKey(contentHash: string): string {
|
|
34
|
+
return `${ARTIFACT_PREFIX}${contentHash}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AuthorshipIntentV1 {
|
|
38
|
+
schemaVersion: 1;
|
|
39
|
+
effectId: string;
|
|
40
|
+
botId: string;
|
|
41
|
+
sessionId: string;
|
|
42
|
+
runId: string;
|
|
43
|
+
turnId: string;
|
|
44
|
+
packageId: string;
|
|
45
|
+
version: string;
|
|
46
|
+
/** sha-256 of the source text. The source itself is never durable state. */
|
|
47
|
+
sourceHash: string;
|
|
48
|
+
sourceBytes: number;
|
|
49
|
+
recordedAt: string;
|
|
50
|
+
status: "recorded";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The immutable content record for one Bot-authored artifact. */
|
|
54
|
+
export interface AuthoredArtifactRecordV1 {
|
|
55
|
+
schemaVersion: 1;
|
|
56
|
+
contentHash: string;
|
|
57
|
+
size: number;
|
|
58
|
+
mediaType: "application/javascript";
|
|
59
|
+
bundlerVersion: string;
|
|
60
|
+
effectId: string;
|
|
61
|
+
r2Key: string;
|
|
62
|
+
provenance: {
|
|
63
|
+
kind: "bot";
|
|
64
|
+
packageId: string;
|
|
65
|
+
version: string;
|
|
66
|
+
botId: string;
|
|
67
|
+
sessionId: string;
|
|
68
|
+
turnId: string;
|
|
69
|
+
runId: string;
|
|
70
|
+
authoredAt: string;
|
|
71
|
+
};
|
|
72
|
+
createdAt: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The latest recorded version of one authored Package identity. */
|
|
76
|
+
export interface AuthoredPackageRecordV1 {
|
|
77
|
+
schemaVersion: 1;
|
|
78
|
+
packageId: string;
|
|
79
|
+
ordinal: number;
|
|
80
|
+
version: string;
|
|
81
|
+
contentHash: string;
|
|
82
|
+
updatedAt: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A visible durable failure. Quota refusals and unknown effects both land here. */
|
|
86
|
+
export interface AuthoringFailureRecordV1 {
|
|
87
|
+
schemaVersion: 1;
|
|
88
|
+
failureId: string;
|
|
89
|
+
effectId: string;
|
|
90
|
+
botId: string;
|
|
91
|
+
packageId: string;
|
|
92
|
+
runId: string;
|
|
93
|
+
phase: "quota" | "bundle" | "recovery" | "compose";
|
|
94
|
+
reason: string;
|
|
95
|
+
diagnostics: string[];
|
|
96
|
+
recordedAt: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function artifactR2KeyV1(contentHash: string): string {
|
|
100
|
+
return `packages/${contentHash}.mjs`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The durable outcome of one authoring effect, written under
|
|
105
|
+
* `authorship:artifact:<effectId>` in the same put as `artifact:<contentHash>`.
|
|
106
|
+
* `generationId` is filled in once the generation has been recorded, so a
|
|
107
|
+
* replay resolves to the same generation rather than proposing another.
|
|
108
|
+
*/
|
|
109
|
+
export type AuthoringEffectOutcomeV1 =
|
|
110
|
+
| {
|
|
111
|
+
schemaVersion: 1;
|
|
112
|
+
status: "bundled";
|
|
113
|
+
effectId: string;
|
|
114
|
+
contentHash: string;
|
|
115
|
+
version: string;
|
|
116
|
+
generationId?: string;
|
|
117
|
+
}
|
|
118
|
+
| {
|
|
119
|
+
schemaVersion: 1;
|
|
120
|
+
status: "failed";
|
|
121
|
+
effectId: string;
|
|
122
|
+
failureId: string;
|
|
123
|
+
reason: string;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export type AuthoringEffectClassificationV1 =
|
|
127
|
+
/** No intent recorded: the effect has not started and may run. */
|
|
128
|
+
| { kind: "fresh" }
|
|
129
|
+
/** The artifact exists and is addressed by hash: reuse it, never re-bundle. */
|
|
130
|
+
| {
|
|
131
|
+
kind: "settled";
|
|
132
|
+
outcome: Extract<AuthoringEffectOutcomeV1, { status: "bundled" }>;
|
|
133
|
+
}
|
|
134
|
+
/** The bundler answered and refused. The same answer is replayed. */
|
|
135
|
+
| { kind: "failed"; failureId: string; reason: string }
|
|
136
|
+
/**
|
|
137
|
+
* Intent exists with no recorded outcome. The bundler may or may not have
|
|
138
|
+
* run; nothing durable says which. The effect is unknown and is reported,
|
|
139
|
+
* not retried.
|
|
140
|
+
*/
|
|
141
|
+
| { kind: "unknown"; reason: string };
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Reads one authoring effect's durable trail. Pure, so the eviction window it
|
|
145
|
+
* exists for is testable without a Durable Object.
|
|
146
|
+
*/
|
|
147
|
+
export function classifyAuthoringEffectV1(input: {
|
|
148
|
+
intent: AuthorshipIntentV1 | undefined;
|
|
149
|
+
outcome: AuthoringEffectOutcomeV1 | undefined;
|
|
150
|
+
}): AuthoringEffectClassificationV1 {
|
|
151
|
+
if (input.outcome) {
|
|
152
|
+
if (!input.intent) {
|
|
153
|
+
return {
|
|
154
|
+
kind: "unknown",
|
|
155
|
+
reason:
|
|
156
|
+
"an authoring outcome is recorded for an effect with no recorded intent",
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return input.outcome.status === "bundled"
|
|
160
|
+
? { kind: "settled", outcome: input.outcome }
|
|
161
|
+
: {
|
|
162
|
+
kind: "failed",
|
|
163
|
+
failureId: input.outcome.failureId,
|
|
164
|
+
reason: input.outcome.reason,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (!input.intent) return { kind: "fresh" };
|
|
168
|
+
return {
|
|
169
|
+
kind: "unknown",
|
|
170
|
+
reason: `authoring effect "${input.intent.effectId}" recorded its intent but has no durable outcome; it will not be bundled again`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
authoredManifestV1,
|
|
4
|
+
authoredVersionV1,
|
|
5
|
+
authoringEffectIdV1,
|
|
6
|
+
decodeAuthorPackageInputV1,
|
|
7
|
+
sha256HexV1,
|
|
8
|
+
} from "./shared.ts";
|
|
9
|
+
|
|
10
|
+
const VALID = {
|
|
11
|
+
packageId: "weather-lookup",
|
|
12
|
+
displayName: "Weather lookup",
|
|
13
|
+
tool: {
|
|
14
|
+
name: "weather_lookup",
|
|
15
|
+
description: "Looks up the weather",
|
|
16
|
+
inputSchema: { type: "object", properties: { city: { type: "string" } } },
|
|
17
|
+
},
|
|
18
|
+
source: "export const tools = [];\nexport async function execute() {}\n",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe("decodeAuthorPackageInputV1", () => {
|
|
22
|
+
test("accepts the exact v1 shape and the optional model Contribution", () => {
|
|
23
|
+
expect(decodeAuthorPackageInputV1(VALID)).toEqual(VALID);
|
|
24
|
+
expect(
|
|
25
|
+
decodeAuthorPackageInputV1({
|
|
26
|
+
...VALID,
|
|
27
|
+
model: { providerId: "ollama-cloud", modelId: "qwen3-coder:480b" },
|
|
28
|
+
}).model,
|
|
29
|
+
).toEqual({ providerId: "ollama-cloud", modelId: "qwen3-coder:480b" });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test.each([
|
|
33
|
+
["a non-object", 7],
|
|
34
|
+
["an unknown field", { ...VALID, activate: true }],
|
|
35
|
+
["a missing field", { ...VALID, source: undefined }],
|
|
36
|
+
["an upper-case package id", { ...VALID, packageId: "Weather" }],
|
|
37
|
+
["a one-character package id", { ...VALID, packageId: "a" }],
|
|
38
|
+
[
|
|
39
|
+
"a tool name with a dash",
|
|
40
|
+
{ ...VALID, tool: { ...VALID.tool, name: "a-b" } },
|
|
41
|
+
],
|
|
42
|
+
["an empty source", { ...VALID, source: "" }],
|
|
43
|
+
[
|
|
44
|
+
"a non-object input schema",
|
|
45
|
+
{ ...VALID, tool: { ...VALID.tool, inputSchema: "object" } },
|
|
46
|
+
],
|
|
47
|
+
[
|
|
48
|
+
"a partial model Contribution",
|
|
49
|
+
{ ...VALID, model: { providerId: "ollama-cloud" } },
|
|
50
|
+
],
|
|
51
|
+
])("rejects %s", (_label, input) => {
|
|
52
|
+
expect(() => decodeAuthorPackageInputV1(input)).toThrow();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("rejects source beyond the 256 KB per-Package quota", () => {
|
|
56
|
+
expect(() =>
|
|
57
|
+
decodeAuthorPackageInputV1({
|
|
58
|
+
...VALID,
|
|
59
|
+
source: "a".repeat(256 * 1024 + 1),
|
|
60
|
+
}),
|
|
61
|
+
).toThrow();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("authoring identity", () => {
|
|
66
|
+
test("the effect id is deterministic in the run and the exact source", async () => {
|
|
67
|
+
const sourceHash = await sha256HexV1(VALID.source);
|
|
68
|
+
const first = await authoringEffectIdV1({
|
|
69
|
+
runId: "run-1",
|
|
70
|
+
packageId: VALID.packageId,
|
|
71
|
+
sourceHash,
|
|
72
|
+
});
|
|
73
|
+
const second = await authoringEffectIdV1({
|
|
74
|
+
runId: "run-1",
|
|
75
|
+
packageId: VALID.packageId,
|
|
76
|
+
sourceHash,
|
|
77
|
+
});
|
|
78
|
+
const otherRun = await authoringEffectIdV1({
|
|
79
|
+
runId: "run-2",
|
|
80
|
+
packageId: VALID.packageId,
|
|
81
|
+
sourceHash,
|
|
82
|
+
});
|
|
83
|
+
const otherSource = await authoringEffectIdV1({
|
|
84
|
+
runId: "run-1",
|
|
85
|
+
packageId: VALID.packageId,
|
|
86
|
+
sourceHash: await sha256HexV1(`${VALID.source}//`),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(first).toBe(second);
|
|
90
|
+
expect(first).not.toBe(otherRun);
|
|
91
|
+
expect(first).not.toBe(otherSource);
|
|
92
|
+
expect(first.length).toBeLessThanOrEqual(200);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("versions are appended, never overwritten", () => {
|
|
96
|
+
expect(authoredVersionV1(1)).toBe("0.0.1");
|
|
97
|
+
expect(authoredVersionV1(2)).toBe("0.0.2");
|
|
98
|
+
expect(() => authoredVersionV1(0)).toThrow();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("the synthesized manifest declares only the isolate host", () => {
|
|
102
|
+
const manifest = authoredManifestV1({
|
|
103
|
+
packageId: VALID.packageId,
|
|
104
|
+
displayName: VALID.displayName,
|
|
105
|
+
version: "0.0.1",
|
|
106
|
+
tool: VALID.tool,
|
|
107
|
+
model: { providerId: "ollama-cloud", modelId: "qwen3-coder:480b" },
|
|
108
|
+
});
|
|
109
|
+
const contributions = manifest.contributions as Record<
|
|
110
|
+
string,
|
|
111
|
+
{ host?: string; binding?: string }
|
|
112
|
+
>;
|
|
113
|
+
expect(Object.keys(contributions).toSorted()).toEqual(["model", "runtime"]);
|
|
114
|
+
expect(contributions.runtime?.host).toBe("bot-isolate");
|
|
115
|
+
expect(contributions.model?.host).toBe("bot-isolate");
|
|
116
|
+
// A Bot-authored model adapter is a translation layer over a kernel
|
|
117
|
+
// binding, never a network client.
|
|
118
|
+
expect(contributions.model?.binding).toBe("capabilities.invokeModel");
|
|
119
|
+
expect(manifest.permissions).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("the manifest hash moves when the declared model Contribution moves", () => {
|
|
123
|
+
const base = authoredManifestV1({
|
|
124
|
+
packageId: VALID.packageId,
|
|
125
|
+
displayName: VALID.displayName,
|
|
126
|
+
version: "0.0.1",
|
|
127
|
+
tool: VALID.tool,
|
|
128
|
+
});
|
|
129
|
+
const withModel = authoredManifestV1({
|
|
130
|
+
packageId: VALID.packageId,
|
|
131
|
+
displayName: VALID.displayName,
|
|
132
|
+
version: "0.0.1",
|
|
133
|
+
tool: VALID.tool,
|
|
134
|
+
model: { providerId: "ollama-cloud", modelId: "qwen3-coder:480b" },
|
|
135
|
+
});
|
|
136
|
+
expect(JSON.stringify(base)).not.toBe(JSON.stringify(withModel));
|
|
137
|
+
});
|
|
138
|
+
});
|