@playcraft/cli 0.1.56-beta → 0.1.58-beta

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.
Files changed (101) hide show
  1. package/dist/assets/client.js +6 -4
  2. package/dist/commands/tools-3d.js +15 -0
  3. package/dist/commands/tools-generation.js +3 -2
  4. package/dist/commands/workspace.js +43 -0
  5. package/dist/utils/agent-api-client.js +31 -8
  6. package/dist/utils/agent-generation-provenance.js +45 -0
  7. package/dist/utils/generation-workspace-scope.js +50 -0
  8. package/dist/utils/image-generation-retry.js +7 -0
  9. package/dist/utils/tool-operation-journal.js +34 -4
  10. package/dist/workspace/messages.js +10 -0
  11. package/dist/workspace-runtime/batch/deliver.js +140 -80
  12. package/dist/workspace-runtime/batch/freeze.js +18 -6
  13. package/dist/workspace-runtime/batch/generate.js +68 -64
  14. package/dist/workspace-runtime/batch/prepare.js +109 -137
  15. package/dist/workspace-runtime/batch/records.js +4 -0
  16. package/dist/workspace-runtime/batch/routes.js +10 -2
  17. package/dist/workspace-runtime/batch/source-candidate.js +2 -0
  18. package/dist/workspace-runtime/batch/source-capture.js +1 -0
  19. package/dist/workspace-runtime/batch/source-failure.js +2 -0
  20. package/dist/workspace-runtime/batch/source-publish.js +3 -0
  21. package/dist/workspace-runtime/batch/writer-proof.js +37 -0
  22. package/dist/workspace-runtime/codex/app-server.js +1 -0
  23. package/dist/workspace-runtime/codex/config-toml.js +4 -0
  24. package/dist/workspace-runtime/codex/delivery-tools.js +7 -5
  25. package/dist/workspace-runtime/codex/generation-provenance-broker.js +80 -0
  26. package/dist/workspace-runtime/codex/launcher-client.js +18 -0
  27. package/dist/workspace-runtime/codex/model-broker.js +23 -4
  28. package/dist/workspace-runtime/persistence/journal.js +34 -0
  29. package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
  30. package/dist/workspace-runtime/previews/output.js +2 -0
  31. package/dist/workspace-runtime/previews/publish.js +20 -5
  32. package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
  33. package/dist/workspace-runtime/processes/managed-writes.js +47 -5
  34. package/dist/workspace-runtime/serve.js +19 -32
  35. package/dist/workspace-runtime/server/accept-operation.js +22 -2
  36. package/dist/workspace-runtime/server/directory-access.js +414 -0
  37. package/dist/workspace-runtime/server/dispatch.js +61 -23
  38. package/dist/workspace-runtime/server/endpoints.js +1 -0
  39. package/dist/workspace-runtime/server/http.js +52 -10
  40. package/dist/workspace-runtime/server/operation-record.js +2 -0
  41. package/dist/workspace-runtime/server/terminal-host.js +43 -8
  42. package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
  43. package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
  44. package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
  45. package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
  46. package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
  47. package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
  48. package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
  49. package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
  50. package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
  51. package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
  52. package/dist/workspace-runtime/workspaces/attachments.js +9 -0
  53. package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
  54. package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
  55. package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
  56. package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
  57. package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
  58. package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
  59. package/dist/workspace-runtime/workspaces/dependencies.js +105 -71
  60. package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
  61. package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
  62. package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
  63. package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
  64. package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
  65. package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
  66. package/dist/workspace-runtime/workspaces/files.js +4 -0
  67. package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
  68. package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
  69. package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
  70. package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
  71. package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
  72. package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
  73. package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
  74. package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
  75. package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
  76. package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
  77. package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
  78. package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
  79. package/dist/workspace-runtime/workspaces/parameters.js +95 -3
  80. package/dist/workspace-runtime/workspaces/platform-export.js +190 -0
  81. package/dist/workspace-runtime/workspaces/prepare.js +4 -0
  82. package/dist/workspace-runtime/workspaces/registry.js +11 -0
  83. package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
  84. package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
  85. package/dist/workspace-runtime/workspaces/skills.js +14 -15
  86. package/dist/workspace-runtime/workspaces/transfer.js +4 -1
  87. package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
  88. package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
  89. package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
  90. package/package.json +5 -4
  91. package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
  92. package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
  93. package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
  94. package/project-template-v2/.playcraft/skills.lock.json +15 -8
  95. package/templates/preview-runtime/v1/README.md +4 -0
  96. package/templates/preview-runtime/v1/manifest.json +6 -6
  97. package/templates/preview-runtime/v1/runtime.mjs +368 -22
  98. package/templates/preview-runtime/v1/types/adapters.d.ts +4 -1
  99. package/templates/preview-runtime/v1/types/preview.d.ts +1 -0
  100. package/templates/preview-runtime/v1/types/protocol.d.ts +19 -0
  101. package/templates/preview-runtime/v1/types/runtime.d.ts +2 -1
