@typetorch/framework 0.5.2 → 0.5.4

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.
@@ -65,6 +65,7 @@ First-ever-session detail, one row per packed chunk.
65
65
  | tech | `load` (src client) | `secs`: from the client starting to the engine starting (first client generation only) |
66
66
  | tech | `error` | Server or client (src). `msg` (first 300 chars, player names replaced by `<player>`), `n` (times in the window). At most 10 per tech tick (server), 5 per 30 s and 100 per session (client) |
67
67
  | tech | `start` | First generation of a server that runs the engine: `gen`, `kernel`, `load?` |
68
+ | tech | `assets` | Each generation whose artifact has hot assets (or an unusable manifest), at its start: `gen`, `boot` (the server's first generation), `manifest` (`ok`, `invalid`, `unread`), `place?` (`same`: the manifest names this place version; `vouched`: the place's backup vouches for it; `differs`: keys without a matching baked copy load from their uploads; `unknown`), `mpv?` (the manifest's place version), `pv?` (game.PlaceVersion), `keys`, `kept`, `baked`, `loaded`, `failed`, `loading` (keys per AssetSync source), `missing` (keys with nothing live), `late` (loaded after the start-up wait), `timedOut`, `ms?`, `first?` (the first failure or manifest error) |
68
69
  | tech | `swap` / `rollback` | A generation started by a hot swap (`rollback` for rollback reasons), 5 s after it started: `reason`, `from?` (previous artifact id), `gen`, `load?`, `stop?`, `swap?` (seconds), `players` |
69
70
  | tech | `swap_out` | Just before a generation stops: `reason`, `next?` (next artifact id), `players` |
70
71
  | zone | `enter` | `zone`, `from` (previous zone or `""`) |
@@ -19,6 +19,8 @@ local _typetorch = TS.import(script, script.Parent.Parent, "typetorch")
19
19
  local closeHooksSupported = _typetorch.closeHooksSupported
20
20
  local onGenerationClose = _typetorch.onGenerationClose
21
21
  local TypeTorch = _typetorch.TypeTorch
22
+ local assetCounts = TS.import(script, script.Parent.Parent, "assets", "manifest").assetCounts
23
+ local assetReport = TS.import(script, script.Parent.Parent, "assets", "sync").assetReport
22
24
  local toBase64 = TS.import(script, script.Parent, "codec").toBase64
23
25
  local _props = TS.import(script, script.Parent, "props")
24
26
  local encodeProps = _props.encodeProps
@@ -495,7 +497,7 @@ do
495
497
  end
496
498
  function ServerAnalytics:track(player, kind, name, props)
497
499
  if not isEventName(name) then
498
- warn("[src/analytics/server.ts:471]", `[analytics] {kind} event name must be 1-64 characters: {string.sub(tostring(name), 1, 80)}`)
500
+ warn("[src/analytics/server.ts:473]", `[analytics] {kind} event name must be 1-64 characters: {string.sub(tostring(name), 1, 80)}`)
499
501
  return nil
500
502
  end
501
503
  local session = if player then self:sessionOf(player) else nil
@@ -603,7 +605,7 @@ do
603
605
  end
604
606
  local control = _condition
605
607
  if not isExperimentName(name) or not isVariantList(variants) then
606
- warn("[src/analytics/server.ts:523]", `[analytics] experiment("{string.sub(tostring(name), 1, 64)}"): bad name or variants (1-16 distinct names)`)
608
+ warn("[src/analytics/server.ts:525]", `[analytics] experiment("{string.sub(tostring(name), 1, 64)}"): bad name or variants (1-16 distinct names)`)
607
609
  return control
608
610
  end
609
611
  local session = self:sessionOf(player)
@@ -668,7 +670,7 @@ do
668
670
  function ServerAnalytics:applySettings(parsed)
669
671
  local errorsText = table.concat(parsed.errors, "; ")
670
672
  if errorsText ~= table.concat(self.settingsErrors, "; ") and errorsText ~= "" then
671
- warn("[src/analytics/server.ts:558]", `[analytics] the sink settings (settings.backend or settings.analytics): {errorsText}`)
673
+ warn("[src/analytics/server.ts:560]", `[analytics] the sink settings (settings.backend or settings.analytics): {errorsText}`)
672
674
  end
673
675
  self.settingsErrors = parsed.errors
674
676
  local hadSettings = self.settings ~= nil
@@ -698,7 +700,7 @@ do
698
700
  -- `new AnalyticsEngine({ settings })`.
699
701
  if not settingsSupported() then
700
702
  if self.kernel ~= nil and self.kernel.test ~= true then
701
- warn("[src/analytics/server.ts:585]", `[analytics] no sink settings: they come from the signed settings record (kernel 0.3.8+, "typetorch backend setup"); this server runs kernel {self.kernel.kernelVersion}. Or pass new AnalyticsEngine(\{ settings \})`)
703
+ warn("[src/analytics/server.ts:587]", `[analytics] no sink settings: they come from the signed settings record (kernel 0.3.8+, "typetorch backend setup"); this server runs kernel {self.kernel.kernelVersion}. Or pass new AnalyticsEngine(\{ settings \})`)
702
704
  end
703
705
  self.settingsTried = true
704
706
  return nil
@@ -1227,6 +1229,20 @@ do
1227
1229
  load = start.loadSeconds,
1228
1230
  }))
