@frockbot/plugin-shell 0.1.4 → 0.2.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.
@@ -15,10 +15,18 @@
15
15
  // re-authored Package appends a version and supersedes its predecessor inside
16
16
  // a new generation.
17
17
  import {
18
+ BOT_ISOLATE_CONTEXT_DTS_V1,
18
19
  decodePackageBundleResultV1,
20
+ PACKAGE_UI_ARTIFACT_VERSION,
21
+ PACKAGE_IFRAME_BRIDGE_DTS_V1,
22
+ PACKAGE_IFRAME_HELPER_JS_V1,
19
23
  type PackageBundleRequestV1,
20
24
  type PackageBundlerBinding,
21
25
  } from "@frockbot/kernel-contracts";
26
+ import type {
27
+ CompositionFailureV1,
28
+ CompositionQuarantineV1,
29
+ } from "@frockbot/kernel-composition/activation";
22
30
  import { canonicalJson, sha256 } from "@frockbot/kernel-composition/compiler";
23
31
  import {
24
32
  compositionArtifactSetHashV1,
@@ -27,6 +35,7 @@ import {
27
35
  type CompositionGenerationV1,
28
36
  type CompositionMemberV1,
29
37
  } from "@frockbot/kernel-composition/generation";
38
+ import { decodeFrockBotManifest } from "@frockbot/kernel-composition";
30
39
  import {
31
40
  artifactKey,
32
41
  artifactR2KeyV1,
@@ -34,13 +43,19 @@ import {
34
43
  authoredSpecifierV1,
35
44
  authoredVersionV1,
36
45
  authoringEffectIdV1,
46
+ packageUndoEffectIdV1,
37
47
  authoringQuotaDayV1,
38
48
  authorshipArtifactKey,
39
49
  authorshipFailureKey,
40
50
  authorshipIntentKey,
51
+ authorshipLatestFailureKey,
52
+ authorshipManifestKey,
41
53
  authorshipPackageKey,
54
+ authorshipUndoIntentKey,
55
+ authorshipUndoOutcomeKey,
42
56
  classifyAuthoringEffectV1,
43
57
  type AuthoredArtifactRecordV1,
58
+ type AuthoredManifestRecordV1,
44
59
  type AuthoredPackageRecordV1,
45
60
  type AuthoringEffectOutcomeV1,
46
61
  type AuthoringFailureRecordV1,
@@ -49,28 +64,55 @@ import {
49
64
  type AuthorPackageRequestV1,
50
65
  type AuthorshipIntentV1,
51
66
  type PackageAuthoringHost,
67
+ type PackageInspectFailureV1,
68
+ type PackageInspectSelfOutcomeV1,
69
+ type PackageUndoIntentV1,
70
+ type PackageUndoOutcomeV1,
71
+ type PackageUndoRecordV1,
72
+ type PackageUndoRequestV1,
73
+ sourceR2KeyV1,
52
74
  } from "@frockbot/plugin-authoring";
53
75
 
54
76
  /** The narrow Bot Durable Object storage surface authoring needs. */
55
77
  export interface AuthoringStorage {
56
78
  get<T>(key: string): Promise<T | undefined>;
57
79
  put(entries: Record<string, unknown>): Promise<void>;
80
+ list?<T>(options: { prefix: string }): Promise<Map<string, T>>;
58
81
  }
59
82
 
60
83
  /** The Composition surface authoring needs; `DurableCompositionStore` satisfies it. */
61
84
  export interface AuthoringCompositionStore {
62
85
  current(): Promise<CompositionGenerationV1>;
86
+ lastKnownGood(): Promise<CompositionGenerationV1>;
63
87
  read(generationId: string): Promise<CompositionGenerationV1 | undefined>;
64
88
  propose(
65
89
  generation: CompositionGenerationV1,
66
90
  options?: { pin?: boolean },
67
91
  ): Promise<void>;
68
92
  retainedCount(): Promise<number>;
93
+ revert(
94
+ toGenerationId: string,
95
+ origin: {
96
+ kind: "revert";
97
+ revertsTo: string;
98
+ botId: string;
99
+ runId: string;
100
+ turnId: string;
101
+ },
102
+ options?: { createdAt?: string },
103
+ ): Promise<CompositionGenerationV1>;
104
+ list(query: {
105
+ limit: number;
106
+ cursor?: string;
107
+ }): Promise<{ generations: CompositionGenerationV1[]; cursor?: string }>;
69
108
  }
70
109
 
71
110
  /** Immutable content, written once and addressed by hash. */
72
111
  export interface AuthoringArtifactStore {
73
112
  putPackageArtifact(contentHash: string, module: string): Promise<void>;
113
+ putPackageUiArtifact?(contentHash: string, html: string): Promise<void>;
114
+ putPackageSource(sourceHash: string, source: string): Promise<void>;
115
+ loadPackageSource(sourceHash: string): Promise<string | undefined>;
74
116
  headPackageArtifact(
75
117
  contentHash: string,
76
118
  ): Promise<{ contentHash: string; size: number } | undefined>;
@@ -89,6 +131,16 @@ export interface PackageAuthoringHostOptions {
89
131
  runId: string;
90
132
  turnId: string;
91
133
  compatibilityDate: string;
134
+ /** The mounted root's exact catalog, read only when `author` is called. */
135
+ currentToolNames?(): readonly string[];
136
+ /** The generation actually mounted after fail-closed fallback. */
137
+ mountedGeneration?(): CompositionGenerationV1 | undefined;
138
+ activationFailures?: {
139
+ list(generationId: string): Promise<CompositionFailureV1[]>;
140
+ quarantine(
141
+ generationId: string,
142
+ ): Promise<CompositionQuarantineV1 | undefined>;
143
+ };
92
144
  now?(): Date;
93
145
  newId?(): string;
94
146
  }
@@ -127,7 +179,10 @@ export function createPackageAuthoringHost(
127
179
  diagnostics: input.diagnostics ?? [],
128
180
  recordedAt: now().toISOString(),
129
181
  };
130
- await options.storage.put({ [authorshipFailureKey(failureId)]: record });
182
+ await options.storage.put({
183
+ [authorshipFailureKey(failureId)]: record,
184
+ [authorshipLatestFailureKey(input.packageId)]: record,
185
+ });
131
186
  return record;
132
187
  }
133
188
 
@@ -141,6 +196,27 @@ export function createPackageAuthoringHost(
141
196
  };
142
197
  }
143
198
 
199
+ async function undoRefused(
200
+ record: AuthoringFailureRecordV1,
201
+ ): Promise<Extract<PackageUndoOutcomeV1, { status: "refused" }>> {
202
+ const outcome = {
203
+ status: "refused",
204
+ reason: record.reason,
205
+ failureId: record.failureId,
206
+ } as const;
207
+ await options.storage.put({
208
+ [authorshipUndoOutcomeKey(record.effectId)]: {
209
+ schemaVersion: 1,
210
+ effectId: record.effectId,
211
+ failureId: record.failureId,
212
+ reason: record.reason,
213
+ recordedAt: record.recordedAt,
214
+ status: "refused",
215
+ } satisfies PackageUndoRecordV1,
216
+ });
217
+ return outcome;
218
+ }
219
+
144
220
  /**
145
221
  * The constitutional shadowing rule: a Bot authors only over its own
146
222
  * Packages. A member of the current Composition whose provenance is
@@ -151,17 +227,85 @@ export function createPackageAuthoringHost(
151
227
  async function shadowedMember(
152
228
  packageId: string,
153
229
  ): Promise<CompositionMemberV1 | undefined> {
154
- const parent = await options.composition.current();
155
- return parent.members.find(
230
+ const current = await options.composition.current();
231
+ const lastKnownGood = await options.composition.lastKnownGood();
232
+ return [current, lastKnownGood]
233
+ .flatMap((generation) => generation.members)
234
+ .find(
235
+ (member) =>
236
+ member.packageId === packageId && member.provenance.kind !== "bot",
237
+ );
238
+ }
239
+
240
+ async function storedManifest(
241
+ member: CompositionMemberV1,
242
+ ): Promise<AuthoredManifestRecordV1 | undefined> {
243
+ if (member.provenance.kind !== "bot") return undefined;
244
+ const stored = await options.storage.get<AuthoredManifestRecordV1>(
245
+ authorshipManifestKey(member.manifestHash),
246
+ );
247
+ if (
248
+ !stored ||
249
+ stored.manifestHash !== member.manifestHash ||
250
+ stored.packageId !== member.packageId ||
251
+ stored.version !== member.version
252
+ ) {
253
+ return undefined;
254
+ }
255
+ return stored;
256
+ }
257
+
258
+ async function compositionHistory(): Promise<CompositionGenerationV1[]> {
259
+ const generations: CompositionGenerationV1[] = [];
260
+ let cursor: string | undefined;
261
+ for (let pageNumber = 0; pageNumber < 100; pageNumber += 1) {
262
+ const page = await options.composition.list({
263
+ limit: 100,
264
+ ...(cursor ? { cursor } : {}),
265
+ });
266
+ generations.push(...page.generations);
267
+ if (!page.cursor) return generations;
268
+ if (page.cursor === cursor) {
269
+ throw new Error("Composition history returned a repeated cursor");
270
+ }
271
+ cursor = page.cursor;
272
+ }
273
+ throw new Error("Composition history exceeds its durable bound");
274
+ }
275
+
276
+ /**
277
+ * A declaration that would collide is refused before quota reservation.
278
+ * Re-authoring may keep or rename tools owned by that same Package, so its
279
+ * currently stored declarations are subtracted from the mounted catalog;
280
+ * every first-party tool and every other authored Package remains reserved.
281
+ */
282
+ async function collidingToolName(
283
+ packageId: string,
284
+ declaredNames: readonly string[],
285
+ ): Promise<string | undefined> {
286
+ const registered = new Set(options.currentToolNames?.() ?? []);
287
+ const mounted =
288
+ options.mountedGeneration?.() ??
289
+ (await options.composition.lastKnownGood());
290
+ const own = mounted.members.find(
156
291
  (member) =>
157
- member.packageId === packageId && member.provenance.kind !== "bot",
292
+ member.packageId === packageId && member.provenance.kind === "bot",
158
293
  );
294
+ if (own) {
295
+ const recorded = await storedManifest(own);
296
+ const manifest = recorded
297
+ ? decodeFrockBotManifest(recorded.manifest)
298
+ : undefined;
299
+ for (const tool of manifest?.tools ?? []) registered.delete(tool.name);
300
+ }
301
+ return declaredNames.find((name) => registered.has(name));
159
302
  }
160
303
 
161
304
  /**
162
- * The member set of the next generation: every member of the pinned-forward
163
- * current generation except the one this Package supersedes, plus the new
164
- * one. A recorded generation is never edited.
305
+ * New authoring always branches from last-known-good, replacing only the
306
+ * Package being authored. This deliberately drops every pending, failed, or
307
+ * quarantined proposal: a broken member can be repaired by re-authoring its
308
+ * packageId, but it can never poison a later, unrelated generation.
165
309
  */
166
310
  async function nextGeneration(input: {
167
311
  member: CompositionMemberV1;
@@ -171,7 +315,7 @@ export function createPackageAuthoringHost(
171
315
  generation: CompositionGenerationV1;
172
316
  supersededVersion?: string;
173
317
  }> {
174
- const parent = await options.composition.current();
318
+ const parent = await options.composition.lastKnownGood();
175
319
  const superseded = parent.members.find(
176
320
  (member) => member.packageId === input.member.packageId,
177
321
  );
@@ -205,10 +349,11 @@ export function createPackageAuthoringHost(
205
349
 
206
350
  async function compose(input: {
207
351
  request: AuthorPackageRequestV1;
352
+ intent: AuthorshipIntentV1;
208
353
  outcome: Extract<AuthoringEffectOutcomeV1, { status: "bundled" }>;
209
354
  artifact: AuthoredArtifactRecordV1;
210
355
  }): Promise<AuthorPackageOutcomeV1> {
211
- const { request, outcome, artifact } = input;
356
+ const { request, intent, outcome, artifact } = input;
212
357
  if (outcome.generationId) {
213
358
  const recorded = await options.composition.read(outcome.generationId);
214
359
  if (recorded) {
@@ -221,18 +366,39 @@ export function createPackageAuthoringHost(
221
366
  };
222
367
  }
223
368
  }
224
- const manifest = authoredManifestV1({
225
- packageId: request.input.packageId,
226
- displayName: request.input.displayName,
227
- version: outcome.version,
228
- tool: request.input.tool,
229
- ...(request.input.model ? { model: request.input.model } : {}),
230
- });
369
+ const manifestRecord = await options.storage.get<AuthoredManifestRecordV1>(
370
+ authorshipManifestKey(intent.manifestHash),
371
+ );
372
+ if (!manifestRecord) {
373
+ return refused(
374
+ await recordFailure({
375
+ effectId: request.effectId,
376
+ packageId: request.input.packageId,
377
+ phase: "recovery",
378
+ reason: `authoring effect "${request.effectId}" has no stored manifest "${intent.manifestHash}"`,
379
+ }),
380
+ );
381
+ }
382
+ const manifest = decodeFrockBotManifest(manifestRecord.manifest);
383
+ if (
384
+ manifestRecord.manifestHash !== intent.manifestHash ||
385
+ manifest.id !== request.input.packageId ||
386
+ manifest.version !== outcome.version
387
+ ) {
388
+ return refused(
389
+ await recordFailure({
390
+ effectId: request.effectId,
391
+ packageId: request.input.packageId,
392
+ phase: "recovery",
393
+ reason: `stored manifest "${intent.manifestHash}" does not match this authoring effect`,
394
+ }),
395
+ );
396
+ }
231
397
  const member: CompositionMemberV1 = {
232
398
  packageId: request.input.packageId,
233
399
  specifier: authoredSpecifierV1(request.input.packageId),
234
400
  version: outcome.version,
235
- manifestHash: await sha256(canonicalJson(manifest)),
401
+ manifestHash: intent.manifestHash,
236
402
  provenance: artifact.provenance,
237
403
  artifact: {
238
404
  contentHash: artifact.contentHash,
@@ -271,6 +437,16 @@ export function createPackageAuthoringHost(
271
437
  runId: options.runId,
272
438
  packageId: input.packageId,
273
439
  sourceHash: input.sourceHash,
440
+ ...(input.uiHtmlHash === undefined
441
+ ? {}
442
+ : { uiHtmlHash: input.uiHtmlHash }),
443
+ ...(input.hooks === undefined ? {} : { hooks: input.hooks }),
444
+ }),
445
+
446
+ undoEffectIdFor: (input) =>
447
+ packageUndoEffectIdV1({
448
+ runId: options.runId,
449
+ ...(input.generationId ? { generationId: input.generationId } : {}),
274
450
  }),
275
451
 
276
452
  async author(
@@ -292,6 +468,20 @@ export function createPackageAuthoringHost(
292
468
  }),
293
469
  );
294
470
  }
471
+ const collision = await collidingToolName(
472
+ packageId,
473
+ request.input.tools.map((tool) => tool.name),
474
+ );
475
+ if (collision) {
476
+ return refused(
477
+ await recordFailure({
478
+ effectId,
479
+ packageId,
480
+ phase: "compose",
481
+ reason: `Tool "${collision}" is already registered in this Bot's current Composition; choose a different tool name`,
482
+ }),
483
+ );
484
+ }
295
485
 
296
486
  const intent = await options.storage.get<AuthorshipIntentV1>(
297
487
  authorshipIntentKey(effectId),
@@ -325,6 +515,9 @@ export function createPackageAuthoringHost(
325
515
  };
326
516
  }
327
517
  if (classification.kind === "settled") {
518
+ if (!intent) {
519
+ throw new Error("a settled authoring effect has no durable intent");
520
+ }
328
521
  const artifact = await options.storage.get<AuthoredArtifactRecordV1>(
329
522
  artifactKey(classification.outcome.contentHash),
330
523
  );
@@ -340,19 +533,24 @@ export function createPackageAuthoringHost(
340
533
  }
341
534
  return await compose({
342
535
  request,
536
+ intent,
343
537
  outcome: classification.outcome,
344
538
  artifact,
345
539
  });
346
540
  }
347
541
 
348
- if (!options.bundler || !options.artifacts) {
542
+ if (
543
+ !options.bundler ||
544
+ !options.artifacts ||
545
+ (request.input.ui && !options.artifacts.putPackageUiArtifact)
546
+ ) {
349
547
  return refused(
350
548
  await recordFailure({
351
549
  effectId,
352
550
  packageId,
353
551
  phase: "compose",
354
552
  reason:
355
- "this host cannot author Packages: it has no Package bundler or artifact store",
553
+ "this host cannot author Packages: it has no required Package bundler or artifact store",
356
554
  }),
357
555
  );
358
556
  }
@@ -362,6 +560,33 @@ export function createPackageAuthoringHost(
362
560
  );
363
561
  const ordinal = (previous?.ordinal ?? 0) + 1;
364
562
  const version = authoredVersionV1(ordinal);
563
+ const uiArtifact = request.input.ui
564
+ ? {
565
+ contentHash: await sha256(request.input.ui.html),
566
+ size: new TextEncoder().encode(request.input.ui.html).byteLength,
567
+ mediaType: "text/html" as const,
568
+ bundlerVersion: PACKAGE_UI_ARTIFACT_VERSION,
569
+ }
570
+ : undefined;
571
+ const rawManifest = authoredManifestV1({
572
+ packageId,
573
+ displayName: request.input.displayName,
574
+ version,
575
+ tools: request.input.tools,
576
+ hooks: request.input.hooks,
577
+ ...(request.input.ui && uiArtifact
578
+ ? {
579
+ ui: {
580
+ artifact: uiArtifact,
581
+ mounts: request.input.ui.mounts,
582
+ },
583
+ }
584
+ : {}),
585
+ });
586
+ // The generated document crosses the same strict seam as every other
587
+ // manifest before it becomes durable or participates in a generation.
588
+ decodeFrockBotManifest(rawManifest);
589
+ const manifestHash = await sha256(canonicalJson(rawManifest));
365
590
  const sourceBytes = new TextEncoder().encode(
366
591
  request.input.source,
367
592
  ).byteLength;
@@ -404,12 +629,21 @@ export function createPackageAuthoringHost(
404
629
  packageId,
405
630
  version,
406
631
  sourceHash: request.sourceHash,
632
+ manifestHash,
407
633
  sourceBytes,
408
634
  recordedAt,
409
635
  status: "recorded",
410
636
  };
411
637
  await options.storage.put({
412
638
  [authorshipIntentKey(effectId)]: recordedIntent,
639
+ [authorshipManifestKey(manifestHash)]: {
640
+ schemaVersion: 1,
641
+ manifestHash,
642
+ packageId,
643
+ version,
644
+ manifest: rawManifest,
645
+ createdAt: recordedAt,
646
+ } satisfies AuthoredManifestRecordV1,
413
647
  });
414
648
 
415
649
  const bundleRequest: PackageBundleRequestV1 = {
@@ -419,6 +653,9 @@ export function createPackageAuthoringHost(
419
653
  compatibilityDate: options.compatibilityDate,
420
654
  entry: "package.ts",
421
655
  sources: [{ path: "package.ts", text: request.input.source }],
656
+ ...(request.input.ui
657
+ ? { ui: { path: "ui.html" as const, html: request.input.ui.html } }
658
+ : {}),
422
659
  };
423
660
  let bundled;
424
661
  try {
@@ -459,12 +696,41 @@ export function createPackageAuthoringHost(
459
696
  }
460
697
 
461
698
  const { artifact } = bundled;
699
+ if (
700
+ uiArtifact &&
701
+ (!bundled.uiArtifact ||
702
+ bundled.uiHtml !== request.input.ui?.html ||
703
+ bundled.uiArtifact.contentHash !== uiArtifact.contentHash ||
704
+ bundled.uiArtifact.size !== uiArtifact.size ||
705
+ bundled.uiArtifact.mediaType !== uiArtifact.mediaType ||
706
+ bundled.uiArtifact.bundlerVersion !== uiArtifact.bundlerVersion)
707
+ ) {
708
+ return refused(
709
+ await recordFailure({
710
+ effectId,
711
+ packageId,
712
+ phase: "bundle",
713
+ reason:
714
+ "the Package bundler returned UI bytes that do not match the recorded manifest",
715
+ }),
716
+ );
717
+ }
462
718
  // Content-addressed and immutable: writing the same hash twice is a
463
719
  // no-op, so the object write is safe to repeat and the record is not.
464
720
  await options.artifacts.putPackageArtifact(
465
721
  artifact.contentHash,
466
722
  bundled.module,
467
723
  );
724
+ if (bundled.uiArtifact && bundled.uiHtml) {
725
+ await options.artifacts.putPackageUiArtifact!(
726
+ bundled.uiArtifact.contentHash,
727
+ bundled.uiHtml,
728
+ );
729
+ }
730
+ await options.artifacts.putPackageSource(
731
+ request.sourceHash,
732
+ request.input.source,
733
+ );
468
734
  const artifactRecord: AuthoredArtifactRecordV1 = {
469
735
  schemaVersion: 1,
470
736
  contentHash: artifact.contentHash,
@@ -473,6 +739,9 @@ export function createPackageAuthoringHost(
473
739
  bundlerVersion: artifact.bundlerVersion,
474
740
  effectId,
475
741
  r2Key: artifactR2KeyV1(artifact.contentHash),
742
+ sourceHash: request.sourceHash,
743
+ sourceR2Key: sourceR2KeyV1(request.sourceHash),
744
+ manifestHash,
476
745
  provenance: {
477
746
  kind: "bot",
478
747
  packageId,
@@ -505,7 +774,261 @@ export function createPackageAuthoringHost(
505
774
  updatedAt: recordedAt,
506
775
  } satisfies AuthoredPackageRecordV1,
507
776
  });
508
- return await compose({ request, outcome, artifact: artifactRecord });
777
+ return await compose({
778
+ request,
779
+ intent: recordedIntent,
780
+ outcome,
781
+ artifact: artifactRecord,
782
+ });
783
+ },
784
+
785
+ async undo(request: PackageUndoRequestV1): Promise<PackageUndoOutcomeV1> {
786
+ const replay = await options.storage.get<PackageUndoRecordV1>(
787
+ authorshipUndoOutcomeKey(request.effectId),
788
+ );
789
+ if (replay) {
790
+ return replay.status === "recorded"
791
+ ? {
792
+ status: "recorded",
793
+ effectId: replay.effectId,
794
+ generationId: replay.generationId,
795
+ targetGenerationId: replay.targetGenerationId,
796
+ }
797
+ : {
798
+ status: "refused",
799
+ reason: replay.reason,
800
+ failureId: replay.failureId,
801
+ };
802
+ }
803
+
804
+ const existingIntent = await options.storage.get<PackageUndoIntentV1>(
805
+ authorshipUndoIntentKey(request.effectId),
806
+ );
807
+ const history = (await options.composition.list({ limit: 100 }))
808
+ .generations;
809
+ const currentGood = await options.composition.lastKnownGood();
810
+ const current = await options.composition.current();
811
+ let target: CompositionGenerationV1 | undefined;
812
+ if (existingIntent) {
813
+ target = await options.composition.read(
814
+ existingIntent.targetGenerationId,
815
+ );
816
+ } else if (request.input.generationId) {
817
+ target = await options.composition.read(request.input.generationId);
818
+ } else {
819
+ const latestAuthored = history.find(
820
+ (generation) => generation.origin.kind === "bot-authored",
821
+ );
822
+ target = latestAuthored?.parentGenerationId
823
+ ? await options.composition.read(latestAuthored.parentGenerationId)
824
+ : undefined;
825
+ }
826
+ if (!target) {
827
+ return undoRefused(
828
+ await recordFailure({
829
+ effectId: request.effectId,
830
+ packageId: "composition",
831
+ phase: "compose",
832
+ reason: request.input.generationId
833
+ ? `Composition generation "${request.input.generationId}" is unavailable`
834
+ : "There is no earlier Bot-authored Package setup change to undo",
835
+ }),
836
+ );
837
+ }
838
+ if (target.status !== "active" && target.status !== "superseded") {
839
+ return undoRefused(
840
+ await recordFailure({
841
+ effectId: request.effectId,
842
+ packageId: "composition",
843
+ phase: "compose",
844
+ reason: `Composition generation "${target.generationId}" was never successfully mounted and cannot be an undo target`,
845
+ }),
846
+ );
847
+ }
848
+ if (target.generationId === current.generationId) {
849
+ return undoRefused(
850
+ await recordFailure({
851
+ effectId: request.effectId,
852
+ packageId: "composition",
853
+ phase: "compose",
854
+ reason: `Package setup already matches Composition generation "${target.generationId}"`,
855
+ }),
856
+ );
857
+ }
858
+
859
+ // A Bot-origin undo may change only Bot-provenance members. First-party
860
+ // and User members must be byte-for-byte identical to the current good
861
+ // setup, so this path cannot become an alternate authority grant/revoke.
862
+ const nonBot = (generation: CompositionGenerationV1) =>
863
+ generation.members
864
+ .filter((member) => member.provenance.kind !== "bot")
865
+ .sort((left, right) => left.packageId.localeCompare(right.packageId));
866
+ if (
867
+ canonicalJson(nonBot(target)) !== canonicalJson(nonBot(currentGood))
868
+ ) {
869
+ return undoRefused(
870
+ await recordFailure({
871
+ effectId: request.effectId,
872
+ packageId: "composition",
873
+ phase: "compose",
874
+ reason:
875
+ "That generation changes first-party or User Package setup; package_undo may revert only this Bot's authored Packages",
876
+ }),
877
+ );
878
+ }
879
+
880
+ const recordedAt = existingIntent?.recordedAt ?? now().toISOString();
881
+ const intent: PackageUndoIntentV1 = existingIntent ?? {
882
+ schemaVersion: 1,
883
+ effectId: request.effectId,
884
+ botId: options.botId,
885
+ runId: options.runId,
886
+ turnId: options.turnId,
887
+ ...(request.input.generationId
888
+ ? { requestedGenerationId: request.input.generationId }
889
+ : {}),
890
+ targetGenerationId: target.generationId,
891
+ recordedAt,
892
+ status: "recorded",
893
+ };
894
+ // Intent before effect. `createdAt` makes the new generation id stable
895
+ // when a Durable Object resumes after `revert` but before outcome write.
896
+ if (!existingIntent) {
897
+ await options.storage.put({
898
+ [authorshipUndoIntentKey(request.effectId)]: intent,
899
+ });
900
+ }
901
+ const generation = await options.composition.revert(
902
+ target.generationId,
903
+ {
904
+ kind: "revert",
905
+ revertsTo: target.generationId,
906
+ botId: options.botId,
907
+ runId: options.runId,
908
+ turnId: options.turnId,
909
+ },
910
+ { createdAt: intent.recordedAt },
911
+ );
912
+ const outcome: PackageUndoRecordV1 = {
913
+ schemaVersion: 1,
914
+ effectId: request.effectId,
915
+ generationId: generation.generationId,
916
+ targetGenerationId: target.generationId,
917
+ recordedAt,
918
+ status: "recorded",
919
+ };
920
+ await options.storage.put({
921
+ [authorshipUndoOutcomeKey(request.effectId)]: outcome,
922
+ });
923
+ return {
924
+ status: "recorded",
925
+ effectId: request.effectId,
926
+ generationId: generation.generationId,
927
+ targetGenerationId: target.generationId,
928
+ };
929
+ },
930
+
931
+ async inspectSelf(): Promise<PackageInspectSelfOutcomeV1> {
932
+ const composition =
933
+ options.mountedGeneration?.() ?? (await options.composition.current());
934
+ const history = await compositionHistory();
935
+ const members = await Promise.all(
936
+ composition.members.map(async (member) => {
937
+ const recorded = await storedManifest(member);
938
+ const manifest = recorded
939
+ ? decodeFrockBotManifest(recorded.manifest)
940
+ : undefined;
941
+ const source =
942
+ member.provenance.kind === "bot" && options.artifacts
943
+ ? await readAuthoredCompositionMemberSourceV1({
944
+ storage: options.storage,
945
+ artifacts: options.artifacts,
946
+ member,
947
+ })
948
+ : undefined;
949
+ return {
950
+ packageId: member.packageId,
951
+ version: member.version,
952
+ provenance: structuredClone(member.provenance) as unknown as Record<
953
+ string,
954
+ unknown
955
+ >,
956
+ declaredTools: (manifest?.tools ?? []).map((tool) => tool.name),
957
+ ...(source === undefined ? {} : { source }),
958
+ };
959
+ }),
960
+ );
961
+ const packageIds = new Set(
962
+ history.flatMap((generation) =>
963
+ generation.members
964
+ .filter((member) => member.provenance.kind === "bot")
965
+ .map((member) => member.packageId),
966
+ ),
967
+ );
968
+ const failures: PackageInspectFailureV1[] = [];
969
+ for (const packageId of [...packageIds].sort()) {
970
+ const authoring = await options.storage.get<AuthoringFailureRecordV1>(
971
+ authorshipLatestFailureKey(packageId),
972
+ );
973
+ let activation: PackageInspectFailureV1["activation"] | undefined;
974
+ if (options.activationFailures) {
975
+ for (const generation of history) {
976
+ if (activation || generation.origin.kind !== "bot-authored") {
977
+ continue;
978
+ }
979
+ const origin = generation.origin;
980
+ const authoredMember = generation.members.find(
981
+ (member) =>
982
+ member.packageId === packageId &&
983
+ member.provenance.kind === "bot" &&
984
+ member.provenance.runId === origin.runId,
985
+ );
986
+ if (!authoredMember) continue;
987
+ const recorded = await options.activationFailures.list(
988
+ generation.generationId,
989
+ );
990
+ const latest = recorded.at(-1);
991
+ if (!latest) continue;
992
+ activation = {
993
+ generationId: generation.generationId,
994
+ attempt: latest.attempt,
995
+ phase: latest.phase,
996
+ message: latest.message,
997
+ diagnostics: latest.diagnostics,
998
+ at: latest.at,
999
+ quarantined: Boolean(
1000
+ await options.activationFailures.quarantine(
1001
+ generation.generationId,
1002
+ ),
1003
+ ),
1004
+ };
1005
+ }
1006
+ }
1007
+ failures.push({
1008
+ packageId,
1009
+ ...(authoring
1010
+ ? {
1011
+ authoring: {
1012
+ failureId: authoring.failureId,
1013
+ phase: authoring.phase,
1014
+ reason: authoring.reason,
1015
+ diagnostics: authoring.diagnostics,
1016
+ recordedAt: authoring.recordedAt,
1017
+ },
1018
+ }
1019
+ : {}),
1020
+ ...(activation ? { activation } : {}),
1021
+ });
1022
+ }
1023
+ return {
1024
+ contextContract: `${BOT_ISOLATE_CONTEXT_DTS_V1}\n\n${PACKAGE_IFRAME_BRIDGE_DTS_V1}\nInline ui.html helper:\n<script>${PACKAGE_IFRAME_HELPER_JS_V1}</script>`,
1025
+ composition: {
1026
+ generationId: composition.generationId,
1027
+ status: composition.status,
1028
+ members,
1029
+ },
1030
+ failures,
1031
+ };
509
1032
  },
510
1033
  };
511
1034
  }
@@ -523,9 +1046,51 @@ export function createR2AuthoringArtifactStore(
523
1046
  httpMetadata: { contentType: "application/javascript" },
524
1047
  });
525
1048
  },
1049
+ async putPackageUiArtifact(contentHash: string, html: string) {
1050
+ await bucket.put(`packages/${contentHash}.html`, html, {
1051
+ httpMetadata: { contentType: "text/html; charset=utf-8" },
1052
+ });
1053
+ },
1054
+ async putPackageSource(sourceHash: string, source: string) {
1055
+ await bucket.put(sourceR2KeyV1(sourceHash), source, {
1056
+ httpMetadata: { contentType: "text/typescript; charset=utf-8" },
1057
+ });
1058
+ },
1059
+ async loadPackageSource(sourceHash: string) {
1060
+ const object = await bucket.get(sourceR2KeyV1(sourceHash));
1061
+ if (!object) return undefined;
1062
+ const source = await object.text();
1063
+ if ((await sha256(source)) !== sourceHash) {
1064
+ throw new Error(
1065
+ `Package source "${sourceHash}" failed hash verification`,
1066
+ );
1067
+ }
1068
+ return source;
1069
+ },
526
1070
  async headPackageArtifact(contentHash: string) {
527
1071
  const object = await bucket.head(artifactR2KeyV1(contentHash));
528
1072
  return object ? { contentHash, size: object.size } : undefined;
529
1073
  },
530
1074
  };
531
1075
  }
1076
+
1077
+ /** Reads one Bot-authored member's retained source through its durable record. */
1078
+ export async function readAuthoredCompositionMemberSourceV1(input: {
1079
+ storage: Pick<AuthoringStorage, "get">;
1080
+ artifacts: Pick<AuthoringArtifactStore, "loadPackageSource">;
1081
+ member: CompositionMemberV1;
1082
+ }): Promise<string | undefined> {
1083
+ const { member } = input;
1084
+ if (member.provenance.kind !== "bot" || !member.artifact) return undefined;
1085
+ const artifact = await input.storage.get<AuthoredArtifactRecordV1>(
1086
+ artifactKey(member.artifact.contentHash),
1087
+ );
1088
+ if (
1089
+ !artifact ||
1090
+ artifact.contentHash !== member.artifact.contentHash ||
1091
+ artifact.manifestHash !== member.manifestHash
1092
+ ) {
1093
+ return undefined;
1094
+ }
1095
+ return input.artifacts.loadPackageSource(artifact.sourceHash);
1096
+ }