@rubytech/create-sitedesk-code 0.1.508 → 0.1.509

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 (65) hide show
  1. package/dist/index.js +53 -0
  2. package/package.json +1 -1
  3. package/payload/platform/docs/superpowers/plans/2026-07-26-task-1926-portal-push-concurrency-guard.md +482 -0
  4. package/payload/platform/docs/superpowers/specs/2026-07-26-task-1926-portal-push-concurrency-guard-design.md +200 -0
  5. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +11 -6
  6. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +6 -0
  7. package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +68 -28
  8. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +271 -29
  9. package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +5 -4
  10. package/payload/platform/plugins/docs/references/admin-ui.md +10 -5
  11. package/payload/platform/scripts/__tests__/check-brand-cache.test.sh +231 -0
  12. package/payload/platform/scripts/check-brand-cache.sh +138 -0
  13. package/payload/server/maxy-edge.js +15 -10
  14. package/payload/server/public/activity.html +5 -5
  15. package/payload/server/public/agents.html +4 -4
  16. package/payload/server/public/assets/{AdminLoginScreens-DKeCt1uf.js → AdminLoginScreens-8Jk7hnWH.js} +1 -1
  17. package/payload/server/public/assets/{AdminShell-HeELCCjJ.js → AdminShell-BMPEWNZk.js} +1 -1
  18. package/payload/server/public/assets/{activity-BEIDAxDA.js → activity-ByWYF5dC.js} +1 -1
  19. package/payload/server/public/assets/admin-CnUWbKcx.js +1 -0
  20. package/payload/server/public/assets/agents-C0rTfrYd.js +1 -0
  21. package/payload/server/public/assets/{browser-GkVTGqlb.js → browser-4tTaem8N.js} +1 -1
  22. package/payload/server/public/assets/{calendar-CSqJeFEg.js → calendar-BEjnkKG1.js} +1 -1
  23. package/payload/server/public/assets/chat-DBgzjDIE.js +1 -0
  24. package/payload/server/public/assets/chevron-left-DqYv3oFh.js +1 -0
  25. package/payload/server/public/assets/chevron-right-CQfPGsFb.js +1 -0
  26. package/payload/server/public/assets/clock-Dn6FHB51.js +1 -0
  27. package/payload/server/public/assets/data-D_e6Vdfd.js +1 -0
  28. package/payload/server/public/assets/{file-text-DiscM2pP.js → file-text-DMKw3nIk.js} +1 -1
  29. package/payload/server/public/assets/{graph-CRP5DM8C.js → graph-C3vW8lvw.js} +1 -1
  30. package/payload/server/public/assets/{graph-labels-Du-6KoBE.js → graph-labels-BZgd0L6n.js} +1 -1
  31. package/payload/server/public/assets/{maximize-2-D-H_uIV-.js → maximize-2-la3IBA3M.js} +1 -1
  32. package/payload/server/public/assets/{operator-D5rA76YY.js → operator-RRRdqoRQ.js} +1 -1
  33. package/payload/server/public/assets/{page-CHK1415C.js → page-BWFHRIAH.js} +1 -1
  34. package/payload/server/public/assets/{page-DZE3H_n4.js → page-DC11gesX.js} +1 -1
  35. package/payload/server/public/assets/{public-DFHXVkdD.js → public-DQbbLHQN.js} +1 -1
  36. package/payload/server/public/assets/{rotate-ccw-CesEJsuO.js → rotate-ccw-OQD5si8N.js} +1 -1
  37. package/payload/server/public/assets/{routines-DYMYY_r8.js → routines-asrBGZUR.js} +1 -1
  38. package/payload/server/public/assets/{skills-67jtJ16D.js → skills-D4ECwoxa.js} +1 -1
  39. package/payload/server/public/assets/{tasks-D2C2a-uk.js → tasks-U3QdbUsO.js} +1 -1
  40. package/payload/server/public/assets/{time-entry-format-BixTGQAd.js → time-entry-format-BgdTKgYr.js} +1 -1
  41. package/payload/server/public/assets/{triangle-alert-Co9RWb9q.js → triangle-alert-Ck_3VhT9.js} +1 -1
  42. package/payload/server/public/assets/{useCopyFeedback-7zwf9_w4.js → useCopyFeedback-BdUwyNpa.js} +1 -1
  43. package/payload/server/public/assets/{useSubAccountSwitcher-DE4v5_Fz.css → useSubAccountSwitcher-DOWQXDT8.css} +1 -1
  44. package/payload/server/public/assets/{useVoiceRecorder-CZDDiwMD.js → useVoiceRecorder-ChnBrzbZ.js} +1 -1
  45. package/payload/server/public/assets/{wrench-CjQqdj4P.js → wrench-Cpee3C3J.js} +1 -1
  46. package/payload/server/public/browser.html +4 -4
  47. package/payload/server/public/calendar.html +7 -7
  48. package/payload/server/public/chat.html +13 -13
  49. package/payload/server/public/data.html +11 -11
  50. package/payload/server/public/graph.html +9 -9
  51. package/payload/server/public/index.html +14 -14
  52. package/payload/server/public/operator.html +14 -14
  53. package/payload/server/public/public.html +13 -13
  54. package/payload/server/public/routines.html +6 -6
  55. package/payload/server/public/skills.html +5 -5
  56. package/payload/server/public/tasks.html +6 -6
  57. package/payload/server/server.js +364 -283
  58. package/payload/server/public/assets/admin-C0-WU1sf.js +0 -1
  59. package/payload/server/public/assets/agents-DjUvMFTj.js +0 -1
  60. package/payload/server/public/assets/chat-C0Ps84U5.js +0 -1
  61. package/payload/server/public/assets/chevron-left-DQ2uNlu9.js +0 -1
  62. package/payload/server/public/assets/chevron-right-C4QCiRGD.js +0 -1
  63. package/payload/server/public/assets/clock-BEeQ7JxX.js +0 -1
  64. package/payload/server/public/assets/data-CGuNfym7.js +0 -1
  65. /package/payload/server/public/assets/{useSubAccountSwitcher-Bg72hdQ8.js → useSubAccountSwitcher-CJKjEPNN.js} +0 -0