1229
1231
  end
1232
+ -- Hot assets: how this generation's AssetSync went (it ran before the modules loaded), so "every new server
1233
+ -- loads its hot assets from uploads" shows in the events table (2026-10-13 incident).
1234
+ local assets = assetReport()
1235
+ if assets.manifest ~= "none" or #assets.errors > 0 then
1236
+ local _self = self
1237
+ local _object = {
1238
+ gen = generation,
1239
+ boot = start.kind ~= "swap",
1240
+ }
1241
+ for _k, _v in assetCounts(assets) do
1242
+ _object[_k] = _v
1243
+ end
1244
+ _self:emit(nil, "tech", "assets", encodeProps(_object))
1245
+ end
1230
1246
  end
1231
1247
  self.trove:add(TypeTorch:onSwapOut(function(info)
1232
1248
  local _self = self
@@ -1500,7 +1516,7 @@ do
1500
1516
  end
1501
1517
  end)
1502
1518
  if not ok then
1503
- warn("[src/analytics/server.ts:1078]", `[analytics] client message failed: {string.sub(tostring(err), 1, 200)}`)
1519
+ warn("[src/analytics/server.ts:1086]", `[analytics] client message failed: {string.sub(tostring(err), 1, 200)}`)
1504
1520
  end
1505
1521
  end)
1506
1522
  self.trove:add(function()
@@ -1783,7 +1799,7 @@ do
1783
1799
  self.lastWarnAt = now
1784
1800
  local more = if self.suppressed > 0 then ` (+{self.suppressed} more in the last minute)` else ""
1785
1801
  self.suppressed = 0
1786
- warn("[src/analytics/server.ts:1273]", `[analytics] {message}{more}`)
1802
+ warn("[src/analytics/server.ts:1281]", `[analytics] {message}{more}`)
1787
1803
  return true
1788
1804
  end
1789
1805
  function ServerAnalytics:noteSuccess()
@@ -13,5 +13,13 @@ export declare function resolveParent(path: string, create: boolean): Instance |
13
13
  * cloning a UI template into PlayerGui) keep the tag and the attributes but live elsewhere, so they never count.
14
14
  */
15
15
  export declare function isLiveCopy(instance: Instance): boolean;
16
+ /**
17
+ * Copies the CollectionService tags of `from` and its descendants onto the instances at the same place in `to` (matched
18
+ * by name, and by order among siblings of the same name); AssetSync's own `__typetorch_asset:` tags stay out. A loaded
19
+ * hot-asset version arrives without the tags its place copy has (2026-10-13: a game that finds its UI by tag lost it
20
+ * on every server that loaded the uploads), so the load gives them back from the place's copy before it goes live.
21
+ * Returns how many tags were added.
22
+ */
23
+ export declare function restoreTags(from: Instance, to: Instance): number;
16
24
  /** The live copies tagged for `key` (normally one; two for a moment while a new version replaces the old). */
17
25
  export declare function liveCopies(key: string): Instance[];
@@ -2,6 +2,7 @@
2
2
  local TS = _G[script]
3
3
  local CollectionService = TS.import(script, TS.getModule(script, "@rbxts", "services")).CollectionService
4
4
  local _manifest = TS.import(script, script.Parent, "manifest")
5
+ local ASSET_TAG_PREFIX = _manifest.ASSET_TAG_PREFIX
5
6
  local assetTag = _manifest.assetTag
6
7
  local ATTR_PATH = _manifest.ATTR_PATH
7
8
  local pathSegments = _manifest.pathSegments
@@ -73,6 +74,68 @@ local function isLiveCopy(instance)
73
74
  local parent = instance.Parent
