@rudderhq/server 0.7.4 → 0.7.6

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 (136) hide show
  1. package/dist/agent-runtimes/index.d.ts +1 -1
  2. package/dist/agent-runtimes/index.d.ts.map +1 -1
  3. package/dist/agent-runtimes/types.d.ts +1 -1
  4. package/dist/agent-runtimes/types.d.ts.map +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +8 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/routes/chats.d.ts.map +1 -1
  9. package/dist/routes/chats.js +2 -4
  10. package/dist/routes/chats.js.map +1 -1
  11. package/dist/routes/orgs.d.ts.map +1 -1
  12. package/dist/routes/orgs.js +5 -0
  13. package/dist/routes/orgs.js.map +1 -1
  14. package/dist/services/index.d.ts +1 -1
  15. package/dist/services/index.d.ts.map +1 -1
  16. package/dist/services/index.js +1 -1
  17. package/dist/services/index.js.map +1 -1
  18. package/dist/services/rudder-plugins.d.ts.map +1 -1
  19. package/dist/services/rudder-plugins.js +1 -0
  20. package/dist/services/rudder-plugins.js.map +1 -1
  21. package/dist/services/rudder-plugins.test.js +10 -0
  22. package/dist/services/rudder-plugins.test.js.map +1 -1
  23. package/dist/services/runtime-kernel/assignment-run-guardrail.d.ts +65 -0
  24. package/dist/services/runtime-kernel/assignment-run-guardrail.d.ts.map +1 -0
  25. package/dist/services/runtime-kernel/assignment-run-guardrail.js +204 -0
  26. package/dist/services/runtime-kernel/assignment-run-guardrail.js.map +1 -0
  27. package/dist/services/runtime-kernel/assignment-run-guardrail.test.d.ts +2 -0
  28. package/dist/services/runtime-kernel/assignment-run-guardrail.test.d.ts.map +1 -0
  29. package/dist/services/runtime-kernel/assignment-run-guardrail.test.js +114 -0
  30. package/dist/services/runtime-kernel/assignment-run-guardrail.test.js.map +1 -0
  31. package/dist/services/runtime-kernel/heartbeat.execute.d.ts.map +1 -1
  32. package/dist/services/runtime-kernel/heartbeat.execute.js +117 -2
  33. package/dist/services/runtime-kernel/heartbeat.execute.js.map +1 -1
  34. package/dist/services/runtime-kernel/heartbeat.recovery.d.ts +1 -0
  35. package/dist/services/runtime-kernel/heartbeat.recovery.d.ts.map +1 -1
  36. package/dist/services/runtime-kernel/heartbeat.recovery.js +1 -0
  37. package/dist/services/runtime-kernel/heartbeat.recovery.js.map +1 -1
  38. package/dist/services/side-chats.d.ts +0 -2
  39. package/dist/services/side-chats.d.ts.map +1 -1
  40. package/dist/services/side-chats.js +4 -12
  41. package/dist/services/side-chats.js.map +1 -1
  42. package/dist/services/workspace-backup-v2.d.ts +127 -0
  43. package/dist/services/workspace-backup-v2.d.ts.map +1 -0
  44. package/dist/services/workspace-backup-v2.js +964 -0
  45. package/dist/services/workspace-backup-v2.js.map +1 -0
  46. package/dist/services/workspace-backup-v2.test.d.ts +2 -0
  47. package/dist/services/workspace-backup-v2.test.d.ts.map +1 -0
  48. package/dist/services/workspace-backup-v2.test.js +318 -0
  49. package/dist/services/workspace-backup-v2.test.js.map +1 -0
  50. package/dist/services/workspace-backups.d.ts +46 -1
  51. package/dist/services/workspace-backups.d.ts.map +1 -1
  52. package/dist/services/workspace-backups.js +579 -60
  53. package/dist/services/workspace-backups.js.map +1 -1
  54. package/package.json +14 -14
  55. package/ui-dist/assets/{LibraryLiveSurface-D4uCo0l0.js → LibraryLiveSurface-nSMExVEC.js} +1 -1
  56. package/ui-dist/assets/{abnfDiagram-VRR7QNED-FF2qcR6A.js → abnfDiagram-VRR7QNED-Av8TM585.js} +1 -1
  57. package/ui-dist/assets/{addon-fit-4gL7JXzc.js → addon-fit-DzFhQGnx.js} +1 -1
  58. package/ui-dist/assets/{arc-D5pLjh6B.js → arc-Bc-Gf4Fc.js} +1 -1
  59. package/ui-dist/assets/{architectureDiagram-ZJ3FMSHR-BvqY-HXg.js → architectureDiagram-ZJ3FMSHR-BOph0PHz.js} +1 -1
  60. package/ui-dist/assets/{blockDiagram-677ZJIJ3-BU6ZbJ0s.js → blockDiagram-677ZJIJ3-BCUhdmf3.js} +1 -1
  61. package/ui-dist/assets/{c4Diagram-LMCZKHZV-Cl5Gsovj.js → c4Diagram-LMCZKHZV-B_3pH6bx.js} +1 -1
  62. package/ui-dist/assets/channel-CyoUWdeD.js +1 -0
  63. package/ui-dist/assets/{chunk-2Q5K7J3B-4a2agt_p.js → chunk-2Q5K7J3B-C4gdcZxO.js} +1 -1
  64. package/ui-dist/assets/{chunk-32BRIVSS-CF0oOO2T.js → chunk-32BRIVSS-JxQV72by.js} +1 -1
  65. package/ui-dist/assets/{chunk-5VM5RSS4-CsSq2zX2.js → chunk-5VM5RSS4-f0zO1uV_.js} +1 -1
  66. package/ui-dist/assets/{chunk-EX3LRPZG-B6riETVZ.js → chunk-EX3LRPZG-Bbn2Dpmg.js} +1 -1
  67. package/ui-dist/assets/{chunk-JWPE2WC7-S-p1xu0r.js → chunk-JWPE2WC7-B118_FhP.js} +1 -1
  68. package/ui-dist/assets/{chunk-MOJQB5TN-mVE4v_LV.js → chunk-MOJQB5TN-CEcGkJ3p.js} +1 -1
  69. package/ui-dist/assets/{chunk-RYQCIY6F-CElbgpgs.js → chunk-RYQCIY6F-C-WY_ozP.js} +1 -1
  70. package/ui-dist/assets/{chunk-V7JOEXUC-8XM3povP.js → chunk-V7JOEXUC-DS5sjH7p.js} +1 -1
  71. package/ui-dist/assets/{chunk-VR4S4FIN-LrThp3ht.js → chunk-VR4S4FIN-COyRk9XW.js} +1 -1
  72. package/ui-dist/assets/{chunk-XXDRQBXY-D38vLb4t.js → chunk-XXDRQBXY-Cd2T_1Uf.js} +1 -1
  73. package/ui-dist/assets/classDiagram-OUVF2IWQ-BETmcliY.js +1 -0
  74. package/ui-dist/assets/classDiagram-v2-EOCWNBFH-BETmcliY.js +1 -0
  75. package/ui-dist/assets/{cose-bilkent-JH36ORCC-CmpZMaDV.js → cose-bilkent-JH36ORCC-iEFFQ2c7.js} +1 -1
  76. package/ui-dist/assets/{cynefin-VYW2F7L2-D3RnbBvI.js → cynefin-VYW2F7L2-Bp-sv4UP.js} +1 -1
  77. package/ui-dist/assets/{cynefinDiagram-TSTJHNR4-DZTcUojp.js → cynefinDiagram-TSTJHNR4-CET_eOH5.js} +1 -1
  78. package/ui-dist/assets/{dagre-VKFMJZFB-6jXSDi0S.js → dagre-VKFMJZFB-BkxxZr0z.js} +1 -1
  79. package/ui-dist/assets/{diagram-FQU43EPY-DIMm3mfS.js → diagram-FQU43EPY-2b0kz8hj.js} +1 -1
  80. package/ui-dist/assets/{diagram-G47NLZAW-Cyo22v0_.js → diagram-G47NLZAW-CWgvA-UY.js} +1 -1
  81. package/ui-dist/assets/{diagram-NH7WQ7WH-CHDV0nSL.js → diagram-NH7WQ7WH-DKoSLHQw.js} +1 -1
  82. package/ui-dist/assets/{diagram-OA4YK3LP-Jijx9vQO.js → diagram-OA4YK3LP-BtpJ7ObO.js} +1 -1
  83. package/ui-dist/assets/{diagram-WEI45ONY-CcL19HLE.js → diagram-WEI45ONY-cTRv44yL.js} +1 -1
  84. package/ui-dist/assets/{ebnfDiagram-CCIWWBDH-D94h-RFQ.js → ebnfDiagram-CCIWWBDH-BMwUwEZM.js} +1 -1
  85. package/ui-dist/assets/{erDiagram-Q63AITRT-B3ypUGZX.js → erDiagram-Q63AITRT-B9qW6I5c.js} +1 -1
  86. package/ui-dist/assets/{flowDiagram-23GEKE2U-DjCT6Y1o.js → flowDiagram-23GEKE2U-Cb1rJFcp.js} +1 -1
  87. package/ui-dist/assets/{ganttDiagram-NO4QXBWP-C3QVhTc9.js → ganttDiagram-NO4QXBWP-CiicdHPl.js} +1 -1
  88. package/ui-dist/assets/{gitGraphDiagram-IHSO6WYX-D0trBzU5.js → gitGraphDiagram-IHSO6WYX-Cge-AgEB.js} +1 -1
  89. package/ui-dist/assets/{graph-D1zFmVUP.js → graph-0wCGCEs4.js} +1 -1
  90. package/ui-dist/assets/{index-Bk_yesiO.js → index-5dD5l_7S.js} +1 -1
  91. package/ui-dist/assets/{index-BwutQLG1.js → index-BOnG5igl.js} +1 -1
  92. package/ui-dist/assets/{index-CiCRRUVV.js → index-BXR0UEnQ.js} +1 -1
  93. package/ui-dist/assets/{index-CB4zr_Ku.js → index-B_c6Qzui.js} +1 -1
  94. package/ui-dist/assets/{index-CFWOO6yn.js → index-BhIqb_sb.js} +1 -1
  95. package/ui-dist/assets/{index-BFktZ2tu.js → index-BjGW8ROf.js} +1 -1
  96. package/ui-dist/assets/{index-B0GEsWui.js → index-BqpG2FnL.js} +555 -555
  97. package/ui-dist/assets/{index-D8JG-IU9.js → index-C3ghnZSF.js} +1 -1
  98. package/ui-dist/assets/{index-DKCiVvJo.js → index-CB9X8t3F.js} +1 -1
  99. package/ui-dist/assets/{index-or2C22bw.js → index-CcDlhtvT.js} +1 -1
  100. package/ui-dist/assets/{index-CRolpP90.js → index-D1w6UIRX.js} +1 -1
  101. package/ui-dist/assets/{index-G2yfKEf_.js → index-DN9Yo10m.js} +1 -1
  102. package/ui-dist/assets/{index-CK8u9b07.js → index-DP30MjES.js} +1 -1
  103. package/ui-dist/assets/{index-B8bxQLfN.js → index-DXRvkZMv.js} +1 -1
  104. package/ui-dist/assets/{index-CC89UPa-.js → index-Dy-AXx6E.js} +1 -1
  105. package/ui-dist/assets/{infoDiagram-FWYZ7A6U-Co0nkcV4.js → infoDiagram-FWYZ7A6U-R2pgAXHR.js} +1 -1
  106. package/ui-dist/assets/{ishikawaDiagram-FXEZZL3T-CkIg1Jdu.js → ishikawaDiagram-FXEZZL3T-BJivveWk.js} +1 -1
  107. package/ui-dist/assets/{journeyDiagram-5HDEW3XC-AYUT6JuJ.js → journeyDiagram-5HDEW3XC-CLlN_hss.js} +1 -1
  108. package/ui-dist/assets/{kanban-definition-HUTT4EX6-C3H2XW08.js → kanban-definition-HUTT4EX6-CCRtDa5s.js} +1 -1
  109. package/ui-dist/assets/{layout-sZWyqNOu.js → layout-BgygArKO.js} +1 -1
  110. package/ui-dist/assets/{linear-BLTYou8N.js → linear-ZTGN1Bax.js} +1 -1
  111. package/ui-dist/assets/{map-zKBdHkX8.js → map-BheoFUge.js} +1 -1
  112. package/ui-dist/assets/{mermaid.core-DbjdFip8.js → mermaid.core-D4HYOaQr.js} +4 -4
  113. package/ui-dist/assets/{mindmap-definition-LN4V7U3C-DcDNQ4GQ.js → mindmap-definition-LN4V7U3C-B25TPfzP.js} +1 -1
  114. package/ui-dist/assets/{pegDiagram-2B236MQR-BGq1PngU.js → pegDiagram-2B236MQR-CWrV5tQE.js} +1 -1
  115. package/ui-dist/assets/{pieDiagram-ENE6RG2P-BfXWRzNb.js → pieDiagram-ENE6RG2P-BQTq4Jp6.js} +1 -1
  116. package/ui-dist/assets/{quadrantDiagram-ABIIQ3AL-AYMVAbo1.js → quadrantDiagram-ABIIQ3AL-C5TwZIzz.js} +1 -1
  117. package/ui-dist/assets/{railroadDiagram-RFXS5EU6-B0PoG0Ih.js → railroadDiagram-RFXS5EU6--wv7_omb.js} +1 -1
  118. package/ui-dist/assets/{requirementDiagram-TGXJPOKE-Cuuu9wfw.js → requirementDiagram-TGXJPOKE-D80O_sgV.js} +1 -1
  119. package/ui-dist/assets/{sankeyDiagram-HTMAVEWB-BceAc42f.js → sankeyDiagram-HTMAVEWB-Cz0SmDPj.js} +1 -1
  120. package/ui-dist/assets/{sequenceDiagram-DBY2YBRQ-cqJ71-Rw.js → sequenceDiagram-DBY2YBRQ-jmLYUOQ2.js} +1 -1
  121. package/ui-dist/assets/{sizeCapture-X5ZJPWSS-DQ6MibbA.js → sizeCapture-X5ZJPWSS-C1FqwoMN.js} +1 -1
  122. package/ui-dist/assets/{stateDiagram-2N3HPSRC-Bdl-XY7t.js → stateDiagram-2N3HPSRC-CZa7Dj75.js} +1 -1
  123. package/ui-dist/assets/stateDiagram-v2-6OUMAXLB-CEUECJmS.js +1 -0
  124. package/ui-dist/assets/{swimlanes-5IMT3BWC-Dpo2HDtE.js → swimlanes-5IMT3BWC-CbHndCqy.js} +2 -2
  125. package/ui-dist/assets/swimlanesDiagram-G3AALYLV-4TgYo_NT.js +8 -0
  126. package/ui-dist/assets/{timeline-definition-FHXFAJF6-BzmJ-xnC.js → timeline-definition-FHXFAJF6-CFOg8vuA.js} +1 -1
  127. package/ui-dist/assets/{vennDiagram-L72KCM5P-BF2EI7vn.js → vennDiagram-L72KCM5P-1dSr5CoN.js} +1 -1
  128. package/ui-dist/assets/{wardleyDiagram-EHGQE667-DM65x1i1.js → wardleyDiagram-EHGQE667-CAMneCxP.js} +1 -1
  129. package/ui-dist/assets/{xterm-DZP7bLZ5.js → xterm-M5ke8gUF.js} +1 -1
  130. package/ui-dist/assets/{xychartDiagram-FW5EYKEG-3Q2t9pdU.js → xychartDiagram-FW5EYKEG-DxVyl1x4.js} +1 -1
  131. package/ui-dist/index.html +1 -1
  132. package/ui-dist/assets/channel-HcmprA3I.js +0 -1
  133. package/ui-dist/assets/classDiagram-OUVF2IWQ-Dpe8LaLf.js +0 -1
  134. package/ui-dist/assets/classDiagram-v2-EOCWNBFH-Dpe8LaLf.js +0 -1
  135. package/ui-dist/assets/stateDiagram-v2-6OUMAXLB-Cjj7yPIO.js +0 -1
  136. package/ui-dist/assets/swimlanesDiagram-G3AALYLV-CPmIkNCu.js +0 -8