@@ -198,6 +198,7 @@ var BridgeMethods = {
198
198
  status: "runtime.parameters.status",
199
199
  dispose: "runtime.parameters.dispose",
200
200
  asset: "runtime.parameters.asset",
201
+ refreshImages: "runtime.parameters.refresh-images",
201
202
  capabilities: "runtime.parameters.capabilities",
202
203
  result: "runtime.parameters.result",
203
204
  resyncRequired: "runtime.parameters.resync-required"
@@ -222,7 +223,8 @@ var BRIDGE_RPC_NAMES = [
222
223
  BridgeMethods.runtimeParameters.restart,
223
224
  BridgeMethods.runtimeParameters.status,
224
225
  BridgeMethods.runtimeParameters.dispose,
225
- BridgeMethods.runtimeParameters.asset
226
+ BridgeMethods.runtimeParameters.asset,
227
+ BridgeMethods.runtimeParameters.refreshImages
226
228
  ];
227
229
 
228
230
  // ../iframe-bridge/src/configs/perspective.ts
@@ -637,6 +639,28 @@ var identityKeys = [
637
639
  "configContractVersion",
638
640
  "locale"
639
641
  ];
642
+ function assertImageRefresh(value) {
643
+ assertIdentity(value);
644
+ const request = value;
645
+ exactKeys(value, [
646
+ ...identityKeys,
647
+ "draftRevision",
648
+ "resourceSequence",
649
+ "images"
650
+ ]);
651
+ if (!revision(request.draftRevision) || !revision(request.resourceSequence) || request.resourceSequence === 0 || !Array.isArray(request.images) || !request.images.length || request.images.length > 64)
652
+ throw new ProtocolError("INVALID", "Invalid image refresh sequence or target count");
653
+ const seen = /* @__PURE__ */ new Set();
654
+ for (const image of request.images) {
655
+ if (!record(image) || !string(image.documentId) || forbidden.has(image.documentId) || !Array.isArray(image.path) || !image.path.length || image.path.length > 32 || image.path.some((part) => !string(part) || forbidden.has(part)) || image.contentHash !== null && (typeof image.contentHash !== "string" || !/^[a-f0-9]{64}$/.test(image.contentHash)))
656
+ throw new ProtocolError("INVALID", "Invalid image refresh target");
657
+ exactKeys(image, ["documentId", "path", "contentHash"]);
658
+ const key = fingerprint([image.documentId, image.path]);
659
+ if (seen.has(key)) throw new ProtocolError("INVALID", "Duplicate image refresh target");
660
+ seen.add(key);
661
+ }
662
+ limit(value, 64 * 1024);
663
+ }
640
664
  function assertImageResourceData(value) {
641
665
  if (!record(value) || typeof value.mimeType !== "string" || typeof value.dataUrl !== "string")
642
666
  throw new ProtocolError("INVALID", "Invalid image resource response");
@@ -738,6 +762,55 @@ function createParameterRuntime(options) {
738
762
  const receipts = /* @__PURE__ */ new Map();
739
763
  const listeners = /* @__PURE__ */ new Set();
740
764
  const capabilityListeners = /* @__PURE__ */ new Set();
765
+ const installedImages = /* @__PURE__ */ new Map();
766
+ const imageReads = /* @__PURE__ */ new Map();
767
+ const imageLoads = /* @__PURE__ */ new WeakMap();
768
+ const refreshGoals = /* @__PURE__ */ new Map();
769
+ const refreshWork = /* @__PURE__ */ new Set();
770
+ const refreshReceipts = /* @__PURE__ */ new Map();
771
+ let resourceSequence = 0;
772
+ const imageKey = (field) => fingerprint([field.documentId, field.path]);
773
+ async function readImage(request, signal) {
774
+ signal.throwIfAborted();
775
+ const key = imageKey(request);
776
+ const read = new AbortController();
777
+ const cancel = () => read.abort(signal.reason);
778
+ signal.addEventListener("abort", cancel, { once: true });
779
+ const active = imageReads.get(key) ?? /* @__PURE__ */ new Set();
780
+ active.add(read);
781
+ imageReads.set(key, active);
782
+ let off = () => {
783
+ };
784
+ try {
785
+ return await Promise.race([
786
+ options.loadImage(request, read.signal),
787
+ new Promise((_, reject) => {
788
+ const cancelled = () => reject(read.signal.reason);
789
+ read.signal.addEventListener("abort", cancelled, { once: true });
790
+ off = () => read.signal.removeEventListener("abort", cancelled);
791
+ if (read.signal.aborted) cancelled();
792
+ })
793
+ ]);
794
+ } finally {
795
+ off();
796
+ signal.removeEventListener("abort", cancel);
797
+ active.delete(read);
798
+ if (!active.size && imageReads.get(key) === active) imageReads.delete(key);
799
+ }
800
+ }
801
+ function cancelRefreshes() {
802
+ for (const goal of refreshGoals.values()) goal.controller.abort(new SupersededResourceError());
803
+ refreshGoals.clear();
804
+ }
805
+ function commitImages(ctx) {
806
+ for (const [key, image] of imageLoads.get(ctx) ?? []) {
807
+ installedImages.set(key, image);
808
+ resourceErrors.delete(key);
809
+ }
810
+ for (const field of ctx.fields)
811
+ if (field.resource === "image" && readPath(ctx.config, field.effectivePath) === "")
812
+ installedImages.delete(imageKey(field));
813
+ }
741
814
  async function release(game) {
742
815
  try {
743
816
  await game?.dispose();
@@ -752,6 +825,7 @@ function createParameterRuntime(options) {
752
825
  ...requireIdentity(),
753
826
  stage,
754
827
  canAcceptSnapshot: true,
828
+ ...options.loadImage ? { canRefreshImages: true, canReportMissingImages: true } : {},
755
829
  fields: clone(options.fields)
756
830
  });
757
831
  function requireIdentity() {
@@ -782,19 +856,22 @@ function createParameterRuntime(options) {
782
856
  const value = result();
783
857
  listeners.forEach((fn) => fn(value));
784
858
  }
785
- function context(config, beforeConfig, draftRevision, fields, signal) {
859
+ function context(config, beforeConfig, draftRevision, fields, signal, refresh) {
786
860
  const images = /* @__PURE__ */ new Map();
861
+ const loaded = /* @__PURE__ */ new Map();
787
862
  const assertCurrent = () => {
788
863
  signal.throwIfAborted();
789
864
  if (signal !== controller.signal || disposed)
790
865
  throw new ProtocolError("STALE", "Application task expired");
866
+ refresh?.assertCurrent();
791
867
  if (images.size && target) {
792
868
  const latest = options.resolve(target);
793
- for (const { field, value } of images.values())
794
- if (readPath(latest, field.effectivePath) !== value) throw new SupersededResourceError();
869
+ for (const [key, { field, value, sequence }] of images)
870
+ if (readPath(latest, field.effectivePath) !== value || (refreshGoals.get(key)?.sequence ?? 0) !== sequence)
871
+ throw new SupersededResourceError();
795
872
  }
796
873
  };
797
- return {
874
+ const ctx = {
798
875
  config,
799
876
  beforeConfig,
800
877
  draftRevision,
@@ -810,21 +887,31 @@ function createParameterRuntime(options) {
810
887
  if (!options.loadImage)
811
888
  throw new ProtocolError("UNSUPPORTED", "Image resource transport is unavailable");
812
889
  const key = fingerprint([registered.documentId, registered.path]);
813
- images.set(key, { field: registered, value });
890
+ const goal = refreshGoals.get(key);
891
+ images.set(key, { field: registered, value, sequence: goal?.sequence ?? 0 });
814
892
  assertCurrent();
815
893
  try {
816
- const image = await options.loadImage(
894
+ const prepared = refresh?.prepared.get(key);
895
+ const response = prepared?.value === value ? prepared : await readImage(
817
896
  {
818
897
  ...requireIdentity(),
819
898
  draftRevision,
820
899
  documentId: registered.documentId,
821
900
  path: clone(registered.path),
822
- value
901
+ value,
902
+ ...goal?.value === value ? {
903
+ resourceSequence: goal.sequence,
904
+ ...goal.contentHash !== null ? { expectedContentHash: goal.contentHash } : {}
905
+ } : {}
823
906
  },
824
907
  signal
825
908
  );
826
909
  assertCurrent();
827
- resourceErrors.delete(key);
910
+ const image = "image" in response ? response.image : response;
911
+ const contentHash = "image" in response ? response.contentHash : void 0;
912
+ if (goal?.value === value && goal.contentHash !== null && contentHash !== goal.contentHash)
913
+ throw new ResourceLoadError("Image resource content changed during refresh");
914
+ loaded.set(key, { value, image, ...contentHash ? { contentHash } : {} });
828
915
  return image;
829
916
  } catch (failure) {
830
917
  assertCurrent();
@@ -838,6 +925,8 @@ function createParameterRuntime(options) {
838
925
  }
839
926
  }
840
927
  };
928
+ imageLoads.set(ctx, loaded);
929
+ return ctx;
841
930
  }
842
931
  function enqueue(work) {
843
932
  const signal = controller.signal;
@@ -860,6 +949,7 @@ function createParameterRuntime(options) {
860
949
  publish();
861
950
  }
862
951
  });
952
+ return queue;
863
953
  }
864
954
  function remember(kind, request) {
865
955
  const key = `${kind}:${request.draftRevision}`;
@@ -915,15 +1005,11 @@ function createParameterRuntime(options) {
915
1005
  } else status = reload ? "requires-reload" : pending ? "pending-commit" : "applying";
916
1006
  publish();
917
1007
  }
918
- async function applyTarget(documents, rev, commit, signal, retryResources = /* @__PURE__ */ new Set()) {
919
- if (status === "error" && !recoverable) return;
920
- if (!adapter || !installed)
921
- throw new ProtocolError("STATE", "Initial snapshot must create the game first");
1008
+ function candidateFor(documents, commit, retryResources) {
922
1009
  const operations = [];
923
1010
  for (const field of options.fields) {
924
1011
  if (field.mode !== "live" && !(commit && field.mode === "restart-scene")) continue;
925
- const resourceKey = fingerprint([field.documentId, field.path]);
926
- if (resourceErrors.has(resourceKey) && !retryResources.has(resourceKey)) continue;
1012
+ if (resourceErrors.has(imageKey(field)) && !retryResources.has(imageKey(field))) continue;
927
1013
  const value = readPath(documents[field.documentId], field.path);
928
1014
  operations.push(
929
1015
  value === void 0 ? { op: "remove", documentId: field.documentId, path: field.path } : {
@@ -934,13 +1020,29 @@ function createParameterRuntime(options) {
934
1020
  }
935
1021
  );
936
1022
  }
937
- const candidate = applyOperations(installed, operations);
1023
+ return applyOperations(installed, operations);
1024
+ }
1025
+ async function applyTarget(documents, rev, commit, signal, retryResources = /* @__PURE__ */ new Set(), refresh, rereadImages = /* @__PURE__ */ new Set()) {
1026
+ if (status === "error" && !recoverable) return;
1027
+ if (!adapter || !installed)
1028
+ throw new ProtocolError("STATE", "Initial snapshot must create the game first");
1029
+ const candidate = candidateFor(documents, commit, retryResources);
938
1030
  const before = options.source.get();
939
1031
  const config = freeze(options.resolve(freeze(clone(candidate))));
940
1032
  const changed = options.fields.filter(
941
- (field) => resourceErrors.has(fingerprint([field.documentId, field.path])) && retryResources.has(fingerprint([field.documentId, field.path])) && (field.mode === "live" || commit && field.mode === "restart-scene") || fingerprint(readPath(before, field.effectivePath)) !== fingerprint(readPath(config, field.effectivePath))
1033
+ (field) => rereadImages.has(imageKey(field)) || refresh?.prepared.has(imageKey(field)) || resourceErrors.has(fingerprint([field.documentId, field.path])) && retryResources.has(fingerprint([field.documentId, field.path])) && (field.mode === "live" || commit && field.mode === "restart-scene") || fingerprint(readPath(before, field.effectivePath)) !== fingerprint(readPath(config, field.effectivePath))
1034
+ );
1035
+ const ctx = context(
1036
+ config,
1037
+ before,
1038
+ rev,
1039
+ changed,
1040
+ signal,
1041
+ refresh ? {
1042
+ prepared: refresh.prepared,
1043
+ assertCurrent: () => refresh.assertCurrent(config)
1044
+ } : void 0
942
1045
  );
943
- const ctx = context(config, before, rev, changed, signal);
944
1046
  ctx.assertCurrent();
945
1047
  status = "applying";
946
1048
  publish();
@@ -953,6 +1055,7 @@ function createParameterRuntime(options) {
953
1055
  ctx.assertCurrent();
954
1056
  await adapter.afterRender(ctx);
955
1057
  ctx.assertCurrent();
1058
+ commitImages(ctx);
956
1059
  for (const field of changed)
957
1060
  if (field.resource === "image" && readPath(config, field.effectivePath) === "")
958
1061
  resourceErrors.delete(fingerprint([field.documentId, field.path]));
@@ -962,6 +1065,18 @@ function createParameterRuntime(options) {
962
1065
  if (!signal.aborted) {
963
1066
  options.source.install(before);
964
1067
  installedRevision = previousInstalledRevision;
1068
+ if (e instanceof ResourceLoadError)
1069
+ for (const field of changed) {
1070
+ if (field.resource !== "image") continue;
1071
+ const key = imageKey(field);
1072
+ installedImages.delete(key);
1073
+ if (!resourceErrors.has(key))
1074
+ resourceErrors.set(key, {
1075
+ documentId: field.documentId,
1076
+ path: clone(field.path),
1077
+ error: e.message
1078
+ });
1079
+ }
965
1080
  }
966
1081
  throw e;
967
1082
  }
@@ -989,6 +1104,10 @@ function createParameterRuntime(options) {
989
1104
  cleanupFailure = e;
990
1105
  });
991
1106
  identity = freeze({ ...clone(value), runtimeInstanceId });
1107
+ cancelRefreshes();
1108
+ installedImages.clear();
1109
+ refreshReceipts.clear();
1110
+ resourceSequence = 0;
992
1111
  target = installed = void 0;
993
1112
  receivedRevision = installedRevision = appliedRevision = -1;
994
1113
  receipts.clear();
@@ -1016,6 +1135,8 @@ function createParameterRuntime(options) {
1016
1135
  const rev = value.draftRevision;
1017
1136
  const documents = freeze(clone(value.documents));
1018
1137
  options.resolve(documents);
1138
+ cancelRefreshes();
1139
+ installedImages.clear();
1019
1140
  target = documents;
1020
1141
  const receipt = accept("snapshot", value);
1021
1142
  enqueue(async (signal) => {
@@ -1054,6 +1175,7 @@ function createParameterRuntime(options) {
1054
1175
  capabilityListeners.forEach((fn) => fn(capabilities()));
1055
1176
  await created.afterRender(ctx);
1056
1177
  ctx.assertCurrent();
1178
+ commitImages(ctx);
1057
1179
  installed = clone(nextDocuments);
1058
1180
  if (resourceErrors.size) installedRevision = -1;
1059
1181
  error = void 0;
@@ -1099,11 +1221,222 @@ function createParameterRuntime(options) {
1099
1221
  const rev = value.draftRevision, commit = value.phase === "commit";
1100
1222
  options.resolve(documents);
1101
1223
  target = documents;
1224
+ const latestConfig = options.resolve(documents);
1225
+ const rereadImages = /* @__PURE__ */ new Set();
1226
+ for (const [key, goal] of refreshGoals)
1227
+ if (readPath(latestConfig, goal.field.effectivePath) !== goal.value || fingerprint(readPath(documents[goal.field.documentId], goal.field.path)) !== fingerprint(goal.raw)) {
1228
+ const image = installedImages.get(key);
1229
+ if (goal.contentHash !== null && (goal.applying || image?.value !== goal.value || image.contentHash !== goal.contentHash))
1230
+ rereadImages.add(key);
1231
+ goal.controller.abort(new SupersededResourceError());
1232
+ refreshGoals.delete(key);
1233
+ }
1102
1234
  const receipt = accept("patch", value);
1103
1235
  const retryResources = new Set(
1104
1236
  value.operations.length ? value.operations.map((operation) => fingerprint([operation.documentId, operation.path])) : options.fields.filter((field) => field.resource === "image").map((field) => fingerprint([field.documentId, field.path]))
1105
1237
  );
1106
- enqueue((signal) => applyTarget(documents, rev, commit, signal, retryResources));
1238
+ enqueue(
1239
+ (signal) => applyTarget(documents, rev, commit, signal, retryResources, void 0, rereadImages)
1240
+ );
1241
+ return receipt;
1242
+ },
1243
+ refreshImages(value) {
1244
+ assertImageRefresh(value);
1245
+ check(value);
1246
+ const request = freeze(clone(value));
1247
+ const previous = refreshReceipts.get(request.resourceSequence);
1248
+ if (previous) {
1249
+ if (previous.fingerprint !== fingerprint(request))
1250
+ throw new ProtocolError("CONFLICT", "Image refresh sequence already accepted");
1251
+ return previous.receipt;
1252
+ }
1253
+ if (request.resourceSequence <= resourceSequence || request.draftRevision !== receivedRevision || !target)
1254
+ throw new ProtocolError("STALE", "Image refresh must name the current target and sequence");
1255
+ if (!adapter || !installed || !options.loadImage || status === "error" && !recoverable)
1256
+ throw new ProtocolError("STATE", "Image refresh requires an available game");
1257
+ if (refreshWork.size >= 64)
1258
+ throw new ProtocolError("LIMIT", "Too many pending image refresh requests");
1259
+ const config = options.resolve(target);
1260
+ const uncertain = /* @__PURE__ */ new Set();
1261
+ const goals = request.images.map((image) => {
1262
+ const field = options.fields.find(
1263
+ (field2) => imageKey(field2) === imageKey(image) && field2.resource === "image" && field2.mode === "live"
1264
+ );
1265
+ const current = field && readPath(config, field.effectivePath);
1266
+ if (!field || typeof current !== "string" || !current)
1267
+ throw new ProtocolError("UNSUPPORTED", "Image refresh requires a live nonempty image");
1268
+ if (refreshGoals.get(imageKey(field))?.applying) uncertain.add(imageKey(field));
1269
+ return {
1270
+ field,
1271
+ value: current,
1272
+ raw: clone(readPath(target[field.documentId], field.path)),
1273
+ contentHash: image.contentHash,
1274
+ sequence: request.resourceSequence,
1275
+ controller: new AbortController()
1276
+ };
1277
+ });
1278
+ const receipt = {
1279
+ accepted: true,
1280
+ receivedRevision,
1281
+ resourceSequence: request.resourceSequence
1282
+ };
1283
+ resourceSequence = request.resourceSequence;
1284
+ refreshReceipts.set(resourceSequence, { fingerprint: fingerprint(request), receipt });
1285
+ if (refreshReceipts.size > 256) refreshReceipts.delete(refreshReceipts.keys().next().value);
1286
+ for (const goal of goals) {
1287
+ for (const read of imageReads.get(imageKey(goal.field)) ?? [])
1288
+ read.abort(new SupersededResourceError());
1289
+ refreshGoals.get(imageKey(goal.field))?.controller.abort(new SupersededResourceError());
1290
+ refreshGoals.set(imageKey(goal.field), goal);
1291
+ }
1292
+ const active = goals.filter((goal) => {
1293
+ if (goal.contentHash === null) return false;
1294
+ const image = installedImages.get(imageKey(goal.field));
1295
+ return uncertain.has(imageKey(goal.field)) || resourceErrors.has(imageKey(goal.field)) || image?.value !== goal.value || image.contentHash !== goal.contentHash;
1296
+ });
1297
+ const sessionSignal = controller.signal;
1298
+ const assertSession = () => {
1299
+ sessionSignal.throwIfAborted();
1300
+ if (disposed || sessionSignal !== controller.signal || !target)
1301
+ throw new SupersededResourceError();
1302
+ };
1303
+ const isCurrent = (goal) => {
1304
+ assertSession();
1305
+ const latest = options.resolve(target);
1306
+ return !goal.controller.signal.aborted && refreshGoals.get(imageKey(goal.field)) === goal && readPath(latest, goal.field.effectivePath) === goal.value;
1307
+ };
1308
+ const assertGoal = (goal) => {
1309
+ if (!isCurrent(goal)) throw new SupersededResourceError();
1310
+ };
1311
+ const missing = goals.filter((goal) => goal.contentHash === null);
1312
+ for (const goal of missing)
1313
+ resourceErrors.set(imageKey(goal.field), {
1314
+ documentId: goal.field.documentId,
1315
+ path: clone(goal.field.path),
1316
+ error: "Image resource file is missing"
1317
+ });
1318
+ if (missing.length)
1319
+ void enqueue(async () => {
1320
+ assertSession();
1321
+ if (missing.some(isCurrent)) updateState(receivedRevision, []);
1322
+ });
1323
+ if (!active.length) return receipt;
1324
+ const work = (async () => {
1325
+ const prepared = /* @__PURE__ */ new Map();
1326
+ const cancel = () => goals.forEach((goal) => goal.controller.abort(sessionSignal.reason));
1327
+ sessionSignal.addEventListener("abort", cancel, { once: true });
1328
+ try {
1329
+ await Promise.all(
1330
+ active.map(async (goal) => {
1331
+ try {
1332
+ assertGoal(goal);
1333
+ const response = await readImage(
1334
+ {
1335
+ ...requireIdentity(),
1336
+ draftRevision: request.draftRevision,
1337
+ documentId: goal.field.documentId,
1338
+ path: clone(goal.field.path),
1339
+ value: goal.value,
1340
+ resourceSequence: goal.sequence,
1341
+ expectedContentHash: goal.contentHash
1342
+ },
1343
+ goal.controller.signal
1344
+ );
1345
+ assertGoal(goal);
1346
+ if (!("image" in response) || response.contentHash !== goal.contentHash)
1347
+ throw new ResourceLoadError("Image resource content changed during refresh");
1348
+ prepared.set(imageKey(goal.field), {
1349
+ value: goal.value,
1350
+ image: response.image,
1351
+ contentHash: response.contentHash
1352
+ });
1353
+ } catch (failure) {
1354
+ if (!isCurrent(goal)) return;
1355
+ if (failure instanceof ResourceLoadError)
1356
+ resourceErrors.set(imageKey(goal.field), {
1357
+ documentId: goal.field.documentId,
1358
+ path: clone(goal.field.path),
1359
+ error: failure.message
1360
+ });
1361
+ throw failure;
1362
+ }
1363
+ })
1364
+ );
1365
+ for (let attempt = 0; attempt < 32; attempt++) {
1366
+ let rebase = false;
1367
+ await enqueue(async (signal) => {
1368
+ assertSession();
1369
+ const current = active.filter(
1370
+ (goal) => prepared.has(imageKey(goal.field)) && isCurrent(goal)
1371
+ );
1372
+ if (!current.length) return;
1373
+ const selected = new Map(
1374
+ current.map((goal) => [imageKey(goal.field), prepared.get(imageKey(goal.field))])
1375
+ );
1376
+ const retry = new Set(selected.keys());
1377
+ const documents = clone(target);
1378
+ const rev = receivedRevision;
1379
+ try {
1380
+ current.forEach((goal) => {
1381
+ goal.applying = true;
1382
+ });
1383
+ await applyTarget(documents, rev, false, signal, retry, {
1384
+ prepared: selected,
1385
+ assertCurrent(config2) {
1386
+ assertSession();
1387
+ if (current.some((goal) => !isCurrent(goal))) {
1388
+ rebase = true;
1389
+ throw new SupersededResourceError();
1390
+ }
1391
+ const latest = options.resolve(candidateFor(target, false, retry));
1392
+ if (fingerprint(latest) !== fingerprint(config2)) {
1393
+ rebase = true;
1394
+ throw new SupersededResourceError();
1395
+ }
1396
+ }
1397
+ });
1398
+ } catch (failure) {
1399
+ if (rebase && failure instanceof SupersededResourceError) return;
1400
+ throw failure;
1401
+ } finally {
1402
+ current.forEach((goal) => {
1403
+ goal.applying = false;
1404
+ });
1405
+ }
1406
+ });
1407
+ if (!rebase) return;
1408
+ }
1409
+ throw new ResourceLoadError("Image refresh was interrupted by parameter changes; retry");
1410
+ } catch (failure) {
1411
+ if (!sessionSignal.aborted && !(failure instanceof SupersededResourceError)) {
1412
+ try {
1413
+ assertSession();
1414
+ } catch {
1415
+ return;
1416
+ }
1417
+ if (failure instanceof ResourceLoadError) {
1418
+ for (const goal of active.filter(isCurrent))
1419
+ if (!resourceErrors.has(imageKey(goal.field)))
1420
+ resourceErrors.set(imageKey(goal.field), {
1421
+ documentId: goal.field.documentId,
1422
+ path: clone(goal.field.path),
1423
+ error: failure.message
1424
+ });
1425
+ }
1426
+ await enqueue(async () => {
1427
+ throw failure;
1428
+ });
1429
+ }
1430
+ } finally {
1431
+ goals.forEach((goal) => goal.controller.abort(new SupersededResourceError()));
1432
+ active.forEach((goal) => {
1433
+ goal.applying = false;
1434
+ });
1435
+ sessionSignal.removeEventListener("abort", cancel);
1436
+ }
1437
+ })();
1438
+ refreshWork.add(work);
1439
+ void work.finally(() => refreshWork.delete(work));
1107
1440
  return receipt;
1108
1441
  },
1109
1442
  restart(value) {
@@ -1143,15 +1476,22 @@ function createParameterRuntime(options) {
1143
1476
  return () => capabilityListeners.delete(fn);
1144
1477
  },
1145
1478
  async settled() {
1146
- await queue;
1479
+ for (; ; ) {
1480
+ const current = queue;
1481
+ await Promise.all([...refreshWork]);
1482
+ await queue;
1483
+ if (!refreshWork.size && current === queue) return;
1484
+ }
1147
1485
  },
1148
1486
  async dispose() {
1149
1487
  if (disposed) return;
1150
1488
  disposed = true;
1151
1489
  controller.abort();
1490
+ cancelRefreshes();
1152
1491
  listeners.clear();
1153
1492
  capabilityListeners.clear();
1154
1493
  await queue;
1494
+ await Promise.all([...refreshWork]);
1155
1495
  await adapter?.dispose();
1156
1496
  adapter = void 0;
1157
1497
  }
@@ -1249,7 +1589,7 @@ function connectPreviewRuntime(options) {
1249
1589
  if (!sameIdentity(request, value))
1250
1590
  throw new ProtocolError("STALE", "Image response belongs to a different runtime");
1251
1591
  const response = value;
1252
- if (response.draftRevision !== request.draftRevision || response.documentId !== request.documentId || fingerprint(response.path) !== fingerprint(request.path) || response.value !== request.value)
1592
+ if (response.draftRevision !== request.draftRevision || response.documentId !== request.documentId || fingerprint(response.path) !== fingerprint(request.path) || response.value !== request.value || response.resourceSequence !== request.resourceSequence || response.expectedContentHash !== request.expectedContentHash)
1253
1593
  throw new ProtocolError("STALE", "Image response does not match its request");
1254
1594
  if (response.status === "stale") throw new SupersededResourceError();
1255
1595
  if (response.status === "failed") {
@@ -1260,7 +1600,12 @@ function connectPreviewRuntime(options) {
1260
1600
  if (response.status !== "ready")
1261
1601
  throw new ProtocolError("INVALID", "Invalid image resource response");
1262
1602
  assertImageResourceData(response);
1263
- return decodeImage(response.dataUrl, signal);
1603
+ if (request.expectedContentHash !== void 0 && response.contentHash !== request.expectedContentHash)
1604
+ throw new ResourceLoadError("Image resource content changed during refresh");
1605
+ return {
1606
+ image: await decodeImage(response.dataUrl, signal),
1607
+ contentHash: response.contentHash
1608
+ };
1264
1609
  }
1265
1610
  });
1266
1611
  const off = [
@@ -1281,6 +1626,7 @@ function connectPreviewRuntime(options) {
1281
1626
  }
1282
1627
  }),
1283
1628
  bridge.on(names.restart, (value) => runtime.restart(value)),
1629
+ bridge.on(names.refreshImages, (value) => runtime.refreshImages(value)),
1284
1630
  bridge.on(names.status, (value) => runtime.status(value)),
1285
1631
  bridge.on(names.dispose, async (value) => {
1286
1632
  runtime.status(value);
@@ -24,5 +24,8 @@ export interface RuntimeOptions<C> {
24
24
  install(config: C): unknown;
25
25
  };
26
26
  createGame(context: ApplyContext<C>): Promise<GameAdapter<C>>;
27
- loadImage?(request: ImageResourceRequest, signal: AbortSignal): Promise<HTMLImageElement>;
27
+ loadImage?(request: ImageResourceRequest, signal: AbortSignal): Promise<HTMLImageElement | {
28
+ image: HTMLImageElement;
29
+ contentHash?: string;
30
+ }>;
28
31
  }
@@ -13,6 +13,7 @@ export declare function connectPreviewRuntime<C>(options: RuntimeOptions<C> & {
13
13
  hello(value: unknown): import("./protocol.js").Capabilities;
14
14
  snapshot(value: unknown): import("./protocol.js").Receipt;
15
15
  patch(value: unknown): import("./protocol.js").Receipt;
16
+ refreshImages(value: unknown): import("./protocol.js").ImageRefreshReceipt;
16
17
  restart(value: unknown): import("./protocol.js").Receipt;
17
18
  status(value: unknown): import("./protocol.js").ApplicationResult;
18
19
  subscribe(fn: (result: import("./protocol.js").ApplicationResult) => void): () => boolean;
@@ -36,6 +36,8 @@ export interface Capabilities extends Identity {
36
36
  stage: 'bootstrap-ready' | 'game-ready';
37
37
  canAcceptSnapshot: boolean;
38
38
  fields: FieldCapability[];
39
+ canRefreshImages?: true;
40
+ canReportMissingImages?: true;
39
41
  }
40
42
  export type Operation = {
41
43
  op: 'set';
@@ -61,6 +63,20 @@ export interface Receipt {
61
63
  accepted: true;
62
64
  receivedRevision: number;
63
65
  }
66
+ export type ImageRefreshTarget = {
67
+ documentId: string;
68
+ path: string[];
69
+ /** Null reports a confirmed missing file; it does not clear the configured image. */
70
+ contentHash: string | null;
71
+ };
72
+ export interface ImageRefreshRequest extends Identity {
73
+ draftRevision: number;
74
+ resourceSequence: number;
75
+ images: ImageRefreshTarget[];
76
+ }
77
+ export interface ImageRefreshReceipt extends Receipt {
78
+ resourceSequence: number;
79
+ }
64
80
  export interface GroupResult {
65
81
  groupId: string;
66
82
  appliedRevision: number;
@@ -86,6 +102,8 @@ export interface ImageResourceRequest extends Identity {
86
102
  documentId: string;
87
103
  path: string[];
88
104
  value: string;
105
+ resourceSequence?: number;
106
+ expectedContentHash?: string;
89
107
  }
90
108
  export type ImageResourceData = {
91
109
  mimeType: string;
@@ -124,6 +142,7 @@ export declare function sameIdentity(a: Identity, b: unknown): b is Identity;
124
142
  export declare function assertBinding(expected: SessionBinding, actual: Hello): void;
125
143
  export declare function assertDocuments(value: unknown): asserts value is RawDocuments;
126
144
  export declare function assertImageResourceRequest(value: unknown): asserts value is ImageResourceRequest;
145
+ export declare function assertImageRefresh(value: unknown): asserts value is ImageRefreshRequest;
127
146
  export declare function assertImageResourceData(value: unknown): asserts value is ImageResourceData;
128
147
  export declare function assertSnapshot(value: unknown): asserts value is SnapshotRequest;
129
148
  export declare function assertPatch(value: unknown): asserts value is PatchRequest;
@@ -1,9 +1,10 @@
1
1
  import type { RuntimeOptions } from './adapters.js';
2
- import type { ApplicationResult, Capabilities, Receipt } from './protocol.js';
2
+ import type { ApplicationResult, Capabilities, ImageRefreshReceipt, Receipt } from './protocol.js';
3
3
  export declare function createParameterRuntime<C>(options: RuntimeOptions<C>): {
4
4
  hello(value: unknown): Capabilities;
5
5
  snapshot(value: unknown): Receipt;
6
6
  patch(value: unknown): Receipt;
7
+ refreshImages(value: unknown): ImageRefreshReceipt;
7
8
  restart(value: unknown): Receipt;
8
9
  status(value: unknown): ApplicationResult;
9
10
  subscribe(fn: (result: ApplicationResult) => void): () => boolean;