74
75
  return path ~= nil and parent ~= nil and resolveParent(path, false) == parent
75
76
  end
77
+ --[[
78
+ *
79
+ * Copies the CollectionService tags of `from` and its descendants onto the instances at the same place in `to` (matched
80
+ * by name, and by order among siblings of the same name); AssetSync's own `__typetorch_asset:` tags stay out. A loaded
81
+ * hot-asset version arrives without the tags its place copy has (2026-10-13: a game that finds its UI by tag lost it
82
+ * on every server that loaded the uploads), so the load gives them back from the place's copy before it goes live.
83
+ * Returns how many tags were added.
84
+
85
+ ]]
86
+ local function restoreTags(from, to)
87
+ local added = 0
88
+ local visit
89
+ visit = function(source, target)
90
+ for _, tag in source:GetTags() do
91
+ local _arg1 = #ASSET_TAG_PREFIX
92
+ local _condition = string.sub(tag, 1, _arg1) == ASSET_TAG_PREFIX
93
+ if not _condition then
94
+ _condition = target:HasTag(tag)
95
+ end
96
+ if _condition then
97
+ continue
98
+ end
99
+ target:AddTag(tag)
100
+ added += 1
101
+ end
102
+ local byName = {}
103
+ for _, child in target:GetChildren() do
104
+ local _name = child.Name
105
+ local list = byName[_name]
106
+ if list == nil then
107
+ local _name_1 = child.Name
108
+ local _arg1 = { child }
109
+ byName[_name_1] = _arg1
110
+ else
111
+ table.insert(list, child)
112
+ end
113
+ end
114
+ local seen = {}
115
+ for _, child in source:GetChildren() do
116
+ local _name = child.Name
117
+ local _condition = seen[_name]
118
+ if _condition == nil then
119
+ _condition = 0
120
+ end
121
+ local index = _condition
122
+ local _name_1 = child.Name
123
+ local _arg1 = index + 1
124
+ seen[_name_1] = _arg1
125
+ local _name_2 = child.Name
126
+ local _match = byName[_name_2]
127
+ if _match ~= nil then
128
+ _match = _match[index + 1]
129
+ end
130
+ local match = _match
131
+ if match ~= nil then
132
+ visit(child, match)
133
+ end
134
+ end
135
+ end
136
+ visit(from, to)
137
+ return added
138
+ end
76
139
  --* The live copies tagged for `key` (normally one; two for a moment while a new version replaces the old).
77
140
  local function liveCopies(key)
78
141
  local _exp = CollectionService:GetTagged(assetTag(key))
@@ -93,5 +156,6 @@ return {
93
156
  stringAttribute = stringAttribute,
94
157
  resolveParent = resolveParent,
95
158
  isLiveCopy = isLiveCopy,
159
+ restoreTags = restoreTags,
96
160
  liveCopies = liveCopies,
97
161
  }
@@ -23,6 +23,14 @@ export declare const ATTR_VERSION = "TypeTorchAssetVersion";
23
23
  * a template are tagged too: only an instance whose Parent is still this path counts as the live one.
24
24
  */
25
25
  export declare const ATTR_PATH = "TypeTorchAssetPath";
26
+ /**
27
+ * The place's backup build (ServerStorage.TypeTorchBackup) vouches for the place's hot-asset copies after the CLI's
28
+ * backup refresh published the place (cli commands/backup.ts, kernelpatch-task step 4b): `HotAssetsPlaceVersion` is the
29
+ * lockfile placeVersion the copies match, `HotAssetsSavedAs` the place version that save became.
30
+ */
31
+ export declare const BACKUP_NAME = "TypeTorchBackup";
32
+ export declare const ATTR_BACKUP_PLACE_VERSION = "HotAssetsPlaceVersion";
33
+ export declare const ATTR_BACKUP_SAVED_AS = "HotAssetsSavedAs";
26
34
  export declare const MAX_KEY_LENGTH = 64;
27
35
  export type AssetRealm = "replicated" | "server";
28
36
  /** One manifest entry (one key), validated. */
@@ -95,6 +103,20 @@ export interface AssetDecision {
95
103
  */
96
104
  fallbackBaked?: number;
97
105
  }