@@ -3,11 +3,13 @@ import { heartbeatRuns, workspaceBackups, } from "@rudderhq/db";
3
3
  import { WORKSPACE_BACKUP_DEFAULT_INTERVAL_HOURS, WORKSPACE_BACKUP_DEFAULT_RETENTION_DAYS, WORKSPACE_BACKUP_OFFLINE_INTERVAL_HOURS, WORKSPACE_BACKUP_RUNNING_INTERVAL_HOURS, } from "@rudderhq/shared";
4
4
  import { and, desc, eq, inArray, isNotNull, isNull, lte, ne, or, sql } from "drizzle-orm";
5
5
  import crypto from "node:crypto";
6
+ import { createReadStream } from "node:fs";
6
7
  import fs from "node:fs/promises";
7
8
  import path from "node:path";
8
- import { conflict, notFound, unprocessable } from "../errors.js";
9
+ import { conflict, HttpError, notFound, unprocessable } from "../errors.js";
9
10
  import { ensureOrganizationWorkspaceLayout, resolveDefaultBackupDir, resolveOrganizationWorkspaceRoot, resolveRudderInstanceId, } from "../home-paths.js";
10
11
  import { organizationService } from "./orgs.js";
12
+ import { createWorkspaceBackupV2File, createWorkspaceBackupV2Native, formatWorkspaceBackupV2NativeFallback, inspectWorkspaceBackupV2File, readWorkspaceBackupV2File, workspaceBackupV2NativeDiagnostic, } from "./workspace-backup-v2.js";
11
13
  const ARTIFACT_VERSION = 1;