@@ -45,10 +45,20 @@ afterEach(() => rmSync(dir, { recursive: true, force: true }))
45
45
  function fakeClient() {
46
46
  const calls: { sql: string; params: unknown[] }[] = []
47
47
  const rows = new Set<string>()
48
+ // The pointer is modelled because the flip is read back (Task 1926): a client
49
+ // that always answers "no pointer" would make every push read as superseded.
50
+ let pointer: number | null = null
48
51
  return {
49
52
  calls,
50
53
  async query(sql: string, params: unknown[] = []) {
51
54
  calls.push({ sql, params })
55
+ if (/SELECT\s+currentGeneration/i.test(sql)) {
56
+ return pointer === null ? [] : [{ currentGeneration: pointer }]
57
+ }
58
+ if (/INSERT INTO directory_state/i.test(sql)) {
59
+ pointer = Number(params[1])
60
+ return []
61
+ }
52
62
  // The open paren is load-bearing: `INSERT INTO directory_state` would
53
63
  // otherwise register as a row insert and inflate every count.
54
64
  if (/INSERT INTO directory \(/i.test(sql)) {
@@ -308,15 +318,24 @@ function generationClient() {
308
318
  })
309
319
  return []
310
320
  }
321
+ if (/SELECT\s+MAX\(generation\)/i.test(sql)) {
322
+ const mine = rows.filter((r) => r.accountId === String(params[0]))
323
+ return [{ g: mine.length ? Math.max(...mine.map((r) => r.generation)) : null }]
324
+ }
311
325
  if (/DELETE FROM directory\b/i.test(sql)) {
312
326
  const g = Number(params[1])
313
- // Two DELETE shapes reach this client: the post-flip sweep removes
314
- // every OTHER generation (`generation != ?`); the pre-stage clear
315
- // (Task 1923) removes exactly THIS generation (`generation = ?`).
316
- if (/generation\s*!=\s*\?/i.test(sql)) {
317
- for (let i = rows.length - 1; i >= 0; i--) if (rows[i].generation !== g) rows.splice(i, 1)
318
- } else {
319
- for (let i = rows.length - 1; i >= 0; i--) if (rows[i].generation === g) rows.splice(i, 1)
327
+ // The post-flip sweep removes strictly OLDER generations
328
+ // (`generation < ?`, Task 1926). `generation != ?` is the shape it
329
+ // replaced and `generation = ?` was the Task 1923 pre-stage clear; both
330
+ // stay modelled so a regression to either shape is visible here.
331
+ for (let i = rows.length - 1; i >= 0; i--) {
332
+ const n = rows[i].generation
333
+ const hit = /generation\s*<\s*\?/i.test(sql)
334
+ ? n < g
335
+ : /generation\s*!=\s*\?/i.test(sql)
336
+ ? n !== g
337
+ : n === g
338
+ if (hit) rows.splice(i, 1)
320
339
  }
321
340
  return []
322
341
  }
@@ -374,12 +393,20 @@ function wedgeableClient(seed: { pointer: number; current?: string[]; orphans: s
374
393
  rows.push({ accountId, relPath, generation })
375
394
  return []
376
395
  }
396
+ if (/SELECT\s+MAX\(generation\)/i.test(sql)) {
397
+ const mine = rows.filter((r) => r.accountId === String(params[0]))
398
+ return [{ g: mine.length ? Math.max(...mine.map((r) => r.generation)) : null }]
399
+ }
377
400
  if (/DELETE FROM directory\b/i.test(sql)) {
378
401
  const g = Number(params[1])
379
- if (/generation\s*!=\s*\?/i.test(sql)) {
380
- for (let i = rows.length - 1; i >= 0; i--) if (rows[i].generation !== g) rows.splice(i, 1)
381
- } else {
382
- for (let i = rows.length - 1; i >= 0; i--) if (rows[i].generation === g) rows.splice(i, 1)
402
+ for (let i = rows.length - 1; i >= 0; i--) {
403
+ const n = rows[i].generation
404
+ const hit = /generation\s*<\s*\?/i.test(sql)
405
+ ? n < g
406
+ : /generation\s*!=\s*\?/i.test(sql)
407
+ ? n !== g
408
+ : n === g
409
+ if (hit) rows.splice(i, 1)
383
410
  }
384
411
  return []
385
412
  }
@@ -395,14 +422,18 @@ function wedgeableClient(seed: { pointer: number; current?: string[]; orphans: s
395
422
  }
396
423
  }
397
424
 
398
- describe('a partial stage self-heals (Task 1923)', () => {
399
- it('clears orphans at the next generation, flips onto it, and logs stage-clear', async () => {
400
- // Pointer at 5 naming the last good tree; three orphan rows already at
401
- // generation 6 for relPaths the current walk re-stages ('output' is the
402
- // walk's first row, so the current code's first INSERT is the one that
403
- // collides). Without the pre-stage clear that INSERT throws on the UNIQUE
404
- // constraint and the push fails, which is what froze gls-data for ~6 h on
405
- // 2026-07-22.
425
+ describe('a partial stage self-heals (Task 1923, by the Task 1926 generation claim)', () => {
426
+ // The wedge: a stage that died mid-batch left rows at current + 1 while the
427
+ // pointer stayed at current, so every later run recomputed the SAME target
428
+ // and its first INSERT hit UNIQUE(accountId, relPath, generation). The index
429
+ // froze at the stale generation until an operator hand-cleared the orphans,
430
+ // which is what held gls-data for ~6 h on 2026-07-22.
431
+ //
432
+ // Task 1923 healed that with a DELETE before the stage. Task 1926 heals it by
433
+ // claiming above the orphans instead, which needs no DELETE before the flip
434
+ // at all — and that pre-flip DELETE was the statement a concurrent peer could
435
+ // aim at the live tree.
436
+ it('claims above the orphans rather than colliding with them, and sweeps them after the flip', async () => {
406
437
  const c = wedgeableClient({
407
438
  pointer: 5,
408
439
  current: ['output', 'output/report.pdf', 'quotes', 'quotes/CR2969'],
@@ -412,21 +443,20 @@ describe('a partial stage self-heals (Task 1923)', () => {
412
443
  const r = await push(c, { log: (l: string) => lines.push(l) })
413
444
 
414
445
  expect(r.rows).toBe(7)
415
- expect(c.pointerValue()).toBe(6)
416
- // Nothing survives at a generation the pointer does not name.
417
- expect(c.rows.every((row) => row.generation === 6)).toBe(true)
418
- const clear = lines.find((l) => l.includes('op=stage-clear'))!
419
- expect(clear).toContain('account=acc-1')
420
- expect(clear).toContain('generation=6')
421
- // Exactly the 3 orphans at generation 6, not the 4 current-tree rows the
422
- // pointer names: the clear scopes to `next`, never to a reader-visible row.
423
- expect(clear).toContain('cleared=3')
446
+ // 7, not 6: the orphans sit at 6, so this run claims the one above them.
447
+ expect(c.pointerValue()).toBe(7)
448
+ // Nothing survives at a generation the pointer does not name: the sweep
449
+ // took both the stale tree at 5 and the orphans at 6.
450
+ expect(c.rows.every((row) => row.generation === 7)).toBe(true)
451
+ // No statement deletes anything before the flip any more.
452
+ expect(lines.some((l) => l.includes('op=stage-clear'))).toBe(false)
424
453
  })
425
454
 
426
- it('emits no stage-clear line on a clean cycle with no orphans', async () => {
455
+ it('re-stages onto a fresh generation with no orphans present', async () => {
427
456
  const c = wedgeableClient({ pointer: 5, orphans: [] })
428
457
  const lines: string[] = []
429
458
  await push(c, { log: (l: string) => lines.push(l) })
459
+ expect(c.pointerValue()).toBe(6)
430
460
  expect(lines.some((l) => l.includes('op=stage-clear'))).toBe(false)
431
461
  })
432
462
  })
@@ -505,3 +535,215 @@ describe('the index replace is atomic', () => {
505
535
  expect(c.calls.filter((x) => /INSERT|DELETE|directory_state/i.test(x.sql))).toEqual([])
506
536
  })
507
537
  })
538
+
539
+ // Task 1926. Two pushes of ONE account are reachable: the admin server's 60s
540
+ // loop (pushAllAccounts) and a hand-run `--account <id>` are separate OS
541
+ // processes calling the same pushAccount, so no in-process lock can see both.
542
+ // These tests drive both writers against ONE store and snapshot the reader's
543
+ // view after every statement, so "never a half-built tree" stays a measured
544
+ // property rather than a claim.
545
+ //
546
+ // Statement-level atomicity matches SQLite: each exec() applies whole.
547
+ function sharedStore() {
548
+ const rows: { accountId: string; relPath: string; generation: number }[] = []
549
+ let pointer: number | null = null
550
+ const pointerHistory: number[] = []
551
+ const snapshots: string[][] = []
552
+ const deletesAtPointer: number[] = []
553
+
554
+ const visible = () =>
555
+ pointer === null
556
+ ? []
557
+ : rows.filter((r) => r.generation === pointer).map((r) => r.relPath).sort()
558
+
559
+ // The three DELETE shapes this file can issue. Order matters: `!=` and `<`
560
+ // must be tested before `=`, or `generation != ?` would read as an equality.
561
+ const deleteMatcher = (sql: string, g: number) => {
562
+ if (/generation\s*<\s*\?/i.test(sql)) return (n: number) => n < g
563
+ if (/generation\s*!=\s*\?/i.test(sql)) return (n: number) => n !== g
564
+ return (n: number) => n === g
565
+ }
566
+
567
+ return {
568
+ rows,
569
+ snapshots,
570
+ deletesAtPointer,
571
+ pointerHistory,
572
+ visible,
573
+ pointerValue: () => pointer,
574
+ seed(generation: number, relPaths: string[]) {
575
+ for (const relPath of relPaths) rows.push({ accountId: 'acc-1', relPath, generation })
576
+ pointer = generation
577
+ pointerHistory.push(generation)
578
+ },
579
+ async exec(sql: string, params: unknown[] = []) {
580
+ const out = await (async () => {
581
+ if (/SELECT\s+currentGeneration/i.test(sql)) {
582
+ return pointer === null ? [] : [{ currentGeneration: pointer }]
583
+ }
584
+ if (/SELECT\s+MAX\(generation\)/i.test(sql)) {
585
+ const mine = rows.filter((r) => r.accountId === String(params[0]))
586
+ return [{ g: mine.length ? Math.max(...mine.map((r) => r.generation)) : null }]
587
+ }
588
+ if (/INSERT INTO directory_state/i.test(sql)) {
589
+ const g = Number(params[1])
590
+ // The upsert's WHERE, modelled: with the guard present the pointer
591
+ // moves forward only. Without it, every flip lands.
592
+ const forwardOnly =
593
+ /excluded\.currentGeneration\s*>\s*directory_state\.currentGeneration/i.test(sql)
594
+ if (pointer === null || !forwardOnly || g > pointer) {
595
+ pointer = g
596
+ pointerHistory.push(g)
597
+ }
598
+ return []
599
+ }
600
+ if (/INSERT INTO directory \(/i.test(sql)) {
601
+ const accountId = String(params[0])
602
+ const relPath = String(params[1])
603
+ const generation = Number(params[6])
604
+ if (
605
+ rows.some(
606
+ (r) => r.accountId === accountId && r.relPath === relPath && r.generation === generation,
607
+ )
608
+ ) {
609
+ throw new Error(
610
+ 'd1 query failed: HTTP 500 — UNIQUE constraint failed: directory.accountId, directory.relPath, directory.generation',
611
+ )
612
+ }
613
+ rows.push({ accountId, relPath, generation })
614
+ return []
615
+ }
616
+ if (/DELETE FROM directory\b/i.test(sql)) {
617
+ const g = Number(params[1])
618
+ const matches = deleteMatcher(sql, g)
619
+ // The bug's signature: a DELETE that removes rows at the generation
620
+ // the pointer names is a reader-visible deletion of the live tree.
621
+ if (pointer !== null && matches(pointer) && rows.some((r) => r.generation === pointer)) {
622
+ deletesAtPointer.push(pointer)
623
+ }
624
+ for (let i = rows.length - 1; i >= 0; i--) if (matches(rows[i].generation)) rows.splice(i, 1)
625
+ return []
626
+ }
627
+ if (/SELECT COUNT/i.test(sql)) {
628
+ if (/generation\s*=\s*\?/i.test(sql)) {
629
+ const g = Number(params[1])
630
+ return [{ n: rows.filter((r) => r.generation === g).length }]
631
+ }
632
+ return [{ n: rows.filter((r) => r.generation === pointer).length }]
633
+ }
634
+ return []
635
+ })()
636
+ snapshots.push(visible())
637
+ return out
638
+ },
639
+ }
640
+ }
641
+
642
+ /** A one-shot rendezvous: `reached` settles when the writer parks, `open()` frees it. */
643
+ function gate() {
644
+ let hit!: () => void
645
+ let open!: () => void
646
+ const reached = new Promise<void>((r) => (hit = r))
647
+ const opened = new Promise<void>((r) => (open = r))
648
+ return { reached, opened, hit: () => hit(), open: () => open() }
649
+ }
650
+
651
+ /** A client that parks once, either before or after the matching statement. */
652
+ function pausingClient(
653
+ store: ReturnType<typeof sharedStore>,
654
+ g: ReturnType<typeof gate>,
655
+ at: { before?: (sql: string) => boolean; after?: (sql: string) => boolean },
656
+ ) {
657
+ let armed = true
658
+ const calls: { sql: string; params: unknown[] }[] = []
659
+ return {
660
+ calls,
661
+ async query(sql: string, params: unknown[] = []) {
662
+ calls.push({ sql, params })
663
+ if (armed && at.before?.(sql)) {
664
+ armed = false
665
+ g.hit()
666
+ await g.opened
667
+ }
668
+ const out = await store.exec(sql, params)
669
+ if (armed && at.after?.(sql)) {
670
+ armed = false
671
+ g.hit()
672
+ await g.opened
673
+ }
674
+ return out
675
+ },
676
+ }
677
+ }
678
+
679
+ const plainClient = (store: ReturnType<typeof sharedStore>) => ({
680
+ query: (sql: string, params: unknown[] = []) => store.exec(sql, params),
681
+ })
682
+
683
+ describe('two concurrent pushes of one account (Task 1926)', () => {
684
+ // The task file's own interleave. B reads the pointer, then stalls. A stages,
685
+ // flips and sweeps a whole publish. B resumes holding a stale `current`.
686
+ //
687
+ // Before the guard: B recomputed next = current + 1 = the generation the
688
+ // pointer now names, its pre-stage COUNT returned A's live rows, and its
689
+ // DELETE emptied the tree a client was reading.
690
+ it('a peer holding a stale pointer never empties or halves the live tree', async () => {
691
+ const store = sharedStore()
692
+ store.seed(5, ['output', 'output/report.pdf', 'quotes', 'quotes/CR2969'])
693
+ const g = gate()
694
+ const b = pausingClient(store, g, { after: (sql) => /SELECT\s+currentGeneration/i.test(sql) })
695
+
696
+ // Captured rather than awaited bare: B throwing for an unrelated reason
697
+ // would otherwise surface as a passing test.
698
+ const bRun = push(b, { log: () => {} }).catch((e: unknown) => e)
699
+ await g.reached
700
+ await push(plainClient(store), { log: () => {} })
701
+ g.open()
702
+ const bResult = await bRun
703
+
704
+ expect(bResult).not.toBeInstanceOf(Error)
705
+ // 4 is the seeded tree, 7 is a complete walk of the fixture. Any other
706
+ // count is a tree a client could see half-built.
707
+ expect(store.snapshots.every((s) => s.length === 4 || s.length === 7)).toBe(true)
708
+ expect(store.deletesAtPointer).toEqual([])
709
+ expect(store.visible().length).toBe(7)
710
+ // B resumed holding a stale pointer of 5, saw A's rows at 6 in the MAX, and
711
+ // claimed 7. Nothing was staged over and nothing was deleted at a live one.
712
+ expect(store.pointerValue()).toBe(7)
713
+ })
714
+
715
+ // The other order: A stages a whole generation, then stalls before its flip.
716
+ // B starts fresh, sees A's staged rows in the MAX, claims the generation
717
+ // above, and publishes. A wakes holding a generation that is now behind.
718
+ it('a writer overtaken while staging leaves the winner live and says so', async () => {
719
+ const store = sharedStore()
720
+ store.seed(5, ['output', 'output/report.pdf', 'quotes', 'quotes/CR2969'])
721
+ const g = gate()
722
+ const a = pausingClient(store, g, { before: (sql) => /INSERT INTO directory_state/i.test(sql) })
723
+ const lines: string[] = []
724
+
725
+ const aRun = push(a, { log: (l: string) => lines.push(l) })
726
+ await g.reached
727
+ await push(plainClient(store), { log: () => {} })
728
+ g.open()
729
+ const aResult = await aRun
730
+
731
+ expect(store.snapshots.every((s) => s.length === 4 || s.length === 7)).toBe(true)
732
+ expect(store.deletesAtPointer).toEqual([])
733
+ // The pointer never goes backward, which is what keeps the loser harmless.
734
+ expect(store.pointerHistory).toEqual([...store.pointerHistory].sort((x, y) => x - y))
735
+ expect(store.pointerValue()).toBe(7)
736
+
737
+ const contended = lines.find((l) => l.includes('op=stage-contended'))!
738
+ expect(contended).toContain('account=acc-1')
739
+ expect(contended).toContain('generation=6')
740
+ expect(contended).toContain('pointer=7')
741
+ expect(contended).toContain('action=superseded')
742
+ expect(lines.some((l) => l.includes('op=flip'))).toBe(false)
743
+ // No sweep and no verify from the loser: both would act on, and measure,
744
+ // the WINNER's tree and report a failure where nothing failed.
745
+ expect(a.calls.some((c) => /DELETE FROM directory/i.test(c.sql))).toBe(false)
746
+ expect(lines.some((l) => l.includes('op=verify'))).toBe(false)
747
+ expect(aResult.superseded).toBe(true)
748
+ })
749
+ })
@@ -150,10 +150,11 @@ CREATE UNIQUE INDEX IF NOT EXISTS auth_attempts_scope_window ON auth_attempts (s
150
150
  -- No backfill follows it: the push replaces every row each cycle.
151
151
  --
152
152
  -- `generation` is which staging round a row belongs to (Task 1910). The push
153
- -- writes the next generation, flips the pointer below in ONE statement, then
154
- -- deletes rows of earlier generations. Uniqueness is scoped by generation
155
- -- because two of them hold the same relPath between the write and the flip;
156
- -- UNIQUE (accountId, relPath) would reject the staged write outright.
153
+ -- claims a generation above every generation the account already holds, writes
154
+ -- the whole tree there, moves the pointer below in ONE forward-only statement,
155
+ -- then deletes strictly older generations (Task 1926). Uniqueness is scoped by
156
+ -- generation because two of them hold the same relPath between the write and
157
+ -- the flip; UNIQUE (accountId, relPath) would reject the staged write outright.
157
158
  -- ALTER TABLE directory ADD COLUMN generation INTEGER NOT NULL DEFAULT 0;
158
159
  CREATE TABLE IF NOT EXISTS directory (
159
160
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -236,8 +236,8 @@ explicit `?plugin=` parameter, not inferred from `--` in the slug.
236
236
 
237
237
  | Route | Behaviour |
238
238
  |-------|-----------|
239
- | `GET /api/admin/agents` | Session-scoped. Lists the session account's public `agents/*/` dirs (never the `admin` agent), its user-created specialists from `plugins/*/agents/*.md`, and its shipped specialists, each row tagged `kind` and `origin` and carrying `risk`, `worstTool`, `unresolved` and `disabled`. Returns `{agents, accountId, skipped, specialistsSkipped, shippedSkipped}`. 401 when the session maps to no account. |
240
- | `GET /api/admin/agents/:slug` | Session-scoped. Without `?plugin=` or `?origin=`, returns a public agent's config fields + four owned docs + a `present` map (a missing or unreadable doc is `''`/`present.<role>=false`, never a 500). With `?plugin=`, that plugin's user-created specialist; with `?origin=shipped`, the shipped one. Both specialist shapes add `{risk, worstTool, unresolved, byTool, disabled}`, where `byTool` is one class per declared tool. Selection is explicit rather than inferred, because a premium file and a user-created specialist under a plugin of the same name produce the same slug. Unknown 404s, 401 as above. |
239
+ | `GET /api/admin/agents` | Session-scoped. Lists the session account's public `agents/*/` dirs (never the `admin` agent), its user-created specialists from `plugins/*/agents/*.md`, and its shipped specialists, each row tagged `kind` and `origin` and carrying `risk`, `riskiestTool`, `unresolved` and `disabled`. Returns `{agents, accountId, skipped, specialistsSkipped, shippedSkipped}`. 401 when the session maps to no account. |
240
+ | `GET /api/admin/agents/:slug` | Session-scoped. Without `?plugin=` or `?origin=`, returns a public agent's config fields + four owned docs + a `present` map (a missing or unreadable doc is `''`/`present.<role>=false`, never a 500). With `?plugin=`, that plugin's user-created specialist; with `?origin=shipped`, the shipped one. Both specialist shapes add `{risk, riskiestTool, unresolved, byTool, disabled}`, where `byTool` is one class per declared tool. Selection is explicit rather than inferred, because a premium file and a user-created specialist under a plugin of the same name produce the same slug. Unknown 404s, 401 as above. |
241
241
  | `POST /api/admin/agents/:slug/disable?accountId=` | Shipped agents only, enforced here: a slug present in none of the three shipped directories 404s. Moves the file from `specialists/agents/` to `specialists/agents-disabled/` and records the basename in the 0600 store. Returns `{ok, moved}`; `moved:false` means the agent existed only as a bundled template, so nothing was there to move and the store entry is what stops the next provisioning run delivering it. The store is read strictly before anything moves, so an unreadable store 500s with the file untouched rather than rewriting the file whole from an empty set and dropping every other disabled agent. Same `accountId` contract as delete. |
242
242
  | `POST /api/admin/agents/:slug/enable?accountId=` | Covers both of disable's outcomes. Returns `{ok, restored}`: a quarantined file moves back (`restored:true`); a bundled-only agent has nothing to move, so clearing the store entry is the whole job (`restored:false`), which is what stops provisioning withholding it. Treating that second case as "nothing to restore" made disable a one-way door. 404 only when neither directory nor store knows the agent. |
243
243
  | `DELETE /api/admin/agents/:slug?accountId=` | Public agents only, on the named validated account. Removes the dir after `deleteAgentProjection`; refuses the `admin` slug (403) and a missing/unknown `accountId` (400) with no write. Loud-fail: a graph-cleanup throw aborts the file removal. |
@@ -267,10 +267,15 @@ Agents page survived undiagnosed on a multi-account install.
267
267
  `reason=<graph-cleanup-failed|rm-error>` on failure (files preserved on a
268
268
  graph-cleanup throw).
269
269
  - `op=classify account=<id8> agent=<name> origin=<shipped|public|specialist>
270
- risk=<green|amber|red> worstTool=<tool> tools=<n> unresolved=<comma-list|none>
270
+ risk=<green|amber|red> riskiestTool=<tool> tools=<n> unresolved=<comma-list|none>
271
271
  disabled=<bool>` — one line per classified agent per listing, so it fires on
272
- every page load. `worstTool` makes a wrong class diagnosable without
273
- re-deriving it. **A non-empty `unresolved=` is the one to act on**: the agent
272
+ every page load. `riskiestTool` makes a wrong class diagnosable without
273
+ re-deriving it. Its value is the FULL canonical tool name, while the card
274
+ renders only the segment after the plugin prefix. One load
275
+ showing `riskiest tool: memory-update` on the card and
276
+ `riskiestTool=mcp__plugin_memory_memory__memory-update` on this line is the
277
+ confirmation that both derive from one value.
278
+ **A non-empty `unresolved=` is the one to act on**: the agent
274
279
  is forced red, and the cause is that the built-in table or the registry
275
280
  mapping has drifted, not that the agent changed.
276
281
  - `op=risk-surface status=load-failed reason=<msg>` — the plugin registry did
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env bash
2
+ # Tests for check-brand-cache.sh (Task 2019). Fixture-driven: builds a throwaway
3
+ # install dir + config dir per case and stubs the fetch command, because the real
4
+ # one crosses a Cloudflare edge that no test can hold still. sha256 and the
5
+ # ingress parse are the real code paths — those are what this asserts.
6
+ set -uo pipefail
7
+
8
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ CHECK="$SCRIPT_DIR/../check-brand-cache.sh"
10
+
11
+ PASS=0
12
+ FAIL=0
13
+
14
+ run_case() {
15
+ local name="$1"; shift
16
+ echo ""
17
+ echo "=== CASE: $name ==="
18
+ if "$@"; then
19
+ PASS=$((PASS + 1)); echo "PASS: $name"
20
+ else
21
+ FAIL=$((FAIL + 1)); echo "FAIL: $name"
22
+ fi
23
+ }
24
+
25
+ # Build install dir with two brand files and brand-defaults.css. Echoes its path.
26
+ mk_install() {
27
+ local d
28
+ d="$(mktemp -d "${TMPDIR:-/tmp}/brand-cache-install.XXXXXX")"
29
+ mkdir -p "$d/server/public/brand"
30
+ printf 'lime-logo-bytes' > "$d/server/public/brand/logo.png"
31
+ printf 'lime-icon-bytes' > "$d/server/public/brand/icon.png"
32
+ printf ':root{--brand:#c3f53c}' > "$d/server/public/brand-defaults.css"
33
+ echo "$d"
34
+ }
35
+
36
+ # Build a config dir whose cloudflared/config.yml has the given ingress hosts.
37
+ mk_config_with_hosts() {
38
+ local d; d="$(mktemp -d "${TMPDIR:-/tmp}/brand-cache-config.XXXXXX")"
39
+ mkdir -p "$d/cloudflared"
40
+ { echo "ingress:"; for h in "$@"; do echo " - hostname: $h"; done; echo " - service: http_status:404"; } \
41
+ > "$d/cloudflared/config.yml"
42
+ echo "$d"
43
+ }
44
+
45
+ mk_config_no_tunnel() {
46
+ local d; d="$(mktemp -d "${TMPDIR:-/tmp}/brand-cache-config.XXXXXX")"
47
+ echo "$d"
48
+ }
49
+
50
+ # A config dir with no cloudflared ingress but an alias-domains.json, the
51
+ # second source resolvePublicHostname reads.
52
+ mk_config_alias_only() {
53
+ local d; d="$(mktemp -d "${TMPDIR:-/tmp}/brand-cache-config.XXXXXX")"
54
+ printf '["public.realagent.app"]' > "$d/alias-domains.json"
55
+ echo "$d"
56
+ }
57
+
58
+ # Stub curl. $1 = dir whose files it serves as the "edge", keyed by basename.
59
+ # Writes headers to the -D path and the body to the -o path, exit 0.
60
+ mk_curl_stub() {
61
+ local serve_dir="$1" stub
62
+ stub="$(mktemp "${TMPDIR:-/tmp}/brand-cache-curl.XXXXXX")"
63
+ cat > "$stub" <<STUB
64
+ #!/usr/bin/env bash
65
+ hdr=""; out=""; url=""
66
+ while [ \$# -gt 0 ]; do
67
+ case "\$1" in
68
+ -D) hdr="\$2"; shift 2 ;;
69
+ -o) out="\$2"; shift 2 ;;
70
+ -*) shift ;;
71
+ *) url="\$1"; shift ;;
72
+ esac
73
+ done
74
+ name="\${url##*/}"
75
+ printf 'HTTP/2 200\r\ncf-cache-status: HIT\r\nage: 5352\r\n\r\n' > "\$hdr"
76
+ cat "$serve_dir/\$name" > "\$out"
77
+ STUB
78
+ chmod +x "$stub"
79
+ echo "$stub"
80
+ }
81
+
82
+ case_clean_install_reports_clean() {
83
+ local i c stub edge out rc
84
+ i="$(mk_install)"; c="$(mk_config_with_hosts operations.realagent.network)"
85
+ # Edge serves exactly what is on disk.
86
+ edge="$(mktemp -d "${TMPDIR:-/tmp}/brand-cache-edge.XXXXXX")"
87
+ cp "$i/server/public/brand/"* "$edge/"
88
+ cp "$i/server/public/brand-defaults.css" "$edge/"
89
+ stub="$(mk_curl_stub "$edge")"
90
+ out="$(BRAND_CACHE_CURL="$stub" "$CHECK" --install-dir "$i" --config-dir "$c" 2>/dev/null)"
91
+ rc=$?
92
+ [ "$rc" -eq 0 ] || { echo " expected exit 0, got $rc"; rm -rf "$i" "$c" "$edge" "$stub"; return 1; }
93
+ # files=2 pinned as an integer: a \d+ match also accepts files=0, and a
94
+ # hostname resolving to nothing would then read as a pass.
95
+ echo "$out" | grep -qx "\[brand-cache\] clean host=operations.realagent.network files=2" \
96
+ || { echo " no clean line with files=2; got: $out"; rm -rf "$i" "$c" "$edge" "$stub"; return 1; }
97
+ rm -rf "$i" "$c" "$edge" "$stub"; return 0
98
+ }
99
+
100
+ case_stale_edge_reports_stale_on_stderr() {
101
+ local i c stub edge err rc
102
+ i="$(mk_install)"; c="$(mk_config_with_hosts operations.realagent.network)"
103
+ edge="$(mktemp -d "${TMPDIR:-/tmp}/brand-cache-edge.XXXXXX")"
104
+ cp "$i/server/public/brand/"* "$edge/"
105
+ cp "$i/server/public/brand-defaults.css" "$edge/"
106
+ # The measured symptom: the edge holds the retired mark, disk holds the new one.
107
+ printf 'black-square-bytes' > "$edge/logo.png"
108
+ stub="$(mk_curl_stub "$edge")"
109
+ err="$(BRAND_CACHE_CURL="$stub" "$CHECK" --install-dir "$i" --config-dir "$c" 2>&1 >/dev/null)"
110
+ rc=$?
111
+ [ "$rc" -eq 1 ] || { echo " expected exit 1, got $rc"; rm -rf "$i" "$c" "$edge" "$stub"; return 1; }
112
+ echo "$err" | grep -qE "^\[brand-cache\] STALE host=operations\.realagent\.network path=/brand/logo\.png edge=[0-9a-f]{7} disk=[0-9a-f]{7} cf-cache-status=HIT age=5352$" \
113
+ || { echo " no STALE line for /brand/logo.png; got: $err"; rm -rf "$i" "$c" "$edge" "$stub"; return 1; }
114
+ rm -rf "$i" "$c" "$edge" "$stub"; return 0
115
+ }
116
+
117
+ case_no_tunnel_reports_skipped() {
118
+ local i c out rc
119
+ i="$(mk_install)"; c="$(mk_config_no_tunnel)"
120
+ out="$("$CHECK" --install-dir "$i" --config-dir "$c" 2>/dev/null)"
121
+ rc=$?
122
+ [ "$rc" -eq 0 ] || { echo " expected exit 0, got $rc"; rm -rf "$i" "$c"; return 1; }
123
+ echo "$out" | grep -qx "\[brand-cache\] skipped host=none reason=no-tunnel-ingress" \
124
+ || { echo " no skipped line; got: $out"; rm -rf "$i" "$c"; return 1; }
125
+ rm -rf "$i" "$c"; return 0
126
+ }
127
+
128
+ case_localhost_only_ingress_is_no_tunnel() {
129
+ local i c out rc
130
+ i="$(mk_install)"; c="$(mk_config_with_hosts localhost muvin.local)"
131
+ out="$("$CHECK" --install-dir "$i" --config-dir "$c" 2>/dev/null)"
132
+ rc=$?
133
+ [ "$rc" -eq 0 ] || { echo " expected exit 0, got $rc"; rm -rf "$i" "$c"; return 1; }
134
+ echo "$out" | grep -qx "\[brand-cache\] skipped host=none reason=no-tunnel-ingress" \
135
+ || { echo " localhost/.local counted as ingress; got: $out"; rm -rf "$i" "$c"; return 1; }
136
+ rm -rf "$i" "$c"; return 0
137
+ }
138
+
139
+ case_unfetchable_asset_is_not_silently_clean() {
140
+ local i c stub err rc
141
+ i="$(mk_install)"; c="$(mk_config_with_hosts operations.realagent.network)"
142
+ # Stub that answers 502 with an empty body for every path.
143
+ stub="$(mktemp "${TMPDIR:-/tmp}/brand-cache-curl.XXXXXX")"
144
+ cat > "$stub" <<'STUB'
145
+ #!/usr/bin/env bash
146
+ hdr=""; out=""
147
+ while [ $# -gt 0 ]; do
148
+ case "$1" in
149
+ -D) hdr="$2"; shift 2 ;;
150
+ -o) out="$2"; shift 2 ;;
151
+ -*) shift ;;
152
+ *) shift ;;
153
+ esac
154
+ done
155
+ printf 'HTTP/2 502\r\n\r\n' > "$hdr"
156
+ : > "$out"
157
+ STUB
158
+ chmod +x "$stub"
159
+ err="$(BRAND_CACHE_CURL="$stub" "$CHECK" --install-dir "$i" --config-dir "$c" 2>&1 >/dev/null)"
160
+ rc=$?
161
+ [ "$rc" -eq 1 ] || { echo " expected exit 1, got $rc"; rm -rf "$i" "$c" "$stub"; return 1; }
162
+ echo "$err" | grep -qE "^\[brand-cache\] UNREACHABLE host=operations\.realagent\.network path=/brand/logo\.png http=502$" \
163
+ || { echo " no UNREACHABLE line; got: $err"; rm -rf "$i" "$c" "$stub"; return 1; }
164
+ rm -rf "$i" "$c" "$stub"; return 0
165
+ }
166
+
167
+ case_no_assets_is_a_named_skip_not_a_clean() {
168
+ local i c out rc
169
+ i="$(mktemp -d "${TMPDIR:-/tmp}/brand-cache-install.XXXXXX")"; mkdir -p "$i/server/public"
170
+ c="$(mk_config_with_hosts operations.realagent.network)"
171
+ # bash 3.2 (macOS) errors on "${PATHS[@]}" for an empty array under `set -u`,
172
+ # so this also pins that the loop is never entered with nothing to compare.
173
+ out="$(/bin/bash "$CHECK" --install-dir "$i" --config-dir "$c" 2>&1)"
174
+ rc=$?
175
+ [ "$rc" -eq 0 ] || { echo " expected exit 0, got $rc; got: $out"; rm -rf "$i" "$c"; return 1; }
176
+ echo "$out" | grep -qx "\[brand-cache\] skipped host=operations.realagent.network reason=no-brand-assets" \
177
+ || { echo " no named skip for an empty asset list; got: $out"; rm -rf "$i" "$c"; return 1; }
178
+ # files=0 must never be emitted: the task's own pinning exists so an empty
179
+ # comparison cannot read as a pass.
180
+ echo "$out" | grep -q "files=0" \
181
+ && { echo " emitted files=0, which reads as a pass"; rm -rf "$i" "$c"; return 1; }
182
+ rm -rf "$i" "$c"; return 0
183
+ }
184
+
185
+ case_alias_only_install_is_checked_not_skipped() {
186
+ local i c stub edge out rc
187
+ i="$(mk_install)"; c="$(mk_config_alias_only)"
188
+ edge="$(mktemp -d "${TMPDIR:-/tmp}/brand-cache-edge.XXXXXX")"
189
+ cp "$i/server/public/brand/"* "$edge/"
190
+ stub="$(mk_curl_stub "$edge")"
191
+ out="$(BRAND_CACHE_CURL="$stub" "$CHECK" --install-dir "$i" --config-dir "$c" 2>/dev/null)"
192
+ rc=$?
193
+ [ "$rc" -eq 0 ] || { echo " expected exit 0, got $rc"; rm -rf "$i" "$c" "$edge" "$stub"; return 1; }
194
+ # An install with an alias edge and no ingress row does have an edge that can
195
+ # diverge, so reporting `skipped` there would read as nothing-to-check on the
196
+ # one state the skip wording promises it is not.
197
+ echo "$out" | grep -qx "\[brand-cache\] clean host=public.realagent.app files=2" \
198
+ || { echo " alias-only install was not checked; got: $out"; rm -rf "$i" "$c" "$edge" "$stub"; return 1; }
199
+ rm -rf "$i" "$c" "$edge" "$stub"; return 0
200
+ }
201
+
202
+ case_brand_defaults_css_is_not_fetched() {
203
+ local i c stub edge out rc
204
+ i="$(mk_install)"; c="$(mk_config_with_hosts admin.realagent.network public.realagent.network)"
205
+ edge="$(mktemp -d "${TMPDIR:-/tmp}/brand-cache-edge.XXXXXX")"
206
+ cp "$i/server/public/brand/"* "$edge/"
207
+ # No brand-defaults.css at the edge. The gate at server/index.ts:1311-1322
208
+ # exempts /brand/ but not /brand-defaults.css, so through an admin-gated host
209
+ # that path answers with a login page at status 200 — hashing that against
210
+ # the stylesheet would be a permanent false STALE with no cache involved.
211
+ stub="$(mk_curl_stub "$edge")"
212
+ out="$(BRAND_CACHE_CURL="$stub" "$CHECK" --install-dir "$i" --config-dir "$c" 2>/dev/null)"
213
+ rc=$?
214
+ [ "$rc" -eq 0 ] || { echo " expected exit 0, got $rc"; rm -rf "$i" "$c" "$edge" "$stub"; return 1; }
215
+ echo "$out" | grep -qx "\[brand-cache\] clean host=admin.realagent.network files=2" \
216
+ || { echo " expected only the two /brand/ files; got: $out"; rm -rf "$i" "$c" "$edge" "$stub"; return 1; }
217
+ rm -rf "$i" "$c" "$edge" "$stub"; return 0
218
+ }
219
+
220
+ run_case "clean install reports clean with a pinned file count" case_clean_install_reports_clean
221
+ run_case "stale edge reports STALE on stderr and exits 1" case_stale_edge_reports_stale_on_stderr
222
+ run_case "no tunnel ingress reports skipped and exits 0" case_no_tunnel_reports_skipped
223
+ run_case "localhost/.local-only ingress counts as no tunnel" case_localhost_only_ingress_is_no_tunnel
224
+ run_case "an unfetchable asset is reported, never counted clean" case_unfetchable_asset_is_not_silently_clean
225
+ run_case "an empty asset list is a named skip, never files=0" case_no_assets_is_a_named_skip_not_a_clean
226
+ run_case "an alias-only install is checked, not skipped" case_alias_only_install_is_checked_not_skipped
227
+ run_case "brand-defaults.css is not fetched through a gated host" case_brand_defaults_css_is_not_fetched
228
+
229
+ echo ""
230
+ echo "PASS=$PASS FAIL=$FAIL"
231
+ [ "$FAIL" -eq 0 ]