106
+ /**
107
+ * Whether this server's place holds exactly the copies the manifest was exported from:
108
+ * - same: the manifest names this place version;
109
+ * - vouched: it names another one, but the place's backup says this version is a save of that one that changed nothing
110
+ * outside the kernel slots (`backup` = its HotAssetsPlaceVersion / HotAssetsSavedAs);
111
+ * - differs: neither (new servers load every key whose baked copy has no matching hash);
112
+ * - unknown: the manifest names no place version.
113
+ */
114
+ export type PlaceMatch = "same" | "vouched" | "differs" | "unknown";
115
+ export interface BackupVouch {
116
+ placeVersion?: number;
117
+ savedAs?: number;
118
+ }
119
+ export declare function placeMatchesManifest(manifestVersion: number | undefined, placeVersion: number, backup?: BackupVouch): PlaceMatch;
98
120
  /**
99
121
  * What AssetSync does for one key:
100
122
  * 1. keep the tagged copy whose TypeTorchAssetVersion is the manifest's `ver` (the newest one if several);
@@ -122,12 +144,19 @@ export interface AssetStatus {
122
144
  ms?: number;
123
145
  /** Loaded after the start-up wait ran out. */
124
146
  late?: boolean;
147
+ /** CollectionService tags a loaded copy got back from the place's copy (restoreTags). */
148
+ tags?: number;
125
149
  }
126
150
  export interface AssetSyncReport {
127
151
  /** none: the artifact has no Assets attribute; invalid: unusable; unread: reading it failed (see errors). */
128
152
  manifest: "none" | "ok" | "invalid" | "unread";
129
153
  /** Where the manifest was found: "mount", "Server" (the payload's Server folder) or "payload" (re-read). */
130
154
  from?: string;
155
+ /** placeMatchesManifest: whether the place's copies are the manifest's ("differs": the upload path). */
156
+ place?: PlaceMatch;
157
+ /** The manifest's placeVersion and this server's game.PlaceVersion. */
158
+ manifestPlaceVersion?: number;
159
+ placeVersion?: number;
131
160
  errors: string[];
132
161
  /** AssetSync is still holding the generation's start. */
133
162
  running: boolean;
@@ -138,6 +167,8 @@ export interface AssetSyncReport {
138
167
  /** Tagged keys this manifest doesn't name: left as they are. */
139
168
  unmanaged: string[];
140
169
  }
170
+ /** Keys per source, and the rest of what the `tech` / `assets` analytics row carries (SCHEMA.md). */
171
+ export declare function assetCounts(report: AssetSyncReport): Record<string, unknown>;
141
172
  /** What Server > Status needs (devtools ServerFacts.assets). */
142
173
  export interface AssetFacts {
143
174
  failed: number;
@@ -28,6 +28,16 @@ local ATTR_VERSION = "TypeTorchAssetVersion"
28
28
 
29
29
  ]]
30
30
  local ATTR_PATH = "TypeTorchAssetPath"
31
+ --[[
32
+ *
33
+ * The place's backup build (ServerStorage.TypeTorchBackup) vouches for the place's hot-asset copies after the CLI's
34
+ * backup refresh published the place (cli commands/backup.ts, kernelpatch-task step 4b): `HotAssetsPlaceVersion` is the
35
+ * lockfile placeVersion the copies match, `HotAssetsSavedAs` the place version that save became.
36
+
37
+ ]]
38
+ local BACKUP_NAME = "TypeTorchBackup"
39
+ local ATTR_BACKUP_PLACE_VERSION = "HotAssetsPlaceVersion"
40
+ local ATTR_BACKUP_SAVED_AS = "HotAssetsSavedAs"
31
41
  local MAX_KEY_LENGTH = 64
32
42
  local MAX_ASSETS = 1000
33
43
  local MAX_ERRORS = 20
@@ -255,6 +265,28 @@ end
255
265
  -- The decision --------------------------------------------------------------------------------------------------------
256
266
  --* A live (tagged) copy of the key: its TypeTorchAssetVersion. Only copies whose Parent is their own path count.
257
267
  --* An untagged copy of the key under the entry's path (the place's baked copy): its TypeTorchAssetHash.
268
+ --[[
269
+ *
270
+ * Whether this server's place holds exactly the copies the manifest was exported from:
271
+ * - same: the manifest names this place version;
272
+ * - vouched: it names another one, but the place's backup says this version is a save of that one that changed nothing
273
+ * outside the kernel slots (`backup` = its HotAssetsPlaceVersion / HotAssetsSavedAs);
274
+ * - differs: neither (new servers load every key whose baked copy has no matching hash);
275
+ * - unknown: the manifest names no place version.
276
+
277
+ ]]
278
+ local function placeMatchesManifest(manifestVersion, placeVersion, backup)
279
+ if manifestVersion == nil then
280
+ return "unknown"
281
+ end
282
+ if manifestVersion == placeVersion then
283
+ return "same"
284
+ end
285
+ if backup ~= nil and backup.placeVersion == manifestVersion and backup.savedAs == placeVersion then
286
+ return "vouched"
287
+ end
288
+ return "differs"
289
+ end
258
290
  local function indexes(count, except)