12
14
  const MAX_PREVIEW_BYTES = 200_000;
13
15
  const SKIPPED_ENTRY_NAMES = new Set([
@@ -40,6 +42,32 @@ const MAX_WARNING_COUNT = 200;
40
42
  const SPARSE_WORKSPACE_RECOVERY_MAX_CURRENT_FILES = 25;
41
43
  const SPARSE_WORKSPACE_RECOVERY_MAX_RATIO = 0.25;
42
44
  const SPARSE_WORKSPACE_RECOVERY_MIN_BACKUP_FILES = 10;
45
+ export function isWorkspaceBackupV2Enabled() {
46
+ return process.env.RUDDER_WORKSPACE_BACKUP_V2_ENABLED === "true";
47
+ }
48
+ export function isWorkspaceBackupV2NativeEnabled() {
49
+ return isWorkspaceBackupV2Enabled() && process.env.RUDDER_WORKSPACE_BACKUP_V2_NATIVE === "true";
50
+ }
51
+ export class WorkspaceRestoreRecoveryRequiredError extends HttpError {
52
+ operationId;
53
+ recoveryCode;
54
+ constructor(receipt, recoveryCode, detail) {
55
+ super(409, "Workspace restore requires recovery before another restore can run.", {
56
+ code: "restore_recovery_required",
57
+ operationId: receipt.operationId,
58
+ orgId: receipt.orgId,
59
+ backupId: receipt.backupId,
60
+ recoveryCode,
61
+ detail,
62
+ workspaceRoot: receipt.workspaceRoot,
63
+ stagingRoot: receipt.stagingRoot,
64
+ rollbackRoot: receipt.rollbackRoot,
65
+ });
66
+ this.name = "WorkspaceRestoreRecoveryRequiredError";
67
+ this.operationId = receipt.operationId;
68
+ this.recoveryCode = recoveryCode;
69
+ }
70
+ }
43
71
  function timestamp(date = new Date()) {
44
72
  const pad = (value) => String(value).padStart(2, "0");
45
73
  return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
@@ -339,6 +367,251 @@ async function removeDirectoryIfEmpty(directoryPath) {
339
367
  throw error;
340
368
  }
341
369
  }
370
+ async function writeRestoreReceipt(filePath, value) {
371
+ const temporary = `${filePath}.${crypto.randomUUID()}.tmp`;
372
+ await fs.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
373
+ const handle = await fs.open(temporary, "r+");
374
+ try {
375
+ await handle.sync();
376
+ }
377
+ finally {
378
+ await handle.close();
379
+ }
380
+ await fs.rename(temporary, filePath);
381
+ await syncDirectory(path.dirname(filePath));
382
+ }
383
+ async function workspaceTreeSha256(rootPath) {
384
+ const warnings = [];
385
+ const entries = await walkWorkspace(rootPath, rootPath, warnings);
386
+ return buildTreeHash(entries);
387
+ }
388
+ async function syncDirectory(directoryPath) {
389
+ const handle = await fs.open(directoryPath, "r");
390
+ try {
391
+ await handle.sync();
392
+ }
393
+ finally {
394
+ await handle.close();
395
+ }
396
+ }
397
+ function restoreRecoveryRequired(message, receipt, cause) {
398
+ const detail = cause instanceof Error ? cause.message : cause ? String(cause) : undefined;
399
+ return new WorkspaceRestoreRecoveryRequiredError(receipt, "rollback_failed", `${message}${detail ? `: ${detail}` : ""}`);
400
+ }
401
+ async function readRestoreReceipt(filePath) {
402
+ try {
403
+ const value = JSON.parse(await fs.readFile(filePath, "utf8"));
404
+ if (value.version !== 1
405
+ || typeof value.operationId !== "string"
406
+ || typeof value.orgId !== "string"
407
+ || typeof value.backupId !== "string"
408
+ || !["prepared", "live_moved", "committed", "rolled_back", "recovery_required"].includes(value.phase ?? "")
409
+ || typeof value.workspaceRoot !== "string"
410
+ || typeof value.stagingRoot !== "string"
411
+ || typeof value.rollbackRoot !== "string"
412
+ || (value.liveTreeSha256 !== null && typeof value.liveTreeSha256 !== "string")
413
+ || typeof value.stagingTreeSha256 !== "string"
414
+ || (value.expectedTreeSha256 !== null && typeof value.expectedTreeSha256 !== "string")
415
+ || typeof value.preRestoreBackupId !== "string") {
416
+ return null;
417
+ }
418
+ return value;
419
+ }
420
+ catch (error) {
421
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT")
422
+ return null;
423
+ throw error;
424
+ }
425
+ }
426
+ function isOwnedRestoreRoot(root, workspaceRoot, operationId, kind) {
427
+ const parent = path.dirname(workspaceRoot);
428
+ return path.resolve(root) !== path.resolve(workspaceRoot)
429
+ && path.resolve(root) === path.resolve(parent, `.rudder-workspace-restore-${kind}-${operationId}`);
430
+ }
431
+ /** Reconcile only validated receipts and their exact operation-owned roots. */
432
+ export async function reconcileWorkspaceRestoreReceipts() {
433
+ const receiptRoot = path.resolve(resolveDefaultBackupDir(), "workspace-restore-receipts");
434
+ const recovered = [];
435
+ const blocked = [];
436
+ let names;
437
+ try {
438
+ names = await fs.readdir(receiptRoot);
439
+ }
440
+ catch (error) {
441
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT")
442
+ return { recovered, blocked };
443
+ throw error;
444
+ }
445
+ for (const name of names.filter((entry) => entry.endsWith(".json"))) {
446
+ const receiptPath = path.join(receiptRoot, name);
447
+ let receipt = null;
448
+ try {
449
+ receipt = await readRestoreReceipt(receiptPath);
450
+ }
451
+ catch (error) {
452
+ blocked.push({ receiptPath, operationId: "unknown", error: error instanceof Error ? error.message : String(error) });
453
+ continue;
454
+ }
455
+ let canonicalWorkspaceRoot = null;
456
+ try {
457
+ if (receipt?.orgId)
458
+ canonicalWorkspaceRoot = path.resolve(resolveOrganizationWorkspaceRoot(receipt.orgId));
459
+ }
460
+ catch {
461
+ canonicalWorkspaceRoot = null;
462
+ }
463
+ if (!receipt
464
+ || !canonicalWorkspaceRoot
465
+ || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(receipt.operationId)
466
+ || path.resolve(receipt.workspaceRoot) !== canonicalWorkspaceRoot
467
+ || !isOwnedRestoreRoot(receipt.stagingRoot, receipt.workspaceRoot, receipt.operationId, "staging")
468
+ || !isOwnedRestoreRoot(receipt.rollbackRoot, receipt.workspaceRoot, receipt.operationId, "rollback")) {
469
+ blocked.push({ receiptPath, operationId: receipt?.operationId ?? "unknown", error: "invalid_or_unowned_receipt" });
470
+ continue;
471
+ }
472
+ try {
473
+ const workspace = await pathExists(receipt.workspaceRoot);
474
+ const rollback = await pathExists(receipt.rollbackRoot);
475
+ if (receipt.phase === "prepared") {
476
+ if (workspace && rollback)
477
+ throw new Error("prepared receipt has conflicting live and rollback roots");
478
+ if (!workspace && rollback) {
479
+ // Crash window: workspace was renamed before the phase receipt advanced.
480
+ await fs.rename(receipt.rollbackRoot, receipt.workspaceRoot);
481
+ await syncDirectory(path.dirname(receipt.workspaceRoot));
482
+ }
483
+ await fs.rm(receipt.stagingRoot, { recursive: true, force: true });
484
+ await fs.rm(receipt.rollbackRoot, { recursive: true, force: true });
485
+ }
486
+ else if (receipt.phase === "committed" || receipt.phase === "rolled_back") {
487
+ await fs.rm(receipt.stagingRoot, { recursive: true, force: true });
488
+ await fs.rm(receipt.rollbackRoot, { recursive: true, force: true });
489
+ }
490
+ else if (receipt.phase === "live_moved" || receipt.phase === "recovery_required") {
491
+ if (!workspace && rollback) {
492
+ await fs.rename(receipt.rollbackRoot, receipt.workspaceRoot);
493
+ await syncDirectory(path.dirname(receipt.workspaceRoot));
494
+ }
495
+ else if (workspace && !rollback) {
496
+ const publishedTree = await workspaceTreeSha256(receipt.workspaceRoot);
497
+ if (receipt.expectedTreeSha256 && publishedTree !== receipt.expectedTreeSha256)
498
+ throw new Error("published workspace tree does not match receipt");
499
+ }
500
+ else if (workspace && rollback) {
501
+ const publishedTree = await workspaceTreeSha256(receipt.workspaceRoot);
502
+ if (receipt.expectedTreeSha256 && publishedTree === receipt.expectedTreeSha256) {
503
+ await fs.rm(receipt.rollbackRoot, { recursive: true, force: true });
504
+ await syncDirectory(path.dirname(receipt.workspaceRoot));
505
+ }
506
+ else if (receipt.liveTreeSha256) {
507
+ const rollbackTree = await workspaceTreeSha256(receipt.rollbackRoot);
508
+ if (rollbackTree !== receipt.liveTreeSha256)
509
+ throw new Error("workspace and rollback roots match neither recorded tree");
510
+ await fs.rm(receipt.workspaceRoot, { recursive: true, force: true });
511
+ await fs.rename(receipt.rollbackRoot, receipt.workspaceRoot);
512
+ await syncDirectory(path.dirname(receipt.workspaceRoot));
513
+ }
514
+ else {
515
+ throw new Error("workspace and rollback roots both exist without a recorded live tree");
516
+ }
517
+ }
518
+ else {
519
+ throw new Error("workspace and rollback roots are both missing");
520
+ }
521
+ await fs.rm(receipt.stagingRoot, { recursive: true, force: true });
522
+ await fs.rm(receipt.rollbackRoot, { recursive: true, force: true });
523
+ }
524
+ await fs.rm(receiptPath, { force: true });
525
+ await syncDirectory(receiptRoot);
526
+ recovered.push(receipt.operationId);
527
+ }
528
+ catch (error) {
529
+ blocked.push({ receiptPath, operationId: receipt.operationId, error: error instanceof Error ? error.message : String(error) });
530
+ }
531
+ }
532
+ return { recovered, blocked };
533
+ }
534
+ async function assertNoUnresolvedRestoreReceipt(orgId) {
535
+ const receiptRoot = path.resolve(resolveDefaultBackupDir(), "workspace-restore-receipts");
536
+ let names;
537
+ try {
538
+ names = await fs.readdir(receiptRoot);
539
+ }
540
+ catch (error) {
541
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT")
542
+ return;
543
+ throw error;
544
+ }
545
+ const prefix = `${orgId}-`;
546
+ for (const name of names.filter((entry) => entry.startsWith(prefix) && entry.endsWith(".json"))) {
547
+ const receiptPath = path.join(receiptRoot, name);
548
+ let receipt = null;
549
+ try {
550
+ receipt = await readRestoreReceipt(receiptPath);
551
+ }
552
+ catch (error) {
553
+ throw conflict("Workspace restore requires recovery before another restore can run.", {
554
+ code: "restore_recovery_required",
555
+ orgId,
556
+ receiptPath,
557
+ detail: error instanceof Error ? error.message : String(error),
558
+ });
559
+ }
560
+ if (receipt && receipt.orgId === orgId && ["prepared", "live_moved", "recovery_required"].includes(receipt.phase)) {
561
+ throw new WorkspaceRestoreRecoveryRequiredError(receipt, "unresolved_receipt", "An earlier workspace restore has not reached a recoverable terminal state.");
562
+ }
563
+ if (!receipt) {
564
+ throw conflict("Workspace restore requires recovery before another restore can run.", {
565
+ code: "restore_recovery_required",
566
+ orgId,
567
+ receiptPath,
568
+ detail: "invalid_or_unowned_receipt",
569
+ });
570
+ }
571
+ }
572
+ }
573
+ async function withWorkspaceRestoreLock(orgId, operation) {
574
+ const workspaceRoot = resolveOrganizationWorkspaceRoot(orgId);
575
+ const lockPath = path.resolve(path.dirname(workspaceRoot), `.rudder-workspace-restore-lock-${orgId}`);
576
+ await fs.mkdir(path.dirname(workspaceRoot), { recursive: true });
577
+ const deadline = Date.now() + 30_000;
578
+ while (true) {
579
+ try {
580
+ await fs.mkdir(lockPath, { recursive: false, mode: 0o700 });
581
+ await fs.writeFile(path.join(lockPath, "owner.json"), `${JSON.stringify({ orgId, pid: process.pid, acquiredAt: new Date().toISOString() })}\n`, { mode: 0o600 });
582
+ break;
583
+ }
584
+ catch (error) {
585
+ if (error.code !== "EEXIST" || Date.now() >= deadline) {
586
+ throw conflict("Workspace restore is busy for this organization.", { code: "restore_lock_unavailable", orgId });
587
+ }
588
+ try {
589
+ const owner = JSON.parse(await fs.readFile(path.join(lockPath, "owner.json"), "utf8"));
590
+ if (typeof owner.pid === "number" && Number.isInteger(owner.pid) && owner.pid > 0) {
591
+ try {
592
+ process.kill(owner.pid, 0);
593
+ }
594
+ catch (probeError) {
595
+ if (probeError.code === "ESRCH") {
596
+ await fs.rm(lockPath, { recursive: true, force: true });
597
+ continue;
598
+ }
599
+ }
600
+ }
601
+ }
602
+ catch {
603
+ // An unreadable owner is treated as held until the bounded timeout.
604
+ }
605
+ await new Promise((resolve) => setTimeout(resolve, 25));
606
+ }
607
+ }
608
+ try {
609
+ return await operation();
610
+ }
611
+ finally {
612
+ await fs.rm(lockPath, { recursive: true, force: true }).catch(() => undefined);
613
+ }
614
+ }
342
615
  export async function reconcileWorkspaceBackupArtifactStorage(db, liveOrgIds) {
343
616
  const migrated = [];
344
617
  const skipped = [];
@@ -671,6 +944,33 @@ export function workspaceBackupService(db) {
671
944
  if (!(await fileExists(row.artifactRef))) {
672
945
  throw notFound("Workspace backup artifact not found");
673
946
  }
947
+ if (path.extname(row.artifactRef).toLowerCase() === ".zip") {
948
+ try {
949
+ const index = await inspectWorkspaceBackupV2File(row.artifactRef);
950
+ if (row.archiveSha256 && await sha256File(row.artifactRef) !== row.archiveSha256) {
951
+ throw new Error("Workspace backup artifact checksum does not match the recorded backup metadata");
952
+ }
953
+ const manifest = index.manifest;
954
+ const artifact = {
955
+ version: ARTIFACT_VERSION,
956
+ orgId: manifest.identity.orgId,
957
+ instanceId: manifest.identity.instanceId,
958
+ createdAt: manifest.createdAt,
959
+ rootPath: manifest.identity.rootPath,
960
+ entries: manifest.entries.map((entry) => ({
961
+ ...entry,
962
+ dataBase64: undefined,
963
+ })),
964
+ warnings: manifest.warnings,
965
+ };
966
+ if (artifact.orgId !== row.orgId)
967
+ throw new Error("organization identity mismatch");
968
+ return { raw: null, artifact, v2: { filePath: row.artifactRef, index } };
969
+ }
970
+ catch (error) {
971
+ throw unprocessable(error instanceof Error ? `Workspace backup v2 artifact is invalid: ${error.message}` : "Workspace backup v2 artifact is invalid");
972
+ }
973
+ }
674
974
  const raw = await fs.readFile(row.artifactRef);
675
975
  if (row.archiveSha256 && sha256Buffer(raw) !== row.archiveSha256) {
676
976
  throw unprocessable("Workspace backup artifact checksum does not match the recorded backup metadata");
@@ -686,6 +986,15 @@ export function workspaceBackupService(db) {
686
986
  }
687
987
  async function readArtifact(row) {
688
988
  const payload = await readArtifactPayload(row);
989
+ if (payload.v2) {
990
+ const entries = await Promise.all(payload.artifact.entries.map(async (entry) => {
991
+ if (entry.kind !== "file")
992
+ return entry;
993
+ const data = await readWorkspaceBackupV2File(payload.v2.filePath, payload.v2.index, entry.path);
994
+ return { ...entry, dataBase64: data.toString("base64") };
995
+ }));
996
+ return { ...payload.artifact, entries };
997
+ }
689
998
  return payload.artifact;
690
999
  }
691
1000
  async function countActiveRuns(orgId) {
@@ -789,7 +1098,8 @@ export function workspaceBackupService(db) {
789
1098
  const triggerSource = input.triggerSource ?? "manual";
790
1099
  const organizationStorageKey = resolveOrganizationStorageKey(input.orgId);
791
1100
  const backupDir = path.resolve(resolveDefaultBackupDir(), "workspaces", organizationStorageKey);
792
- const artifactRef = path.resolve(backupDir, `workspace-${organizationStorageKey}-${timestamp(startedAt)}-${backupId.slice(0, 8)}.json`);
1101
+ const artifactExtension = isWorkspaceBackupV2Enabled() ? ".zip" : ".json";
1102
+ const artifactRef = path.resolve(backupDir, `workspace-${organizationStorageKey}-${timestamp(startedAt)}-${backupId.slice(0, 8)}${artifactExtension}`);
793
1103
  const [runningRow] = await db
794
1104
  .insert(workspaceBackups)
795
1105
  .values({
@@ -816,6 +1126,91 @@ export function workspaceBackupService(db) {
816
1126
  try {
817
1127
  await fs.mkdir(path.dirname(artifactRef), { recursive: true });
818
1128
  const layout = await ensureOrganizationWorkspaceLayout(runningRow.orgId);
1129
+ if (path.extname(artifactRef).toLowerCase() === ".zip") {
1130
+ let nativeFallbackWarning = null;
1131
+ if (isWorkspaceBackupV2NativeEnabled()) {
1132
+ try {
1133
+ const artifact = await createWorkspaceBackupV2Native({
1134
+ rootPath: layout.root,
1135
+ orgId: runningRow.orgId,
1136
+ instanceId: resolveRudderInstanceId(),
1137
+ artifactPath: artifactRef,
1138
+ createdAt: runningRow.startedAt ?? runningRow.createdAt,
1139
+ });
1140
+ const stat = await fs.stat(artifact.artifactPath);
1141
+ const finishedAt = new Date();
1142
+ const [row] = await db
1143
+ .update(workspaceBackups)
1144
+ .set({
1145
+ status: "succeeded",
1146
+ archiveSha256: artifact.archiveSha256,
1147
+ treeSha256: artifact.treeSha256,
1148
+ fileCount: artifact.fileCount,
1149
+ byteSize: artifact.byteSize,
1150
+ compressedSize: stat.size,
1151
+ manifest: artifact.manifest,
1152
+ warnings: artifact.warnings,
1153
+ finishedAt,
1154
+ updatedAt: finishedAt,
1155
+ })
1156
+ .where(and(eq(workspaceBackups.id, backupId), eq(workspaceBackups.status, "running")))
1157
+ .returning();
1158
+ if (row)
1159
+ return mapBackupRow(row);
1160
+ const [currentRow] = await db.select().from(workspaceBackups).where(eq(workspaceBackups.id, backupId)).limit(1);
1161
+ if (currentRow)
1162
+ return mapBackupRow(currentRow);
1163
+ throw new Error("Workspace backup row was not updated.");
1164
+ }
1165
+ catch (error) {
1166
+ // Native is opt-in and never changes the default Node comparator. A
1167
+ // failed native operation falls through to the same contract-equivalent
1168
+ // Node writer while retaining the DB row and TypeScript decisions.
1169
+ const diagnostic = workspaceBackupV2NativeDiagnostic(error);
1170
+ if (!diagnostic.fallbackAllowed || await fs.stat(artifactRef).then(() => true).catch(() => false)) {
1171
+ throw new Error(`Native workspace backup cannot fall back [${diagnostic.category}/${diagnostic.code}]: ${diagnostic.detail}`);
1172
+ }
1173
+ nativeFallbackWarning = formatWorkspaceBackupV2NativeFallback(error);
1174
+ }
1175
+ }
1176
+ const artifact = await createWorkspaceBackupV2File({
1177
+ rootPath: layout.root,
1178
+ orgId: runningRow.orgId,
1179
+ instanceId: resolveRudderInstanceId(),
1180
+ artifactPath: artifactRef,
1181
+ createdAt: runningRow.startedAt ?? runningRow.createdAt,
1182
+ additionalWarnings: nativeFallbackWarning ? [nativeFallbackWarning] : undefined,
1183
+ });
1184
+ const archiveSha256 = artifact.archiveSha256;
1185
+ const stat = await fs.stat(artifact.artifactPath);
1186
+ const finishedAt = new Date();
1187
+ const [row] = await db
1188
+ .update(workspaceBackups)
1189
+ .set({
1190
+ status: "succeeded",
1191
+ archiveSha256,
1192
+ treeSha256: artifact.treeSha256,
1193
+ fileCount: artifact.fileCount,
1194
+ byteSize: artifact.byteSize,
1195
+ compressedSize: stat.size,
1196
+ manifest: artifact.manifest,
1197
+ warnings: artifact.warnings,
1198
+ finishedAt,
1199
+ updatedAt: finishedAt,
1200
+ })
1201
+ .where(and(eq(workspaceBackups.id, backupId), eq(workspaceBackups.status, "running")))
1202
+ .returning();
1203
+ if (row)
1204
+ return mapBackupRow(row);
1205
+ const [currentRow] = await db
1206
+ .select()
1207
+ .from(workspaceBackups)
1208
+ .where(eq(workspaceBackups.id, backupId))
1209
+ .limit(1);
1210
+ if (currentRow)
1211
+ return mapBackupRow(currentRow);
1212
+ throw new Error("Workspace backup row was not updated.");
1213
+ }
819
1214
  const warnings = [];
820
1215
  const entries = await walkWorkspace(layout.root, layout.root, warnings);
821
1216
  const fileCount = entries.filter((entry) => entry.kind === "file").length;
@@ -911,7 +1306,8 @@ export function workspaceBackupService(db) {
911
1306
  },
912
1307
  async listFiles(orgId, backupId, directoryPath = "") {
913
1308
  const row = await getBackupRow(orgId, backupId);
914
- const artifact = await readArtifact(row);
1309
+ const payload = await readArtifactPayload(row);
1310
+ const artifact = payload.artifact;
915
1311
  const normalizedPath = assertSafeRelativePath(directoryPath);
916
1312
  const entries = directChildrenFromArtifact(artifact, normalizedPath);
917
1313
  return {
@@ -926,12 +1322,17 @@ export function workspaceBackupService(db) {
926
1322
  },
927
1323
  async readFile(orgId, backupId, filePath) {
928
1324
  const row = await getBackupRow(orgId, backupId);
929
- const artifact = await readArtifact(row);
1325
+ const payload = await readArtifactPayload(row);
1326
+ const artifact = payload.artifact;
930
1327
  const normalizedPath = assertSafeRelativePath(filePath);
931
1328
  const file = findArtifactFile(artifact, normalizedPath);
932
- if (!file?.dataBase64)
1329
+ if (!file)
1330
+ throw notFound("File not found inside the workspace backup");
1331
+ const buffer = payload.v2
1332
+ ? await readWorkspaceBackupV2File(payload.v2.filePath, payload.v2.index, normalizedPath)
1333
+ : file.dataBase64 ? Buffer.from(file.dataBase64, "base64") : null;
1334
+ if (!buffer)
933
1335
  throw notFound("File not found inside the workspace backup");
934
- const buffer = Buffer.from(file.dataBase64, "base64");
935
1336
  if (isBinaryBuffer(buffer)) {
936
1337
  return {
937
1338
  source: "org_root",
@@ -971,6 +1372,16 @@ export function workspaceBackupService(db) {
971
1372
  async getDownload(orgId, backupId) {
972
1373
  const row = await getBackupRow(orgId, backupId);
973
1374
  const payload = await readArtifactPayload(row);
1375
+ if (payload.v2) {
1376
+ return {
1377
+ artifactRef: row.artifactRef,
1378
+ filename: `${path.basename(row.artifactRef, path.extname(row.artifactRef))}.zip`,
1379
+ contentType: "application/zip",
1380
+ byteSize: payload.v2.index.archiveSize,
1381
+ archiveSha256: row.archiveSha256,
1382
+ contentStream: createReadStream(row.artifactRef),
1383
+ };
1384
+ }
974
1385
  const zip = buildWorkspaceBackupZip(payload.artifact, backupDownloadRootFolderName(payload.artifact));
975
1386
  return {
976
1387
  artifactRef: row.artifactRef,
@@ -1146,64 +1557,172 @@ export function workspaceBackupService(db) {
1146
1557
  };
1147
1558
  },
1148
1559
  async restore(orgId, backupId, input) {
1149
- const row = await getBackupRow(orgId, backupId);
1150
- const activeRunCount = await countActiveRuns(orgId);
1151
- if (activeRunCount > 0) {
1152
- throw conflict("Workspace restore is blocked while this organization has active runs.", { activeRunCount });
1153
- }
1154
- const artifact = await readArtifact(row);
1155
- const preRestoreBackup = await service.create({
1156
- orgId,
1157
- triggerSource: "pre_restore",
1158
- createdByUserId: input?.createdByUserId ?? null,
1159
- restoredFromBackupId: backupId,
1160
- });
1161
- if (preRestoreBackup.status !== "succeeded") {
1162
- throw conflict("Pre-restore backup failed; workspace was not changed.", { backupId: preRestoreBackup.id });
1163
- }
1164
- const activeRunCountAfterPreRestore = await countActiveRuns(orgId);
1165
- if (activeRunCountAfterPreRestore > 0) {
1166
- throw conflict("Workspace restore is blocked while this organization has active runs.", {
1167
- activeRunCount: activeRunCountAfterPreRestore,
1168
- preRestoreBackupId: preRestoreBackup.id,
1560
+ return withWorkspaceRestoreLock(orgId, async () => {
1561
+ await assertNoUnresolvedRestoreReceipt(orgId);
1562
+ const row = await getBackupRow(orgId, backupId);
1563
+ const activeRunCount = await countActiveRuns(orgId);
1564
+ if (activeRunCount > 0) {
1565
+ throw conflict("Workspace restore is blocked while this organization has active runs.", { activeRunCount });
1566
+ }
1567
+ const payload = await readArtifactPayload(row);
1568
+ const artifact = payload.artifact;
1569
+ const preRestoreBackup = await service.create({
1570
+ orgId,
1571
+ triggerSource: "pre_restore",
1572
+ createdByUserId: input?.createdByUserId ?? null,
1573
+ restoredFromBackupId: backupId,
1169
1574
  });
1170
- }
1171
- const workspaceRoot = resolveOrganizationWorkspaceRoot(orgId);
1172
- const stagingRoot = path.resolve(resolveDefaultBackupDir(), "workspace-restore-staging", `${orgId}-${backupId}-${Date.now()}`);
1173
- await fs.rm(stagingRoot, { recursive: true, force: true });
1174
- await fs.mkdir(stagingRoot, { recursive: true, mode: 0o700 });
1175
- try {
1176
- const directories = artifact.entries.filter((entry) => entry.kind === "directory");
1177
- const files = artifact.entries.filter((entry) => entry.kind === "file");
1178
- for (const entry of directories) {
1179
- const { resolvedTarget } = resolveWithinRoot(stagingRoot, entry.path);
1180
- await fs.mkdir(resolvedTarget, { recursive: true });
1575
+ if (preRestoreBackup.status !== "succeeded") {
1576
+ throw conflict("Pre-restore backup failed; workspace was not changed.", { backupId: preRestoreBackup.id });
1181
1577
  }
1182
- for (const entry of files) {
1183
- const { resolvedTarget } = resolveWithinRoot(stagingRoot, entry.path);
1184
- await fs.mkdir(path.dirname(resolvedTarget), { recursive: true });
1185
- await fs.writeFile(resolvedTarget, Buffer.from(entry.dataBase64 ?? "", "base64"), { mode: entry.mode ?? 0o600 });
1578
+ const activeRunCountAfterPreRestore = await countActiveRuns(orgId);
1579
+ if (activeRunCountAfterPreRestore > 0) {
1580
+ throw conflict("Workspace restore is blocked while this organization has active runs.", {
1581
+ activeRunCount: activeRunCountAfterPreRestore,
1582
+ preRestoreBackupId: preRestoreBackup.id,
1583
+ });
1186
1584
  }
1187
- await fs.rm(workspaceRoot, { recursive: true, force: true });
1188
- await fs.mkdir(path.dirname(workspaceRoot), { recursive: true });
1189
- await fs.rename(stagingRoot, workspaceRoot);
1190
- await ensureOrganizationWorkspaceLayout(orgId);
1191
- }
1192
- finally {
1585
+ const workspaceRoot = resolveOrganizationWorkspaceRoot(orgId);
1586
+ const operationId = crypto.randomUUID();
1587
+ const stagingRoot = path.resolve(path.dirname(workspaceRoot), `.rudder-workspace-restore-staging-${operationId}`);
1588
+ const rollbackRoot = path.resolve(path.dirname(workspaceRoot), `.rudder-workspace-restore-rollback-${operationId}`);
1589
+ const receiptRoot = path.resolve(resolveDefaultBackupDir(), "workspace-restore-receipts");
1590
+ const receiptPath = path.join(receiptRoot, `${orgId}-${operationId}.json`);
1193
1591
  await fs.rm(stagingRoot, { recursive: true, force: true });
1194
- }
1195
- const updatedAt = new Date();
1196
- const [restoredRow] = await db
1197
- .update(workspaceBackups)
1198
- .set({ status: "restored", updatedAt })
1199
- .where(eq(workspaceBackups.id, backupId))
1200
- .returning();
1201
- if (!restoredRow)
1202
- throw notFound("Workspace backup not found");
1203
- return {
1204
- restoredBackup: mapBackupRow(restoredRow),
1205
- preRestoreBackup,
1206
- };
1592
+ await fs.mkdir(stagingRoot, { recursive: true, mode: 0o700 });
1593
+ try {
1594
+ const directories = artifact.entries.filter((entry) => entry.kind === "directory");
1595
+ const files = artifact.entries.filter((entry) => entry.kind === "file");
1596
+ for (const entry of directories) {
1597
+ const { resolvedTarget } = resolveWithinRoot(stagingRoot, entry.path);
1598
+ await fs.mkdir(resolvedTarget, { recursive: true });
1599
+ }
1600
+ for (const entry of files) {
1601
+ const { resolvedTarget } = resolveWithinRoot(stagingRoot, entry.path);
1602
+ await fs.mkdir(path.dirname(resolvedTarget), { recursive: true });
1603
+ const data = payload.v2
1604
+ ? await readWorkspaceBackupV2File(payload.v2.filePath, payload.v2.index, entry.path)
1605
+ : Buffer.from(entry.dataBase64 ?? "", "base64");
1606
+ await fs.writeFile(resolvedTarget, data, { mode: entry.mode ?? 0o600 });
1607
+ }
1608
+ await fs.mkdir(path.dirname(workspaceRoot), { recursive: true });
1609
+ await fs.mkdir(receiptRoot, { recursive: true, mode: 0o700 });
1610
+ const stagingTreeSha256 = await workspaceTreeSha256(stagingRoot);
1611
+ const liveTreeSha256 = await pathExists(workspaceRoot) ? await workspaceTreeSha256(workspaceRoot) : null;
1612
+ await writeRestoreReceipt(receiptPath, {
1613
+ version: 1,
1614
+ operationId,
1615
+ orgId,
1616
+ backupId,
1617
+ phase: "prepared",
1618
+ workspaceRoot,
1619
+ stagingRoot,
1620
+ rollbackRoot,
1621
+ liveTreeSha256,
1622
+ stagingTreeSha256,
1623
+ expectedTreeSha256: row.treeSha256,
1624
+ preRestoreBackupId: preRestoreBackup.id,
1625
+ });
1626
+ if (await countActiveRuns(orgId) > 0) {
1627
+ throw conflict("Workspace restore is blocked while this organization has active runs.");
1628
+ }
1629
+ const liveExists = await pathExists(workspaceRoot);
1630
+ if (liveExists && !liveExists.isDirectory()) {
1631
+ throw conflict("Workspace restore is blocked because the organization workspace root is not a directory.");
1632
+ }
1633
+ if (liveExists) {
1634
+ await fs.rename(workspaceRoot, rollbackRoot);
1635
+ await syncDirectory(path.dirname(workspaceRoot));
1636
+ await writeRestoreReceipt(receiptPath, {
1637
+ version: 1, operationId, orgId, backupId, phase: "live_moved", workspaceRoot, stagingRoot, rollbackRoot,
1638
+ expectedTreeSha256: row.treeSha256, preRestoreBackupId: preRestoreBackup.id,
1639
+ liveTreeSha256, stagingTreeSha256,
1640
+ });
1641
+ }
1642
+ try {
1643
+ await fs.rename(stagingRoot, workspaceRoot);
1644
+ await syncDirectory(path.dirname(workspaceRoot));
1645
+ }
1646
+ catch (error) {
1647
+ if (liveExists) {
1648
+ try {
1649
+ await fs.rename(rollbackRoot, workspaceRoot);
1650
+ await syncDirectory(path.dirname(workspaceRoot));
1651
+ await writeRestoreReceipt(receiptPath, {
1652
+ version: 1, operationId, orgId, backupId, phase: "rolled_back", workspaceRoot, stagingRoot, rollbackRoot,
1653
+ expectedTreeSha256: row.treeSha256, preRestoreBackupId: preRestoreBackup.id,
1654
+ liveTreeSha256, stagingTreeSha256,
1655
+ });
1656
+ throw conflict("Workspace restore failed and was rolled back.", { code: "restore_rolled_back", operationId });
1657
+ }
1658
+ catch (rollbackError) {
1659
+ if (rollbackError instanceof Error && "status" in rollbackError && rollbackError.status === 409)
1660
+ throw rollbackError;
1661
+ const receipt = {
1662
+ version: 1, operationId, orgId, backupId, phase: "recovery_required", workspaceRoot, stagingRoot, rollbackRoot,
1663
+ expectedTreeSha256: row.treeSha256, preRestoreBackupId: preRestoreBackup.id,
1664
+ liveTreeSha256, stagingTreeSha256,
1665
+ };
1666
+ await writeRestoreReceipt(receiptPath, receipt);
1667
+ throw restoreRecoveryRequired("Workspace restore requires recovery after a failed rollback.", receipt, rollbackError);
1668
+ }
1669
+ }
1670
+ throw error;
1671
+ }
1672
+ await ensureOrganizationWorkspaceLayout(orgId);
1673
+ const publishedTreeSha256 = await workspaceTreeSha256(workspaceRoot);
1674
+ if (row.treeSha256 && publishedTreeSha256 !== row.treeSha256) {
1675
+ await fs.rm(workspaceRoot, { recursive: true, force: true });
1676
+ if (liveExists) {
1677
+ try {
1678
+ await fs.rename(rollbackRoot, workspaceRoot);
1679
+ await syncDirectory(path.dirname(workspaceRoot));
1680
+ await writeRestoreReceipt(receiptPath, {
1681
+ version: 1, operationId, orgId, backupId, phase: "rolled_back", workspaceRoot, stagingRoot, rollbackRoot,
1682
+ expectedTreeSha256: row.treeSha256, preRestoreBackupId: preRestoreBackup.id,
1683
+ liveTreeSha256, stagingTreeSha256, publishedTreeSha256,
1684
+ });
1685
+ throw conflict("Workspace restore failed published tree verification and was rolled back.", { code: "restore_rolled_back", operationId });
1686
+ }
1687
+ catch (rollbackError) {
1688
+ if (rollbackError instanceof Error && "status" in rollbackError && rollbackError.status === 409)
1689
+ throw rollbackError;
1690
+ const receipt = {
1691
+ version: 1, operationId, orgId, backupId, phase: "recovery_required", workspaceRoot, stagingRoot, rollbackRoot,
1692
+ expectedTreeSha256: row.treeSha256, preRestoreBackupId: preRestoreBackup.id,
1693
+ liveTreeSha256, stagingTreeSha256, publishedTreeSha256,
1694
+ };
1695
+ await writeRestoreReceipt(receiptPath, receipt);
1696
+ throw restoreRecoveryRequired("Workspace restore requires recovery after published-tree verification failed.", receipt, rollbackError);
1697
+ }
1698
+ }
1699
+ throw conflict("Workspace restore failed published tree verification.", { code: "restore_rolled_back", operationId });
1700
+ }
1701
+ await writeRestoreReceipt(receiptPath, {
1702
+ version: 1, operationId, orgId, backupId, phase: "committed", workspaceRoot, stagingRoot, rollbackRoot,
1703
+ expectedTreeSha256: row.treeSha256, liveTreeSha256, stagingTreeSha256, publishedTreeSha256, preRestoreBackupId: preRestoreBackup.id,
1704
+ });
1705
+ await syncDirectory(path.dirname(workspaceRoot));
1706
+ if (liveExists)
1707
+ await fs.rm(rollbackRoot, { recursive: true, force: true });
1708
+ await fs.rm(receiptPath, { force: true });
1709
+ }
1710
+ finally {
1711
+ await fs.rm(stagingRoot, { recursive: true, force: true });
1712
+ }
1713
+ const updatedAt = new Date();
1714
+ const [restoredRow] = await db
1715
+ .update(workspaceBackups)
1716
+ .set({ status: "restored", updatedAt })
1717
+ .where(eq(workspaceBackups.id, backupId))
1718
+ .returning();
1719
+ if (!restoredRow)
1720
+ throw notFound("Workspace backup not found");
1721
+ return {
1722
+ restoredBackup: mapBackupRow(restoredRow),
1723
+ preRestoreBackup,
1724
+ };
1725
+ });
1207
1726
  },
1208
1727
  };
1209
1728
  return service;