259
291
  local list = {}
260
292
  do
@@ -328,6 +360,61 @@ local function decideAsset(entry, tagged, baked, placeMatches)
328
360
  end
329
361
  -- Status (devtools op "assets", Server > Status) ------------------------------------------------------------------------
330
362
  --* kept: already live with the manifest version; baked: the place's copy adopted; loaded; failed (see `error`).
363
+ --* Keys per source, and the rest of what the `tech` / `assets` analytics row carries (SCHEMA.md).
364
+ local function assetCounts(report)
365
+ local counts = {
366
+ kept = 0,
367
+ baked = 0,
368
+ loaded = 0,
369
+ failed = 0,
370
+ loading = 0,
371
+ }
372
+ local missing = 0
373
+ local late = 0
374
+ local first
375
+ for _, status in report.entries do
376
+ local _condition = counts[status.source]
377
+ if _condition == nil then
378
+ _condition = 0
379
+ end
380
+ counts[status.source] = _condition + 1
381
+ if not status.live then
382
+ missing += 1
383
+ end
384
+ if status.late == true then
385
+ late += 1
386
+ end
387
+ if status.source == "failed" and first == nil then
388
+ local _exp = status.key
389
+ local _condition_1 = status.error
390
+ if _condition_1 == nil then
391
+ _condition_1 = "failed"
392
+ end
393
+ first = `{_exp}: {_condition_1}`
394
+ end
395
+ end
396
+ local _object = {
397
+ manifest = report.manifest,
398
+ place = report.place,
399
+ mpv = report.manifestPlaceVersion,
400
+ pv = report.placeVersion,
401
+ keys = #report.entries,
402
+ }
403
+ for _k, _v in counts do
404
+ _object[_k] = _v
405
+ end
406
+ _object.missing = missing
407
+ _object.late = late
408
+ _object.timedOut = report.timedOut == true
409
+ _object.ms = report.ms
410
+ local _left = "first"
411
+ local _condition = first
412
+ if _condition == nil then
413
+ _condition = report.errors[1]
414
+ end
415
+ _object[_left] = _condition
416
+ return _object
417
+ end
331
418
  --* What Server > Status needs (devtools ServerFacts.assets).
332
419
  local function summarizeAssets(report)
333
420
  if report.manifest == "none" and #report.errors == 0 then
@@ -368,7 +455,9 @@ return {
368
455
  pathSegments = pathSegments,
369
456
  realmOfPath = realmOfPath,
370
457
  parseManifest = parseManifest,
458
+ placeMatchesManifest = placeMatchesManifest,
371
459
  decideAsset = decideAsset,
460
+ assetCounts = assetCounts,
372
461
  summarizeAssets = summarizeAssets,
373
462
  ASSET_TAG_PREFIX = ASSET_TAG_PREFIX,
374
463
  ATTR_KEY = ATTR_KEY,
@@ -376,5 +465,8 @@ return {
376
465
  ATTR_HASH = ATTR_HASH,
377
466
  ATTR_VERSION = ATTR_VERSION,
378
467
  ATTR_PATH = ATTR_PATH,
468
+ BACKUP_NAME = BACKUP_NAME,
469
+ ATTR_BACKUP_PLACE_VERSION = ATTR_BACKUP_PLACE_VERSION,
470
+ ATTR_BACKUP_SAVED_AS = ATTR_BACKUP_SAVED_AS,
379
471
  MAX_KEY_LENGTH = MAX_KEY_LENGTH,
380
472
  }
@@ -4,10 +4,12 @@ local _services = TS.import(script, TS.getModule(script, "@rbxts", "services"))
4
4
  local CollectionService = _services.CollectionService
5
5
  local HttpService = _services.HttpService
6
6
  local InsertService = _services.InsertService
7
+ local ServerStorage = _services.ServerStorage
7
8
  local _dom = TS.import(script, script.Parent, "dom")
8
9
  local liveCopies = _dom.liveCopies
9
10
  local numberAttribute = _dom.numberAttribute
10
11
  local resolveParent = _dom.resolveParent
12
+ local restoreTags = _dom.restoreTags
11
13
  local stringAttribute = _dom.stringAttribute
12
14
  local _manifest = TS.import(script, script.Parent, "manifest")
13
15
  local assetTag = _manifest.assetTag
@@ -15,10 +17,14 @@ local ATTR_HASH = _manifest.ATTR_HASH
15
17
  local ATTR_ID = _manifest.ATTR_ID
16
18
  local ATTR_KEY = _manifest.ATTR_KEY
17
19
  local ATTR_PATH = _manifest.ATTR_PATH
20
+ local ATTR_BACKUP_PLACE_VERSION = _manifest.ATTR_BACKUP_PLACE_VERSION
21
+ local ATTR_BACKUP_SAVED_AS = _manifest.ATTR_BACKUP_SAVED_AS
18
22
  local ATTR_VERSION = _manifest.ATTR_VERSION
23
+ local BACKUP_NAME = _manifest.BACKUP_NAME
19
24
  local decideAsset = _manifest.decideAsset
20
25
  local keyOfTag = _manifest.keyOfTag
21
26
  local parseManifest = _manifest.parseManifest
27
+ local placeMatchesManifest = _manifest.placeMatchesManifest
22
28
  local summarizeAssets = _manifest.summarizeAssets
23
29
  --[[
24
30
  *
@@ -217,6 +223,17 @@ local function readManifest(kernel, deadline)
217
223
  from = "payload",
218
224
  }
219
225
  end
226
+ --* What the place's backup build says about its hot-asset copies (BackupVouch), if anything.
227
+ local function backupVouch()
228
+ local backup = ServerStorage:FindFirstChild(BACKUP_NAME)
229
+ if backup == nil then
230
+ return nil
231
+ end
232
+ return {
233
+ placeVersion = numberAttribute(backup, ATTR_BACKUP_PLACE_VERSION),
234
+ savedAs = numberAttribute(backup, ATTR_BACKUP_SAVED_AS),
235
+ }
236
+ end
220
237
  --* Marks `instance` as the live copy of `entry` (attributes first, so they replicate with the tag).
221
238
  local function stamp(instance, entry, version)
222
239
  instance:SetAttribute(ATTR_KEY, entry.key)
@@ -283,7 +300,7 @@ local function loadCopy(entry)
283
300
  container:Destroy()
284
301
  local removed = stripScripts(root)
285
302
  if removed > 0 then
286
- warn("[src/assets/sync.ts:204]", `hot asset {entry.key}: removed {removed} scripts`)
303
+ warn("[src/assets/sync.ts:215]", `hot asset {entry.key}: removed {removed} scripts`)
287
304
  end
288
305
  return root
289
306
  end
@@ -393,6 +410,50 @@ local function syncEntry(entry, status, placeMatches, ownEpoch, threads)
393
410
  if target == nil then
394
411
  error(`no {string.split(entry.path, "/")[1]} service`, 0)
395
412
  end
413
+ -- The uploaded version has no CollectionService tags: the place's copy (on a hot swap, the copy being
414
+ -- replaced, which got them the same way) gives them back (restoreTags).
415
+ local _result_1
416
+ if fallback ~= nil and fallback.Parent ~= nil then
417
+ _result_1 = fallback
418
+ else
419
+ -- ▼ ReadonlyArray.find ▼
420
+ local _callback_2 = function(candidate)
421
+ return candidate.Parent ~= nil
422
+ end
423
+ local _result_2
424
+ for _i, _v in baked do
425
+ if _callback_2(_v, _i - 1, baked) == true then
426
+ _result_2 = _v
427
+ break
428
+ end
429
+ end
430
+ -- ▲ ReadonlyArray.find ▲
431
+ _result_1 = _result_2
432
+ end
433
+ local source = _result_1
434
+ do
435
+ local index = #tagged - 1
436
+ local _shouldIncrement = false
437
+ while true do
438
+ if _shouldIncrement then
439
+ index -= 1
440
+ else
441
+ _shouldIncrement = true
442
+ end
443
+ if not (source == nil and index >= 0) then
444
+ break
445
+ end
446
+ if tagged[index + 1].Parent ~= nil then
447
+ source = tagged[index + 1]
448
+ end
449
+ end
450
+ end
451
+ if source ~= nil then
452
+ local restored = restoreTags(source, copy)
453
+ if restored > 0 then
454
+ status.tags = restored
455
+ end
456
+ end
396
457
  stamp(copy, entry, entry.ver)
397
458
  copy.Parent = target
398
459
  end)
@@ -409,7 +470,7 @@ local function syncEntry(entry, status, placeMatches, ownEpoch, threads)
409
470
  if _condition == nil then
410
471
  _condition = entry.ver
411
472
  end
412
- warn("[src/assets/sync.ts:287]", `hot asset {_exp_1} (version {_condition}) failed: {reason}`)
473
+ warn("[src/assets/sync.ts:308]", `hot asset {_exp_1} (version {_condition}) failed: {reason}`)
413
474
  return nil
414
475
  end
415
476
  -- The new copy is live: the copies it replaces go.
@@ -422,7 +483,7 @@ local function syncEntry(entry, status, placeMatches, ownEpoch, threads)
422
483
  status.ms = math.floor((os.clock() - started) * 1000)
423
484
  if late then
424
485
  status.late = true
425
- print("[src/assets/sync.ts:300]", `hot asset {entry.key} loaded late ({status.ms} ms)`)
486
+ print("[src/assets/sync.ts:321]", `hot asset {entry.key} loaded late ({status.ms} ms)`)
426
487
  end
427
488
  end)
428
489
  table.insert(_threads, _arg0)
@@ -471,7 +532,16 @@ local function run(kernel, deadline, ownEpoch, threads)
471
532
  end
472
533
  setManifest(manifest)
473
534
  report.manifest = "ok"
474
- local placeMatches = manifest.placeVersion ~= nil and manifest.placeVersion == game.PlaceVersion
535
+ -- A publish that changed nothing outside the kernel slots (the CLI's backup refresh) doesn't move servers to the
536
+ -- upload path: the backup vouches for the version it saved (2026-10-13 incident).
537
+ local place = placeMatchesManifest(manifest.placeVersion, game.PlaceVersion, backupVouch())
538
+ report.place = place
539
+ report.manifestPlaceVersion = manifest.placeVersion
540
+ report.placeVersion = game.PlaceVersion
541
+ local placeMatches = place == "same" or place == "vouched"
542
+ if place == "differs" then
543
+ warn("[src/assets/sync.ts:366]", `hot assets: the manifest is for place v{manifest.placeVersion}, this server runs v{game.PlaceVersion}: keys without a matching baked copy load from their uploaded versions (typetorch assets sync --deploy <branch> fixes it)`)
544
+ end
475
545
  local loads = {}
476
546
  for _, entry in manifest.entries do
477
547
  local status = {
@@ -490,7 +560,7 @@ local function run(kernel, deadline, ownEpoch, threads)
490
560
  if not ok then
491
561
  status.source = "failed"
492
562
  status.error = shortError(result)
493
- warn("[src/assets/sync.ts:347]", `hot asset {entry.key} failed: {status.error}`)
563
+ warn("[src/assets/sync.ts:379]", `hot asset {entry.key} failed: {status.error}`)
494
564
  elseif result ~= nil then
495
565
  table.insert(loads, result)
496
566
  end
@@ -524,7 +594,7 @@ local function run(kernel, deadline, ownEpoch, threads)
524
594
  end
525
595
  report.timedOut = true
526
596
  load.fail("timed out")
527
- warn("[src/assets/sync.ts:356]", `hot asset {load.status.key}: still loading; the old copy stays until it finishes`)
597
+ warn("[src/assets/sync.ts:388]", `hot asset {load.status.key}: still loading; the old copy stays until it finishes`)
528
598
  end
529
599
  report.unmanaged = unmanagedKeys()
530
600
  end
@@ -571,7 +641,7 @@ local function syncHotAssets(kernel, trove)
571
641
  local _errors = report.errors
572
642
  local _arg0 = `AssetSync failed: {shortError(err)}`
573
643
  table.insert(_errors, _arg0)
574
- warn("[src/assets/sync.ts:385]", `AssetSync failed: {err}`)
644
+ warn("[src/assets/sync.ts:417]", `AssetSync failed: {err}`)
575
645
  end
576
646
  report.running = false
577
647
  report.ms = math.floor((os.clock() - started) * 1000)
@@ -592,7 +662,7 @@ local function syncHotAssets(kernel, trove)
592
662
  table.insert(parts, _arg0)
593
663
  end
594
664
  table.sort(parts)
595
- print("[src/assets/sync.ts:395]", `hot assets: {table.concat(parts, ", ")} in {report.ms} ms`)
665
+ print("[src/assets/sync.ts:427]", `hot assets: {table.concat(parts, ", ")} in {report.ms} ms (place {report.place or "?"})`)
596
666
  end
597
667
  end
598
668
  return {
@@ -63,6 +63,19 @@ local function drawServer(target, report)
63
63
  target:field("Manifest", if ok then `{#report.entries} keys{if report.from ~= nil then ` ({report.from})` else ""}` else report.manifest, if ok then COLORS.text else COLORS.warn)
64
64
  local sync = if report.running then "Running" elseif report.ms ~= nil then `{report.ms} ms{if report.timedOut == true then " timed out" else ""}` else "-"
65
65
  target:field("Sync", sync, if report.timedOut == true then COLORS.warn else COLORS.text)
66
+ if report.place ~= nil then
67
+ local _condition = report.manifestPlaceVersion
68
+ if _condition == nil then
69
+ _condition = "?"
70
+ end
71
+ local _condition_1 = report.placeVersion
72
+ if _condition_1 == nil then
73
+ _condition_1 = "?"
74
+ end
75
+ local versions = `manifest v{_condition}, place v{_condition_1}`
76
+ local place = if report.place == "differs" then `differs ({versions}): keys load from uploads` elseif report.place == "vouched" then `vouched by the backup ({versions})` else report.place
77
+ target:field("Place", place, if report.place == "differs" then COLORS.warn else COLORS.text)
78
+ end
66
79
  for _, why in report.errors do
67
80
  target:text(why, COLORS.warn)
68
81
  end
@@ -188,7 +201,7 @@ local function renderAssetsTab(tab, options)
188
201
  end
189
202
  end)
190
203
  if not ok then
191
- warn("[src/devtools/assets-ui.ts:138]", `[devtools] assets refresh failed: {err}`)
204
+ warn("[src/devtools/assets-ui.ts:143]", `[devtools] assets refresh failed: {err}`)
192
205
  end
193
206
  task.wait(REFRESH)
194
207
  end
@@ -2,7 +2,7 @@ import type { AnalyticsStats } from "../analytics/schema";
2
2
  import type { AssetFacts } from "../assets/manifest";
3
3
  import type { DetachedStatus, ErrorReportsStatus, FleetSenderInfo, HealthInfo, KernelStatus, PerfSummary, SettingsStatus } from "../kernel";
4
4
  /** The newest kernel this framework release knows about. Bump it with every kernel release. */
5
- export declare const LATEST_KERNEL = "0.5.1";
5
+ export declare const LATEST_KERNEL = "0.5.3";
6
6
  /**
7
7
  * The live health window while it is open (kernel 0.3.7 thresholds; older kernels: 3): "1/3 errors, 24 s left", plus
8
8
  * ", no rollback" when a failure would only mark the server degraded. Undefined once the window closed.
@@ -2,7 +2,7 @@
2
2
  local TS = _G[script]
3
3
  local describeFleetError = TS.import(script, script.Parent.Parent, "analytics", "hints").describeFleetError
4
4
  --* The newest kernel this framework release knows about. Bump it with every kernel release.
5
- local LATEST_KERNEL = "0.5.1"
5
+ local LATEST_KERNEL = "0.5.3"
6
6
  --* Kernels before 0.3.7 use fixed thresholds: 3 errors within 30 s of ready roll back.
7
7
  local OLD_HEALTH_ERRORS = 3
8
8
  local OLD_HEALTH_WINDOW = 30
package/out/version.d.ts CHANGED
@@ -2,4 +2,4 @@
2
2
  * This framework's version, as package.json says (scripts/test-state-inspect.luau checks they match). Shown in
3
3
  * uploaded log headers (Logs > Upload); bump it with package.json.
4
4
  */
5
- export declare const FRAMEWORK_VERSION = "0.5.2";
5
+ export declare const FRAMEWORK_VERSION = "0.5.4";
package/out/version.luau CHANGED
@@ -5,7 +5,7 @@
5
5
  * uploaded log headers (Logs > Upload); bump it with package.json.
6
6
 
7
7
  ]]
8
- local FRAMEWORK_VERSION = "0.5.2"
8
+ local FRAMEWORK_VERSION = "0.5.4"
9
9
  return {
10
10
  FRAMEWORK_VERSION = FRAMEWORK_VERSION,
11
11
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typetorch/framework",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "TypeTorch framework for roblox-ts: hot-swappable modules (dependency injection, lifecycle, troves), guarded networking, hot assets and the in-game dev menu. Ships inside every TypeTorch artifact.",
5
5
  "license": "MIT",
6
6
  "author": "TypeTorch",