dodo-mcp 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +45 -6
  2. package/dist/cli/dataRecovery.js +38 -0
  3. package/dist/cli/dataRecovery.js.map +1 -0
  4. package/dist/cli/deployment.js +61 -0
  5. package/dist/cli/deployment.js.map +1 -0
  6. package/dist/cli/main.js +35 -1
  7. package/dist/cli/main.js.map +1 -1
  8. package/dist/config/globalConfig.js +8 -0
  9. package/dist/config/globalConfig.js.map +1 -1
  10. package/dist/errors.js +1 -1
  11. package/dist/errors.js.map +1 -1
  12. package/dist/ipc/server.js +2 -0
  13. package/dist/ipc/server.js.map +1 -1
  14. package/dist/ipc/timeouts.js +5 -0
  15. package/dist/ipc/timeouts.js.map +1 -0
  16. package/dist/platform/privateFs.js +32 -3
  17. package/dist/platform/privateFs.js.map +1 -1
  18. package/dist/platform/windowsPrivateAcl.js +90 -0
  19. package/dist/platform/windowsPrivateAcl.js.map +1 -0
  20. package/dist/security/outboundAddress.js +20 -0
  21. package/dist/security/outboundAddress.js.map +1 -0
  22. package/dist/server/aiAdmin.js +35 -10
  23. package/dist/server/aiAdmin.js.map +1 -1
  24. package/dist/server/appServer.js +11 -3
  25. package/dist/server/appServer.js.map +1 -1
  26. package/dist/server/bootstrap.js +14 -0
  27. package/dist/server/bootstrap.js.map +1 -1
  28. package/dist/server/configUi/index.html +3 -0
  29. package/dist/server/configUi/ui/dataRecovery.js +90 -0
  30. package/dist/server/configUi/ui/deployment.js +111 -0
  31. package/dist/server/configUi/ui/recovery.js +167 -0
  32. package/dist/server/configUi/workbench.css +28 -1
  33. package/dist/server/configUi/workbench.js +136 -25
  34. package/dist/server/installationRuntime.js +3 -1
  35. package/dist/server/installationRuntime.js.map +1 -1
  36. package/dist/server/instructions.js +6 -2
  37. package/dist/server/instructions.js.map +1 -1
  38. package/dist/server/ipcDispatch.js +219 -0
  39. package/dist/server/ipcDispatch.js.map +1 -1
  40. package/dist/server/localConfig.js +1 -1
  41. package/dist/server/localConfig.js.map +1 -1
  42. package/dist/server/stdioServer.js +11 -3
  43. package/dist/server/stdioServer.js.map +1 -1
  44. package/dist/services/ai/network.js +1 -18
  45. package/dist/services/ai/network.js.map +1 -1
  46. package/dist/services/ai/subagents.js +5 -2
  47. package/dist/services/ai/subagents.js.map +1 -1
  48. package/dist/services/assistance/verification.js +43 -10
  49. package/dist/services/assistance/verification.js.map +1 -1
  50. package/dist/services/changes/applier.js +472 -297
  51. package/dist/services/changes/applier.js.map +1 -1
  52. package/dist/services/git/gitService.js +150 -34
  53. package/dist/services/git/gitService.js.map +1 -1
  54. package/dist/services/jobs/jobManager.js +59 -2
  55. package/dist/services/jobs/jobManager.js.map +1 -1
  56. package/dist/services/jobs/spool.js +9 -3
  57. package/dist/services/jobs/spool.js.map +1 -1
  58. package/dist/services/multimodal/mediaService.js +6 -6
  59. package/dist/services/multimodal/mediaService.js.map +1 -1
  60. package/dist/services/recovery/configKeys.js +115 -0
  61. package/dist/services/recovery/configKeys.js.map +1 -0
  62. package/dist/services/recovery/configVault.js +361 -0
  63. package/dist/services/recovery/configVault.js.map +1 -0
  64. package/dist/services/recovery/contracts.js +24 -0
  65. package/dist/services/recovery/contracts.js.map +1 -0
  66. package/dist/services/recovery/databaseAwareness.js +179 -0
  67. package/dist/services/recovery/databaseAwareness.js.map +1 -0
  68. package/dist/services/recovery/deploymentArchive.js +129 -0
  69. package/dist/services/recovery/deploymentArchive.js.map +1 -0
  70. package/dist/services/recovery/deploymentContracts.js +70 -0
  71. package/dist/services/recovery/deploymentContracts.js.map +1 -0
  72. package/dist/services/recovery/deploymentHealth.js +143 -0
  73. package/dist/services/recovery/deploymentHealth.js.map +1 -0
  74. package/dist/services/recovery/deploymentMaintenance.js +197 -0
  75. package/dist/services/recovery/deploymentMaintenance.js.map +1 -0
  76. package/dist/services/recovery/deploymentService.js +429 -0
  77. package/dist/services/recovery/deploymentService.js.map +1 -0
  78. package/dist/services/recovery/deploymentSource.js +36 -0
  79. package/dist/services/recovery/deploymentSource.js.map +1 -0
  80. package/dist/services/recovery/dockerDeployment.js +172 -0
  81. package/dist/services/recovery/dockerDeployment.js.map +1 -0
  82. package/dist/services/recovery/drift.js +178 -0
  83. package/dist/services/recovery/drift.js.map +1 -0
  84. package/dist/services/recovery/evidence.js +127 -0
  85. package/dist/services/recovery/evidence.js.map +1 -0
  86. package/dist/services/recovery/gitCopies.js +219 -0
  87. package/dist/services/recovery/gitCopies.js.map +1 -0
  88. package/dist/services/recovery/history.js +423 -0
  89. package/dist/services/recovery/history.js.map +1 -0
  90. package/dist/services/recovery/recoveryService.js +521 -0
  91. package/dist/services/recovery/recoveryService.js.map +1 -0
  92. package/dist/services/recovery/storage.js +344 -0
  93. package/dist/services/recovery/storage.js.map +1 -0
  94. package/dist/services/schedules/scheduleService.js +9 -10
  95. package/dist/services/schedules/scheduleService.js.map +1 -1
  96. package/dist/store/db.js +71 -0
  97. package/dist/store/db.js.map +1 -1
  98. package/dist/tools/catalog.js +4 -0
  99. package/dist/tools/catalog.js.map +1 -1
  100. package/dist/tools/changeTools.js +1 -1
  101. package/dist/tools/changeTools.js.map +1 -1
  102. package/dist/tools/context.js +23 -3
  103. package/dist/tools/context.js.map +1 -1
  104. package/dist/tools/deploymentTools.js +58 -0
  105. package/dist/tools/deploymentTools.js.map +1 -0
  106. package/dist/tools/directTools.js +9 -5
  107. package/dist/tools/directTools.js.map +1 -1
  108. package/dist/tools/gitTools.js +7 -2
  109. package/dist/tools/gitTools.js.map +1 -1
  110. package/dist/tools/jobTools.js +4 -4
  111. package/dist/tools/jobTools.js.map +1 -1
  112. package/dist/tools/projectTools.js +1 -0
  113. package/dist/tools/projectTools.js.map +1 -1
  114. package/dist/tools/recoveryTools.js +79 -0
  115. package/dist/tools/recoveryTools.js.map +1 -0
  116. package/dist/tools/runtimeTools.js +1 -1
  117. package/dist/tools/runtimeTools.js.map +1 -1
  118. package/dist/tools/surface.js +101 -41
  119. package/dist/tools/surface.js.map +1 -1
  120. package/dist/tunnel/credentials.js +1 -1
  121. package/dist/tunnel/credentials.js.map +1 -1
  122. package/docs/AI_PROVIDERS.md +6 -3
  123. package/docs/ARCHITECTURE.md +88 -4
  124. package/docs/CI.md +118 -0
  125. package/docs/COMPATIBILITY.md +7 -6
  126. package/docs/EVALUATION.md +17 -12
  127. package/docs/MANUAL_ACCEPTANCE.md +59 -2
  128. package/docs/RECOVERY.md +448 -0
  129. package/docs/RELEASE_1.2.1.md +47 -0
  130. package/docs/RELEASE_1.3.0.md +64 -0
  131. package/docs/RELEASE_NOTES.md +41 -0
  132. package/docs/SECURITY.md +122 -0
  133. package/docs/TEST_REPORT.md +327 -5
  134. package/docs/WEB_CLIENTS.md +6 -1
  135. package/docs/adr/051-source-recovery-foundation.md +45 -0
  136. package/docs/adr/052-reviewed-source-restore.md +50 -0
  137. package/docs/adr/053-content-drift-and-private-git-recovery.md +29 -0
  138. package/docs/adr/054-verified-source-checkpoints.md +58 -0
  139. package/docs/adr/055-reviewed-docker-deployments.md +42 -0
  140. package/docs/adr/056-owner-data-recovery.md +47 -0
  141. package/docs/adr/README.md +7 -0
  142. package/native/private-state-windows.cs +105 -0
  143. package/package.json +7 -1
  144. package/schemas/global-config.schema.json +44 -0
  145. package/schemas/tools.compact.json +175 -15
  146. package/schemas/tools.hybrid.json +350 -16
  147. package/schemas/tools.json +4883 -816
@@ -0,0 +1,90 @@
1
+ /* Private owner data controls. Separate opt-in; no database writes or credentials in the browser. */
2
+ 'use strict';
3
+ window.DodoDataRecovery=({el,section,button,check,field,choice,confirmDanger})=>async(parent,act,selected)=>{
4
+ const card=section('Database · ตรวจความเข้ากันได้','อ่านเฉพาะ migration metadata ที่เจ้าของเลือก ไม่ย้อนข้อมูล ไม่รัน SQL หรือ migration อัตโนมัติ');parent.append(card);
5
+ const state=el('p','กำลังอ่านการตั้งค่า…');state.setAttribute('role','status');card.append(state);
6
+ const reviewed=input=>({workspaceId:selected.workspaceId,workspaceEpoch:selected.workspaceEpoch,confirm:true,input});
7
+ try{
8
+ const result=await act('recovery.database.list');
9
+ state.textContent=result.configured?`${result.targetCount} ปลายทาง · ยังไม่รับรองความเข้ากันได้จนตรวจ checkpoint`:'ยังไม่เปิดอ่านฐานข้อมูล · Source backup ไม่รวมข้อมูล DB';
10
+ const advanced=el('details');advanced.append(el('summary','เพิ่มปลายทาง SQLite แบบอ่านอย่างเดียว'));card.append(advanced);
11
+ const name=field(advanced,'ชื่อฐานข้อมูลสำหรับการตรวจ','recoveryDbName',''),file=field(advanced,'ไฟล์ .sqlite/.db ภายในโปรเจกต์','recoveryDbFile','');
12
+ const table=field(advanced,'ตาราง migration IDs','recoveryDbTable','migrations'),column=field(advanced,'คอลัมน์ migration ID','recoveryDbColumn','id');
13
+ const policy=choice(advanced,'เมื่อ schema ไม่ตรงหรือยังตรวจไม่ได้','recoveryDbPolicy',[['block','หยุด source restore จนตรวจได้'],['warn','แจ้งเตือน แต่เจ้าของยอมให้ restore source']],'block');
14
+ advanced.append(button('อนุญาตให้อ่าน migration metadata',async()=>{
15
+ if(!await confirmDanger('เปิดการตรวจฐานข้อมูลนี้?',`${file.value} · อ่านอย่างเดียว ไม่มีการสำรองหรือแก้แถวข้อมูล`,'อนุญาตให้อ่าน'))return;
16
+ const saved=await act('recovery.database.configure',reviewed({expectedRevision:0,enabled:true,confirmReadOnlyAccess:true,definition:{name:name.value,adapter:'sqlite-migration-table',databaseFile:file.value,table:table.value,column:column.value,onMismatch:policy.value}}));
17
+ state.textContent=`บันทึก ${saved.id} revision ${saved.revision} · รีเฟรชหน้าโปรเจกต์เพื่อผูก checkpoint`;
18
+ }));
19
+ for(const target of result.items){
20
+ const d=section(target.definition.name,`${target.enabled?'เปิด':'ปิด'} · ${target.definition.databaseFile} · revision ${target.revision}`);card.append(d);
21
+ d.append(button('อ่าน migration IDs ปัจจุบัน',async()=>{const out=await act('recovery.database.inspect',reviewed({targetId:target.id}));const view=el('details');view.open=true;view.append(el('summary','Metadata ที่อ่านจริง'),el('pre',JSON.stringify(out,null,2)));d.append(view);}));
22
+ d.append(button(target.enabled?'ปิดการตรวจปลายทางนี้':'เปิดการตรวจปลายทางนี้',async()=>{
23
+ if(!await confirmDanger('เปลี่ยนการตรวจ schema?',target.enabled?'เมื่อปิด ผล compatibility จะเป็น UNKNOWN และไม่ใช้ปลายทางนี้บล็อก source restore':'จะตรวจ readiness แบบอ่านอย่างเดียวก่อนเปิด','บันทึก'))return;
24
+ const out=await act('recovery.database.configure',reviewed({targetId:target.id,expectedRevision:target.revision,enabled:!target.enabled,confirmReadOnlyAccess:true,definition:target.definition}));state.textContent=`บันทึก revision ${out.revision} แล้ว · รีเฟรชก่อนแก้ครั้งถัดไป`;
25
+ }));
26
+ const bind=el('details');bind.append(el('summary','กำหนด compatibility ของ checkpoint โดยเจ้าของ'));d.append(bind);
27
+ const checkpoint=field(bind,'Checkpoint ID','dbCheckpoint-'+target.id,''),ids=field(bind,'Migration IDs ที่ source นี้ต้องใช้ (คั่นด้วย comma)','dbIds-'+target.id,'');
28
+ const extra=check(bind,'ยอมรับ migration เพิ่มเติมนอกจากที่ระบุ','dbExtra-'+target.id,false);
29
+ bind.append(button('บันทึกกติกา checkpoint',async()=>{
30
+ if(!await confirmDanger('รับรองกติกาความเข้ากันได้?',checkpoint.value+' · เป็นกติกาที่เจ้าของระบุ ไม่ใช่ผลเดาจาก SQL หรือ AI','บันทึกกติกา'))return;
31
+ const out=await act('recovery.database.bind',reviewed({targetId:target.id,expectedRevision:target.revision,checkpointId:checkpoint.value,requiredMigrationIds:ids.value.split(',').map(s=>s.trim()).filter(Boolean),allowExtra:extra.checked,confirmCompatibilityRule:true}));
32
+ state.textContent=`ผูก ${out.checkpointId} กับ ${out.requiredCount} migration IDs แล้ว · preview source restore ใหม่เพื่อดูผล`;
33
+ }));
34
+ bind.append(button('ถอนกติกาของ checkpoint นี้',async()=>{
35
+ if(!await confirmDanger('ถอนกติกาความเข้ากันได้?',checkpoint.value+' · กลับเป็น UNKNOWN และจุดกู้คืนนี้อาจหมดอายุตาม retention ถ้าไม่มีรายการอื่นอ้างถึง','ถอนกติกา'))return;
36
+ const out=await act('recovery.database.unbind',reviewed({targetId:target.id,expectedRevision:target.revision,checkpointId:checkpoint.value,confirmCompatibilityRemoval:true}));state.textContent=out.removed?'ถอนกติกาแล้ว · ไม่ได้เปลี่ยน DB':'ไม่มีกติกาที่ตรงกับ checkpoint นี้';
37
+ }));
38
+ }
39
+ }catch(e){state.textContent='อ่านการตั้งค่าฐานข้อมูลไม่สำเร็จ: '+e.message;state.setAttribute('role','alert');}
40
+ const vault=section('Private config · สำรองแบบเข้ารหัส','ปิดเป็นค่าเริ่มต้นสำหรับข้อมูลลับ · เปิดแยกเป็นรายไฟล์โดยเจ้าของ เก็บ key ใน Keychain / Credential Manager / Secret Service ไม่มี plaintext export ให้ AI');parent.append(vault);
41
+ const vaultState=el('p','กำลังอ่านรายการสำรอง…');vaultState.setAttribute('role','status');const vaultBody=el('div');vault.append(vaultState,vaultBody);
42
+ async function loadVault(){
43
+ try{
44
+ const data=await act('recovery.config.list');vaultBody.replaceChildren();
45
+ vaultState.textContent=data.targets.length?`${data.targets.length} ไฟล์ที่ลงทะเบียน · แสดงสำเนาล่าสุด ${data.backups.length} รายการ`:'ยังไม่ลงทะเบียนไฟล์ลับ · Source backup ยังคงไม่รวม secrets';
46
+ const form=el('details');form.append(el('summary','ลงทะเบียนไฟล์ลับสำหรับสำรอง'));vaultBody.append(form);
47
+ const name=field(form,'ชื่อรายการ config','configVaultName',''),file=field(form,'Path ภายในโปรเจกต์ เช่น .env','configVaultPath','');
48
+ const retention=field(form,'จำนวนสำเนาที่เก็บ (2–100; สำเนาที่มีแผนกู้คืนอ้างถึงจะยังเก็บ)','configVaultRetention','10','number');retention.min='2';retention.max='100';
49
+ form.append(el('p','เลือกได้เฉพาะไฟล์ private ที่ source tools อ่านไม่ได้ ขนาดไม่เกิน 1 MiB · หาก OS key store ใช้ไม่ได้จะไม่เปิดการสำรอง'));
50
+ form.append(button('เปิดสำรองไฟล์ลับนี้',async()=>{
51
+ if(!await confirmDanger('เปิดสำรองแบบเข้ารหัส?',file.value+' · Key เก็บใน OS key store ของผู้ใช้นี้ หาก key หายจะถอดสำเนาไม่ได้','เปิดเฉพาะไฟล์นี้'))return;
52
+ const out=await act('recovery.config.configure',reviewed({expectedRevision:0,enabled:true,confirmEncryptedPrivateBackup:true,definition:{name:name.value,path:file.value,retention:Number(retention.value)}}));
53
+ await loadVault();vaultState.textContent=`ลงทะเบียน ${out.id} revision ${out.revision} แล้ว · ยังไม่สร้างสำเนาจนกดสำรอง`;
54
+ }));
55
+ for(const target of data.targets){
56
+ const box=section(target.definition.name,`${target.enabled?'เปิด':'ปิด'} · ${target.definition.path} · revision ${target.revision}`);vaultBody.append(box);
57
+ if(target.enabled)box.append(button('สำรอง config ตอนนี้',async()=>{const out=await act('recovery.config.backup',reviewed({targetId:target.id}));await loadVault();vaultState.textContent=`สร้างสำเนาเข้ารหัส ${out.id} · ${out.bytes} bytes`; }));
58
+ box.append(button(target.enabled?'ปิดการสำรอง config':'เปิดการสำรอง config',async()=>{
59
+ if(!await confirmDanger('เปลี่ยนการสำรองไฟล์ลับ?',target.definition.path+' · สำเนาเดิมยังเก็บไว้ การปิดจะระงับการกู้คืนจนเปิดใหม่','บันทึก'))return;
60
+ const out=await act('recovery.config.configure',reviewed({targetId:target.id,expectedRevision:target.revision,enabled:!target.enabled,definition:target.definition,confirmEncryptedPrivateBackup:true}));await loadVault();vaultState.textContent=`บันทึก revision ${out.revision} · ${out.enabled?'เปิด':'ปิด'}`;
61
+ }));
62
+ box.append(button('หมุนเวียน key',async()=>{
63
+ if(!await confirmDanger('สร้าง key ใหม่?', 'สำเนาเก่ายังต้องใช้ key เดิม ห้ามลบ key เดิมออกจาก OS store จนเลิกใช้สำเนาที่อ้างถึง','สร้าง key ใหม่'))return;
64
+ const out=await act('recovery.config.rotate',reviewed({targetId:target.id,expectedRevision:target.revision}));await loadVault();vaultState.textContent=`สร้าง key ใหม่แล้ว revision ${out.revision} · ไม่เข้ารหัสสำเนาเก่าซ้ำ`;
65
+ }));
66
+ const keep=field(box,'จำนวนสำเนาที่เก็บสำหรับ '+target.definition.name,'configKeep-'+target.id,String(target.definition.retention),'number');keep.min='2';keep.max='100';
67
+ box.append(button('บันทึก retention ของ config',async()=>{
68
+ if(!await confirmDanger('เปลี่ยนจำนวนสำเนาที่เก็บ?', 'สำเนาเก่าที่เกินจำนวนนี้และไม่มีแผนกู้คืนอ้างถึงจะถูกลบจาก state โดยไม่ลบ key เก่าออกจาก OS store','บันทึก retention'))return;
69
+ const out=await act('recovery.config.configure',reviewed({targetId:target.id,expectedRevision:target.revision,enabled:target.enabled,definition:{...target.definition,retention:Number(keep.value)},confirmEncryptedPrivateBackup:true}));await loadVault();vaultState.textContent=`บันทึก retention แล้ว revision ${out.revision} · สำเนาที่แผนกู้คืนอ้างถึงยังเก็บไว้`;
70
+ }));
71
+ const backups=data.backups.filter(b=>b.target_id===target.id);if(!backups.length)box.append(el('p','ยังไม่มีสำเนาเข้ารหัส'));
72
+ for(const backup of backups){
73
+ const row=el('div');row.append(el('p',`${backup.id} · ${new Date(backup.created_at).toLocaleString('th-TH')} · ${backup.bytes} bytes`));box.append(row);
74
+ if(target.enabled)row.append(button('ตรวจแผนคืน config',async()=>{
75
+ const plan=await act('recovery.config.preview',reviewed({backupId:backup.id}));const result=el('section');
76
+ result.append(el('h4','แผนคืนค่า · ไม่แสดงเนื้อหาลับ'),el('p',`${plan.changed?'เนื้อหาต่างจากปัจจุบัน':'เนื้อหาตรงกับปัจจุบัน'} · ${plan.bytesBefore} → ${plan.bytesAfter} bytes · หมดอายุ ${new Date(plan.expiresAt).toLocaleTimeString('th-TH')}`));
77
+ result.append(el('p','หยุดโปรแกรมที่อ่าน/เขียน config นี้ก่อนยืนยัน ระบบสำรองค่าปัจจุบันแบบเข้ารหัสก่อนเขียนทับไฟล์เดิม หากเครื่องดับอาจเหลือบางส่วนและจะรายงาน UNKNOWN ไม่เขียนซ้ำอัตโนมัติ ไม่ restart โปรแกรมให้'));
78
+ result.append(button('ยืนยันคืนค่า config',async()=>{
79
+ if(!await confirmDanger('คืนค่าไฟล์ลับตามแผนนี้?',target.definition.path+' · ยืนยันว่าหยุดโปรแกรมที่ใช้ไฟล์นี้แล้ว สำเนาก่อนคืนค่าจะเข้ารหัสด้วย','คืนค่า config'))return;
80
+ const out=await act('recovery.config.apply',reviewed({planId:plan.planId,planHash:plan.planHash}));await loadVault();
81
+ vaultState.textContent=out.state==='APPLIED'?`อ่านกลับตรงกับสำเนา · ${plan.planId} · ยังไม่ได้ restart โปรแกรม`:`สถานะ ${out.state} · ตรวจไฟล์ในเครื่องและสำเนาก่อนคืนค่า ห้ามส่งแผนเดิมซ้ำเพื่อหวังให้เขียนใหม่`;
82
+ }));row.append(result);
83
+ }));
84
+ }
85
+ }
86
+ if(data.restores.length){const details=el('details');details.append(el('summary','ประวัติแผนและสถานะการกู้คืน'));for(const r of data.restores)details.append(el('p',`${r.id} · ${r.state} · สำเนาก่อนเขียน: ${r.before_id||'ยังไม่มี'}`));vaultBody.append(details);}
87
+ }catch(e){vaultState.textContent='อ่านรายการ config ไม่สำเร็จ: '+e.message;vaultState.setAttribute('role','alert');}
88
+ }
89
+ await loadVault();
90
+ };
@@ -0,0 +1,111 @@
1
+ /* Private owner deployment controls. No credentials, raw job output or model HTML. */
2
+ 'use strict';
3
+ window.DodoDeployment=({el,section,button,check,field,choice,notice,confirmDanger})=>async(parent,act,selected)=>{
4
+ const box=section('Deployment · Docker','ตั้งปลายทางโดยเจ้าของก่อน Build และ Deploy คำสั่งอาจมีผลนอก workspace ตามสิทธิ์ Docker ของบัญชีนี้ ยังใช้ sandbox และ approval เดิม');parent.append(box);
5
+ const status=el('p','กำลังอ่านปลายทาง…','wb-status');status.setAttribute('role','status');box.append(status);
6
+ const reviewed=input=>({workspaceId:selected.workspaceId,workspaceEpoch:selected.workspaceEpoch,confirm:true,input});
7
+ try{
8
+ const targets=await act('deployment.targets');status.textContent=targets.items.length?`${targets.items.length} ปลายทางที่เจ้าของลงทะเบียน · ผล health เป็นหลักฐาน ณ เวลาที่ตรวจ`:'ยังไม่มีปลายทาง · การสำรอง source ยังทำงานตามปกติ';
9
+ const setup=el('details');setup.append(el('summary','เพิ่มหรือแก้ปลายทาง'));box.append(setup);
10
+ const form=el('form',undefined,'wb-form');form.onsubmit=e=>e.preventDefault();setup.append(form);
11
+ const targetPicker=choice(form,'ปลายทางที่แก้ไข','deploymentTarget',[['','เพิ่มปลายทางใหม่'],...targets.items.map(t=>[t.id,t.definition.name])],'');
12
+ const name=field(form,'ชื่อปลายทาง (a-z, 0-9, -)','deploymentName','web');
13
+ const context=field(form,'Docker context','dockerContext','default'),project=field(form,'Compose project','composeProject',''),service=field(form,'Compose service','composeService','web');
14
+ const root=field(form,'โฟลเดอร์ build ภายในโปรเจกต์','buildRoot','.'),dockerfile=field(form,'Dockerfile ภายในโฟลเดอร์ build','dockerfile','Dockerfile');
15
+ const source=field(form,'โฟลเดอร์ source ใน image (เว้นว่างหากไม่รองรับกู้ source)','containerSource','');
16
+ const health=field(form,'Health URL','healthUrl',''),privateNet=check(form,'อนุญาต Health URL นี้เข้าปลายทาง localhost/LAN','deploymentPrivate',false);
17
+ const published=field(form,'พอร์ตเครื่องสำหรับ service (เว้นว่างหากไม่ publish)','publishedPort','','number'),containerPort=field(form,'พอร์ตใน container','containerPort','3000','number');
18
+ const task=choice(form,'Required check ก่อน build','deploymentCheck',targets.recipes.map(t=>[t.taskId,t.taskId]));
19
+ if(!targets.recipes.length)form.append(el('p','ยังไม่มี task recipe ตั้ง test script ของโปรเจกต์ก่อนลงทะเบียน target'));
20
+ const enabled=check(form,'เปิดปลายทางนี้','deploymentEnabled',true);
21
+ const advanced=el('details');advanced.append(el('summary','ตัวเลือกขั้นสูง: definition JSON'));form.append(advanced);
22
+ const json=field(advanced,'Definition JSON (เว้นว่างเพื่อใช้ช่องด้านบน)','deploymentDefinition','','textarea');
23
+ advanced.append(el('p','ใช้สำหรับ health หลายรายการ, OpenAPI contract, volume ที่มีอยู่แล้ว และงบ image ห้ามใส่ credentials หรือ Docker socket path'));
24
+ let editing;
25
+ targetPicker.onchange=()=>{editing=targets.items.find(t=>t.id===targetPicker.value);json.value=editing?JSON.stringify(editing.definition,null,2):'';enabled.checked=editing?.enabled??true;};
26
+ form.append(button('บันทึกปลายทาง',async()=>{
27
+ const recipe=targets.recipes.find(t=>t.taskId===task.value);
28
+ let definition;
29
+ if(json.value.trim()){try{definition=JSON.parse(json.value);}catch{throw new Error('Definition JSON ไม่ถูกต้อง');}}
30
+ else {if(!recipe)throw new Error('ต้องเลือก required check');definition={name:name.value,adapter:'docker-compose',dockerContext:context.value,composeProject:project.value,service:service.value,contextRoot:root.value,dockerfile:dockerfile.value,requiredChecks:[recipe],health:[{id:'ready',kind:'http',url:health.value,allowPrivateNetwork:privateNet.checked}],...(source.value?{sourceMapping:{workspaceRoot:root.value,containerRoot:source.value}}:{}),ports:published.value?[{host:'127.0.0.1',published:Number(published.value),target:Number(containerPort.value)}]:[]};}
31
+ if(!await confirmDanger('บันทึกปลายทาง Docker?',`ปลายทาง ${definition.name} จะใช้ Docker daemon ตาม context ที่ระบุ การบันทึกยังไม่ Build หรือ Deploy`,'บันทึกปลายทาง'))return;
32
+ const result=await act('deployment.configure',reviewed({...(editing?{targetId:editing.id}:{}),expectedRevision:editing?.revision??0,enabled:enabled.checked,definition,confirmDaemonAccess:true}));
33
+ status.textContent=`บันทึก ${result.definition.name} revision ${result.revision} แล้ว · กดรีเฟรชเพื่อใช้รายการล่าสุด`;notice('บันทึกปลายทางที่เจ้าของตรวจแล้ว');
34
+ }));
35
+ async function maintenancePreview(input,label,parent){
36
+ const review=await act('deployment.maintenance',reviewed(input)),details=el('details');details.open=true;
37
+ details.append(el('summary',label+' — ยังไม่ดำเนินการ'),el('pre',JSON.stringify(review,null,2),'wb-output'));parent.append(details);
38
+ details.append(button('ยืนยันตามแผนนี้',async()=>{
39
+ if(!await confirmDanger(label+'?',review.reviewId+' · ตรวจสถานะจริงซ้ำก่อนใช้แผนนี้ ไม่สั่งซ้ำหากผลไม่แน่นอน','ยืนยัน'))return;
40
+ const outcome=await act('deployment.maintenance',reviewed({action:'apply',reviewId:review.reviewId,reviewHash:review.reviewHash}));
41
+ details.append(el('p',outcome.state==='COMPLETED'?'ดำเนินการตามแผนแล้ว · ไม่ใช่การรับรองว่า production healthy':'ผลยังไม่แน่นอน ต้องตรวจสถานะจริงก่อนดำเนินการต่อ'));
42
+ }));
43
+ }
44
+ const retention=el('details');retention.append(el('summary','การเก็บ image และล้างเฉพาะรายการที่ไม่มีการอ้างอิง'));box.append(retention);
45
+ retention.append(el('p','เก็บตามงบของ target พร้อมป้องกัน image ที่ปักหมุด, ใช้งานใน container, known-good ล่าสุด/ก่อนหน้า หรือแผนที่ยังใช้ได้ ไม่มีการ prune หรือแตะ volume'));
46
+ for(const t of targets.items)retention.append(button('ดูแผนล้าง image: '+t.definition.name,()=>maintenancePreview({action:'cleanup_preview',targetId:t.id},'ล้าง image ตามแผน',retention)));
47
+ const plans=section('แผน Build / Deploy','เลือก verification ที่ตรงกับ source ปัจจุบัน ทุกแผนมี hash และหมดอายุ ผลไม่แน่นอนต้องตรวจสถานะก่อนสั่งใหม่');box.append(plans);
48
+ const target=choice(plans,'ปลายทางสำหรับแผน','deploymentPlanTarget',targets.items.filter(t=>t.enabled).map(t=>[t.id,`${t.definition.name} · revision ${t.revision}`]));
49
+ const verification=field(plans,'Verification ID ที่ checks ผ่าน','deploymentVerification','');
50
+ let prepareKey;
51
+ plans.append(button('สร้างแผน Deployment',async()=>{
52
+ const t=targets.items.find(t=>t.id===target.value);if(!t)throw new Error('เลือกปลายทางที่เปิดอยู่');
53
+ prepareKey??=crypto.randomUUID();
54
+ const p=await act('deployment.prepare',reviewed({targetId:t.id,expectedTargetRevision:t.revision,verificationId:verification.value,idempotencyKey:prepareKey}));
55
+ prepareKey=undefined;await load(p.plan.deploymentId);
56
+ }));
57
+ const records=el('div');plans.append(records);
58
+ async function load(focus){
59
+ records.replaceChildren(el('p','กำลังอ่านผลที่บันทึก…'));
60
+ const result=await act('deployment.list');records.replaceChildren();
61
+ if(!result.items.length)records.append(el('p','ยังไม่มีแผน Deployment'));
62
+ for(const r of result.items){
63
+ const card=section(r.plan.deploymentId,`${r.state} · image ${r.imageDigest||'ยังไม่สร้าง'}`);records.append(card);
64
+ const details=el('details');details.open=r.plan.deploymentId===focus;details.append(el('summary','แผนและหลักฐาน'),el('pre',JSON.stringify(r,null,2),'wb-output'));card.append(details);
65
+ card.append(button('เปรียบเทียบ source',async()=>{const result=await act('deployment.compare',{deploymentId:r.plan.deploymentId});details.open=true;details.replaceChildren(el('summary','ผลเปรียบเทียบ'),el('pre',JSON.stringify(result,null,2),'wb-output'));}));
66
+ if(!r.retention?.imageRetired)card.append(button(r.retention?.pinned?'เลิกปักหมุด image':'ปักหมุด image',async()=>{
67
+ await act('deployment.maintenance',reviewed({action:'pin',deploymentId:r.plan.deploymentId,pinned:!r.retention?.pinned,expectedPinned:!!r.retention?.pinned}));await load(r.plan.deploymentId);
68
+ }));
69
+ if(r.state==='UNKNOWN'&&!r.retention?.uncertaintyAcknowledged)card.append(button('ตรวจและรับทราบผลที่ไม่แน่นอน',()=>maintenancePreview({action:'resolve_preview',deploymentId:r.plan.deploymentId},'รับทราบ UNKNOWN โดยไม่สั่งซ้ำและไม่รับรอง health',card)));
70
+ if(r.retention?.cleanupOutcome?.state==='UNKNOWN')card.append(button('ตรวจผลล้าง image ที่ยังไม่แน่นอน',()=>maintenancePreview({action:'image_preview',deploymentId:r.plan.deploymentId},'รับทราบสถานะ image โดยไม่สั่งลบซ้ำ',card)));
71
+ if(r.retention?.unfinishedProbe)card.append(button('ตรวจ container probe ที่ค้าง',()=>maintenancePreview({action:'probe_preview',deploymentId:r.plan.deploymentId},'ล้างเฉพาะ probe ที่หยุดอยู่และมี identity ตรง',card)));
72
+ if(r.imageDigest){
73
+ card.append(button('ตรวจ container จริง',async()=>{const result=await act('deployment.observe',reviewed({deploymentId:r.plan.deploymentId}));details.open=true;details.replaceChildren(el('summary','ผลตรวจ container ณ เวลานี้'),el('pre',JSON.stringify(result,null,2),'wb-output'));}));
74
+ card.append(button('ดูแผนกู้ source จาก container',async()=>{
75
+ const result=await act('deployment.source_preview',reviewed({deploymentId:r.plan.deploymentId})),p=result.restore;
76
+ details.open=true;details.replaceChildren(el('summary','แผนกู้ source — ยังไม่เปลี่ยนไฟล์'));
77
+ for(const file of p.files||[])details.append(el('h4',file.path),el('pre',file.diff||'(ตรวจด้วย hash)','wb-output'));
78
+ for(const conflict of p.conflicts||[])details.append(el('p',conflict.path+': '+conflict.reason));
79
+ if(!p.applicable){details.append(el('p',p.unchanged?'Source ตรงกับภาพที่ตรวจแล้ว':'แผนยังใช้ไม่ได้ โปรดตรวจ conflict'));return;}
80
+ const key=crypto.randomUUID();let sent=false;
81
+ details.append(button('กู้ source ตามแผนจาก container',async()=>{
82
+ if(sent||!await confirmDanger('กู้ source ที่ตรวจแล้ว?',`${p.planId} · สำรองไฟล์ปัจจุบันก่อนแก้ ไม่แตะ DB, volume หรือ restart container`,'กู้ source'))return;
83
+ sent=true;
84
+ try{const outcome=await act('recovery.restore_apply',{planId:p.planId,planHash:p.planHash,idempotencyKey:key,workspaceId:p.workspaceId,workspaceEpoch:p.workspaceEpoch,confirm:true});details.append(el('p',outcome.verified?'กู้และตรวจ source สำเร็จ':'ยังไม่มีผลยืนยันการตรวจ source'));}
85
+ catch(e){details.append(el('p','ยังยืนยันผลไม่ได้: '+e.message+' · ตรวจ restore_status ก่อนสั่งใหม่'));throw e;}
86
+ }));
87
+ }));
88
+ }
89
+ if(r.state==='KNOWN_GOOD'&&!r.retention?.imageRetired){
90
+ let rollbackKey;
91
+ card.append(button('เตรียม rollback image นี้',async()=>{
92
+ if(!await confirmDanger('เตรียมแผน rollback?',`${r.imageDigest} · ตรวจ container ปัจจุบันก่อน ยังไม่ Deploy และไม่ย้อน DB`,'เตรียมแผน'))return;
93
+ rollbackKey??=crypto.randomUUID();const result=await act('deployment.rollback_prepare',reviewed({deploymentId:r.plan.deploymentId,idempotencyKey:rollbackKey}));await load(result.plan.deploymentId);
94
+ }));
95
+ }
96
+ if(r.state==='PREPARED'||r.state==='BUILT'){
97
+ const op=r.state==='PREPARED'?'build':'apply',label=op==='build'?'Build ตามแผนนี้':'Deploy image นี้';
98
+ card.append(button(label,async()=>{
99
+ if(!await confirmDanger(label+'?',`${r.plan.deploymentId} · ${r.imageDigest||r.planHash} · ไม่เปลี่ยน DB หรือสั่งลบ volume` ,label))return;
100
+ const payload={deploymentId:r.plan.deploymentId,planHash:r.planHash,...(op==='apply'?{imageDigest:r.imageDigest}:{})};
101
+ try{const result=await act('deployment.'+op,reviewed(payload));status.textContent=`${result.state} · ${result.plan.deploymentId}`;await load(r.plan.deploymentId);}
102
+ catch(e){status.textContent='ยังยืนยันผลไม่ได้: '+e.message+' · กดอ่านผลล่าสุดก่อนสั่งใหม่';throw e;}
103
+ }));
104
+ }
105
+ }
106
+ if(result.nextCursor!==null)records.append(el('p','มีประวัติเก่ามากกว่าหน้านี้ ใช้ deployment_list พร้อม cursor เพื่ออ่านต่อ'));
107
+ }
108
+ plans.append(button('อ่านผลล่าสุด',()=>load()));await load();
109
+ box.append(el('p','การป้องกันนี้ครอบคลุม DODO deployment adapter คำสั่ง shell หรือ CI ภายนอกยังเปลี่ยน production ได้เอง Source backup ไม่รวม DB, secrets และ volume data','muted'));
110
+ }catch(e){status.textContent='อ่าน Deployment ไม่สำเร็จ: '+e.message;status.dataset.kind='error';}
111
+ };
@@ -0,0 +1,167 @@
1
+ /* Owner Recovery dashboard. All untrusted values use textContent; no HTML injection. */
2
+ 'use strict';
3
+ window.DodoRecovery=({el,section,button,check,field,choice,notice,confirmDanger,help,api,refresh,getSelected})=>{
4
+ async function recoveryDrift(parent,act,data,selected){
5
+ const box=section('การเปลี่ยนไฟล์นอก journal','เปรียบเทียบ hash จริง ไม่ระบุว่าใครแก้จากเวลาไฟล์ การยอมรับสถานะไม่ใช่ผลทดสอบและไม่เพิ่มสิทธิ์');parent.append(box);
6
+ const view=el('div');box.append(view);
7
+ function render(d){
8
+ view.replaceChildren(el('p',d.state==='external_changes'?`พบ ${d.changedCount} รายการ · ไม่ทราบผู้แก้${d.critical?' · ต้องตรวจทานก่อนรันคำสั่ง':''}`:d.state==='unchanged'?'ไม่พบการเปลี่ยนจากสถานะที่บันทึก':d.state==='unavailable'?`ตรวจไม่สำเร็จ: ${d.errorCode}`:'ยังไม่ได้สแกน'));
9
+ if(d.changedCount){
10
+ const list=el('ul');for(const c of d.changes)list.append(el('li',`${c.change} · ${c.path} · ${c.beforeHash?.slice(0,12)||'ไม่มี'} → ${c.observedHash?.slice(0,12)||'ไม่มี'}`));view.append(list);
11
+ if(d.truncated){view.append(el('p','ยังมีรายการถัดไป การยืนยันใช้กับการเปลี่ยนแปลงทั้งหมดที่ระบุ'));view.append(button('ดูรายการเปลี่ยนแปลงถัดไป',async()=>render(await act('recovery.drift.scan',{cursor:d.nextCursor}))));}
12
+ view.append(el('p','สำเนาที่ตรวจพบแยกจาก baseline เดิม ไม่รับรองว่ามี bytes ก่อนถูกเขียนทับ หากยังไม่เคยสำรอง'));
13
+ view.append(button('ยอมรับสถานะที่ตรวจนี้',async()=>{
14
+ if(!await confirmDanger('ยอมรับไฟล์ที่เปลี่ยน?',`มี ${d.changedCount} รายการ การยืนยันอนุญาตให้ทำงานต่อจากสถานะนี้ ไม่ได้กู้ไฟล์กลับ`,'ยอมรับสถานะ'))return;
15
+ const result=await act('recovery.drift.acknowledge',{digest:d.digest,workspaceId:selected.workspaceId,workspaceEpoch:selected.workspaceEpoch,confirm:true});render(result);notice('บันทึกสถานะที่เจ้าของตรวจแล้ว');
16
+ }));
17
+ }
18
+ }
19
+ render(data.drift);box.append(button('ตรวจไฟล์และเปรียบเทียบ',async()=>{view.replaceChildren(el('p','กำลังตรวจ hash…'));try{render(await act('recovery.drift.scan'));}catch(e){view.replaceChildren(el('p',String(e.message),'wb-status'));throw e;}}));
20
+ box.append(el('p',`Git recovery: ${data.git.state}${data.git.errorCode?' · '+data.git.errorCode:''} · สำเนา source แยกจาก index/branch ของคุณ`));
21
+ }
22
+
23
+ async function recoveryPanel(parent) {
24
+ const card=section('Recovery · สำรอง source','สำรองไฟล์จริงนอกโปรเจกต์ก่อนแก้ไฟล์หรือรันคำสั่ง ไม่รวม secrets, ฐานข้อมูล และ generated files');parent.append(card);
25
+ const status=el('p','กำลังตรวจสอบจุดกู้คืน…','wb-status');status.setAttribute('role','status');card.append(status);
26
+ const selected={...getSelected()};
27
+ const act=(operation,args={})=>api('admin/action',{projectId:selected.projectId,operation,args},true);
28
+ try {
29
+ const data=await act('recovery.status');
30
+ const labels={INITIALIZING:'กำลังเตรียมสำเนา — ยังไม่อนุญาตการเปลี่ยน source จนสำรองสำเร็จ',READY:'SAVED · มีสำเนาที่ตรวจ integrity แล้ว',BLOCKED:'สำรองไม่ได้ — หยุดการเปลี่ยน source',DISABLED_BY_OWNER:'เจ้าของปิดการสำรองอัตโนมัติ'};
31
+ status.textContent=labels[data.state]||data.state;
32
+ if(data.awaitingActivation)status.textContent='เปิดการป้องกันไว้ — รอสร้างจุดกู้คืนแรก';
33
+ if(data.errorCode){status.dataset.kind='error';card.append(el('p',`เหตุผล: ${data.errorCode} · ตรวจพื้นที่ดิสก์ quota, ไฟล์ที่ถูกปฏิเสธ หรือความสมบูรณ์ของ backup แล้วลองสร้างจุดกู้คืนอีกครั้ง`));}
34
+ card.append(el('p',data.lastCheckedAt?`ตรวจล่าสุด ${new Date(data.lastCheckedAt).toLocaleString('th-TH')} · ใช้พื้นที่โปรเจกต์ ${(data.projectBytes/1048576).toFixed(2)} MiB`:'ยังไม่มีสำเนาที่ตรวจสอบแล้ว'));
35
+ card.append(el('p',data.verification.lastVerifiedAt?`เคยตรวจ checks ผ่านล่าสุด ${new Date(data.verification.lastVerifiedAt).toLocaleString('th-TH')} · ตรวจความสดอีกครั้งด้านล่าง`:'ยังไม่มีหลักฐาน checks ผ่านกับ snapshot'));
36
+ card.append(el('p',`รวม ${data.counts.included} รายการ · ยกเว้นตามนโยบาย ${data.counts.excludedByPolicy} รายการ · เหตุการณ์ล่าสุด ${data.lastTrigger||'ยังไม่มี'}`));
37
+ const enabled=check(card,'เปิดการสำรองอัตโนมัติ','recoveryEnabled',data.enabled);enabled.setAttribute('role','switch');
38
+ const advanced=el('details');advanced.append(el('summary','ขอบเขตและพื้นที่สำรอง'));card.append(advanced);
39
+ await cleanupPanel(advanced,act);
40
+ const quota=field(advanced,'พื้นที่สูงสุดต่อโปรเจกต์ (GiB)','recoveryQuota',String(data.policy.projectBytes/1073741824),'number');quota.min='0.001';quota.step='0.1';
41
+ const days=field(advanced,'เก็บกี่วัน (ยกเว้นจุดล่าสุด/ที่ pin)','recoveryDays',String(data.policy.retentionDays),'number');
42
+ const count=field(advanced,'จำนวนจุดที่ไม่ได้ pin','recoveryCount',String(data.policy.retainedPoints),'number');
43
+ const gitRequired=check(advanced,'ต้องมี Git recovery copy ก่อนทำงาน','recoveryGitRequired',data.policy.gitRequired);
44
+ const gitDirectory=field(advanced,'ที่เก็บ Git สำรอง (absolute path; เว้นว่างใช้ private state)','recoveryGitDirectory',data.policy.gitDirectory||'');
45
+ advanced.append(el('p','โฟลเดอร์สำรองต้องมีอยู่แล้ว เป็น private ของเจ้าของ และอยู่นอกโปรเจกต์ หากถอดดิสก์จะหยุด ไม่เปลี่ยนปลายทางเอง','muted'));
46
+ const roots=field(advanced,'โฟลเดอร์ข้อมูล runtime ที่ไม่สำรอง (relative path หนึ่งรายการต่อบรรทัด)','recoveryDataRoots',data.policy.dataRoots.join('\n'),'textarea');
47
+ card.append(button('บันทึกการสำรอง',async()=>{
48
+ if(!enabled.checked&&!await confirmDanger('ปิดการสำรองอัตโนมัติ?', 'การแก้ไฟล์และคำสั่งหลังจากนี้จะไม่มี source checkpoint ใหม่ สำเนาเดิมและ file journal ยังอยู่','ปิดการสำรอง'))return;
49
+ const result=await act('recovery.configure',{confirm:true,policy:{...data.policy,enabled:enabled.checked,gitRequired:gitRequired.checked,gitDirectory:gitDirectory.value.trim()||null,projectBytes:Math.round(Number(quota.value)*1073741824),retentionDays:Number(days.value),retainedPoints:Number(count.value),dataRoots:roots.value.split('\n').map(s=>s.trim()).filter(Boolean)}});
50
+ notice(result.enabled?'บันทึกแล้ว กำลังตรวจความพร้อม':'บันทึกแล้ว เจ้าของปิดการสำรอง');await refresh();
51
+ }));
52
+ card.append(button('สร้างจุดกู้คืนตอนนี้',async()=>{await act('recovery.checkpoint');await refresh();}));
53
+ card.append(button('ตรวจสถานะใหม่',async()=>refresh()));
54
+ card.append(el('p','กู้คืนเฉพาะ source ที่แสดงในแผน ไม่รวมฐานข้อมูล secrets, volumes หรือผลภายนอก และไม่ใช่ OS sandbox','muted'));
55
+ await verificationPanel(card,act,selected);
56
+ await recoveryDrift(card,act,data,selected);
57
+ await recoveryHistory(card,act,selected);
58
+ } catch(error){status.dataset.kind='error';status.textContent='ตรวจสอบ Recovery ไม่สำเร็จ: '+error.message;}
59
+ }
60
+
61
+ async function recoveryHistory(card,act,selected) {
62
+ const history=el('section');history.append(el('h3','ประวัติและการกู้คืน'));card.append(history);
63
+ const kind=choice(history,'ประเภทประวัติ','recoveryHistoryKind',[['checkpoints','จุดกู้คืน'],['sessions','Session งาน']],'checkpoints');
64
+ const selection=field(history,'เลือกไฟล์หรือโฟลเดอร์ (เว้นว่างเพื่อเลือกทั้งหมดที่สำรอง)','restorePath','');
65
+ const mirror=check(history,'Exact mirror: รวมการลบไฟล์ source ที่เพิ่มหลัง checkpoint','restoreMirror',false);
66
+ const list=el('div'),preview=el('section');history.append(list,preview);let cursor=0;
67
+ const showStatus=async(planId)=>{
68
+ const result=await act('recovery.restore_status',planId?{planId}:{});preview.replaceChildren(el('h4','สถานะจาก journal'));
69
+ if(!result.plans.length)preview.append(el('p','ยังไม่มีแผนกู้คืน'));
70
+ for(const p of result.plans)preview.append(el('p',`${p.planId} · ${p.status}${p.changesetId?' · '+p.changesetId:''}`));
71
+ };
72
+ const review=async(item)=>{
73
+ preview.replaceChildren(el('p','กำลังตรวจไฟล์และ backup…'));mirror.disabled=kind.value==='sessions';
74
+ let p;
75
+ try{p=await act('recovery.restore_preview',{...(kind.value==='sessions'?{sessionId:item.id}:{checkpointId:item.id}),...(selection.value.trim()?{paths:[selection.value.trim()]}:{}),exactMirror:kind.value==='checkpoints'&&mirror.checked});}
76
+ catch(error){const status=el('p','ตรวจแผนไม่สำเร็จ: '+error.message,'wb-status');status.setAttribute('role','alert');preview.replaceChildren(status);return;}
77
+ preview.replaceChildren(el('h4','ตรวจแผนก่อนกู้คืน'));preview.append(el('p',p.applicable?'ยังไม่มีการแก้ไฟล์ — ตรวจ diff แล้วจึงยืนยัน':'ยังไม่มีการแก้ไฟล์ — '+(p.unchanged?'ไฟล์ตรงกับจุดที่เลือกแล้ว':'พบ conflict ต้องตรวจไฟล์ก่อน')));
78
+ if(p.database)preview.append(el('p',`Database compatibility: ${p.database.state} · ${p.database.configured?'ใช้กติกาที่เจ้าของกำหนด':'ยังไม่ตั้ง adapter'} · ไม่มีการย้อนข้อมูล DB`,'wb-status'));
79
+ for(const c of p.conflicts||[])preview.append(el('p',`${c.path}: ${c.reason}`,'wb-status'));
80
+ for(const f of p.files||[]){const d=el('details');d.append(el('summary',`${f.action} · ${f.path} (${f.bytesBefore} → ${f.bytesAfter} bytes)`));d.append(el('pre',f.diff||'(ตรวจตามชนิดและ hash)'));d.append(el('p',`${f.beforeHash||'ไม่มีไฟล์'} → ${f.afterHash||'ลบไฟล์'}`,'muted'));preview.append(d);}
81
+ if(!p.applicable)return;
82
+ preview.append(el('p','สำรองสถานะปัจจุบันก่อนกู้คืน ถ้าไฟล์เปลี่ยนหลัง preview ระบบจะปฏิเสธ ไม่รวม DB/secrets/volumes','muted'));
83
+ const key=crypto.randomUUID();let sent=false;
84
+ const apply=button('กู้คืนตามแผนนี้',async()=>{
85
+ if(sent)return;
86
+ if(!await confirmDanger('กู้คืน source ตามแผนนี้?',`จะใช้แผน ${p.planId} เท่านั้น รวมรายการลบที่แสดงใน preview`,'กู้คืน'))return;
87
+ sent=true;apply.hidden=true;
88
+ try{
89
+ const result=await act('recovery.restore_apply',{planId:p.planId,planHash:p.planHash,idempotencyKey:key,workspaceId:p.workspaceId,workspaceEpoch:p.workspaceEpoch,confirm:true});
90
+ preview.replaceChildren(el('h4','ผลกู้คืน'),el('p',`ตรวจสอบ source สำเร็จ · ${result.changesetId}`));
91
+ if(result.databaseChangedDuringRestore)preview.append(el('p','ตรวจพบ schema เปลี่ยนระหว่างกู้ source ต้องให้เจ้าของตรวจความเข้ากันได้ก่อนใช้งาน · DODO ไม่ได้ย้อนข้อมูล DB','wb-status'));
92
+ }catch(e){preview.append(el('p','ยังไม่ยืนยันว่ากู้คืนสำเร็จ: '+e.message+' · ตรวจสถานะก่อนสั่งใหม่','wb-status'));}
93
+ });
94
+ preview.append(apply,button('ตรวจสถานะแผนนี้',()=>showStatus(p.planId)));
95
+ apply.focus();
96
+ };
97
+ const load=async()=>{
98
+ list.replaceChildren(el('p','กำลังโหลดประวัติ…'));
99
+ try{
100
+ const data=await act(kind.value==='sessions'?'recovery.recovery_session_list':'recovery.checkpoint_list',{cursor,limit:10});list.replaceChildren();
101
+ if(!data.items.length)list.append(el('p','ยังไม่มีประวัติประเภทนี้'));
102
+ for(const item of data.items){const row=el('div',undefined,'wb-card');row.append(el('p',`${item.title||item.id} · ${new Date(item.created_at).toLocaleString('th-TH')} · ${item.state}`));const view=button('ดู preview',()=>review(item));if(kind.value==='checkpoints'&&item.state!=='READY')view.disabled=true;row.append(view);
103
+ if(kind.value==='checkpoints'&&item.state==='READY'){
104
+ row.append(button(item.pinned?'เลิก pin':'Pin สำเนานี้',async()=>{const result=await act('recovery.pin',{checkpointId:item.id,pinned:!item.pinned,expectedPinned:Boolean(item.pinned),workspaceId:selected.workspaceId,workspaceEpoch:selected.workspaceEpoch,confirm:true});notice(result.pinned?'Pin แล้ว สำเนาจะไม่หมดอายุ':'เลิก pin แล้ว จุดที่ยังมีชื่ออ้างถึงจะยังเก็บไว้');await load();}));
105
+ row.append(button('ตั้งชื่อสำเนา',async()=>{const data=await act('recovery.evidence.list');const form=el('section');form.append(el('h4','ชื่อที่เจ้าของเลือก · ไม่รับรองผลทดสอบ'));const name=field(form,'ชื่อสำเนา','checkpointName','stable');
106
+ form.append(button('บันทึกชื่อสำเนา',async()=>{const old=data.pointers.find(p=>p.name===name.value.trim());
107
+ if(!await confirmDanger('ตั้งชื่อสำเนานี้?',`${name.value.trim()}: ${old?.snapshotId||'ยังไม่มี'} → ${item.id} ชื่อนี้ไม่เพิ่มสิทธิ์และไม่เปลี่ยนผลทดสอบ`,'บันทึกชื่อ'))return;
108
+ const result=await act('recovery.mark',{name:name.value.trim(),snapshotId:item.id,expectedRevision:old?.revision||0,workspaceId:selected.workspaceId,workspaceEpoch:selected.workspaceEpoch,confirm:true});notice(`บันทึก ${result.name} แล้ว · OWNER_MARKED_STABLE`);await refresh();}));preview.replaceChildren(form);name.focus();}));
109
+ }else if(kind.value==='sessions')row.append(button('ดู timeline',async()=>{
110
+ const render=async(cursor=0)=>{const d=await act('recovery.recovery_session_inspect',{sessionId:item.id,cursor,limit:20});preview.replaceChildren(el('h4',d.title),el('p',`สถานะ ${d.state}`));for(const e of d.events)preview.append(el('p',`${new Date(e.created_at).toLocaleString('th-TH')} · ${e.kind} · ${e.ref||e.snapshot_id||''}`));if(d.nextCursor!==null)preview.append(button('เหตุการณ์ถัดไป',()=>render(d.nextCursor)));};await render();
111
+ }));list.append(row);}
112
+ if(data.nextCursor!==null)list.append(button('หน้าถัดไป',async()=>{cursor=data.nextCursor;await load();}));
113
+ if(cursor)list.append(button('กลับหน้าแรก',async()=>{cursor=0;await load();}));
114
+ }catch(e){list.replaceChildren(el('p','โหลดประวัติไม่สำเร็จ: '+e.message,'wb-status'));}
115
+ };
116
+ kind.onchange=()=>{cursor=0;preview.replaceChildren();mirror.disabled=kind.value==='sessions';void load();};
117
+ history.append(button('ตรวจแผนล่าสุด / หลังเชื่อมต่อใหม่',()=>showStatus()));
118
+ await load();
119
+ }
120
+
121
+
122
+ async function cleanupPanel(parent,act){
123
+ const result=el('div');parent.append(button('ดู preview การล้างตาม retention',async()=>{
124
+ const render=async(cursor=0)=>{
125
+ result.replaceChildren(el('p','กำลังคำนวณรายการ…'));
126
+ try{const d=await act('recovery.cleanup.preview',{cursor,limit:20});result.replaceChildren(el('p',`ล้างได้ ${d.eligiblePoints} สำเนา · ${d.closedSessions} session ที่ปิดแล้ว · ยังไม่ได้ลบข้อมูล`));
127
+ result.append(el('p','พื้นที่ไฟล์อาจใช้ร่วมกัน จึงยังระบุพื้นที่ดิสก์ที่จะคืนจริงไม่ได้','muted'));
128
+ for(const p of d.items)result.append(el('p',`${p.checkpointId} · ${p.eligible?'เข้าเกณฑ์ล้าง':'เก็บไว้'} · ${p.reason}`));
129
+ if(d.nextCursor!==null)result.append(button('ดู retention หน้าถัดไป',()=>render(d.nextCursor)));
130
+ }catch(e){result.replaceChildren(el('p','ตรวจ retention ไม่สำเร็จ: '+e.message,'wb-status'));throw e;}
131
+ };await render();
132
+ }),result);
133
+ }
134
+ async function verificationPanel(parent,act,selected){
135
+ const box=section('หลักฐานและชื่อสำเนา','SAVED คือมีสำเนา · VERIFIED คือ checks ที่เลือกผ่าน · stable เป็นชื่อที่เจ้าของตั้ง');parent.append(box);
136
+ box.append(help('ผลทดสอบอ้างเฉพาะ source และ recipes ที่ตรวจในเวลานั้น ไม่ครอบคลุม environment ภายนอก การแก้แล้วเปลี่ยนกลับระหว่าง checks หรือความถูกต้องทุกกรณี','ขอบเขตหลักฐาน'));
137
+ const status=el('div'),detail=el('div');status.setAttribute('aria-live','polite');box.append(status,detail);let cursor=0;
138
+ const inspect=async(id)=>{
139
+ detail.replaceChildren(el('p','กำลังเทียบ snapshot กับ source, recipes และ jobs จริง…'));
140
+ try{const d=await act('recovery.evidence.inspect',{verificationId:id});const labels={VERIFIED:'ผ่าน checks ที่เลือกกับ snapshot นี้',FAILED:'มี check ไม่ผ่าน',STALE:'หลักฐานเก่า — source, recipe หรือ runtime เปลี่ยน',INCONCLUSIVE:'ยังรับรองไม่ได้ — ตรวจ job และขอบเขตหลักฐาน'};
141
+ detail.replaceChildren(el('h4',`${d.state} · ${labels[d.state]}`),el('p',`ตรวจเมื่อ ${new Date(d.checkedAt).toLocaleString('th-TH')}`));
142
+ const more=el('details');more.append(el('summary','Snapshot และผลทดสอบที่ใช้ตัดสิน'),el('p',d.checkpointId),el('p',`Manifest ${d.manifestHash}`),el('p',`เหตุผล: ${d.reason}`));
143
+ for(const c of d.evidence?.checks||[])more.append(el('p',`${c.taskId} · ${c.status} · exit ${c.exitCode??'ยังไม่มี'} · ผ่าน ${c.counts.passed??'ไม่ทราบ'} / ${c.counts.total??'ไม่ทราบ'} · ข้าม ${c.counts.skipped??'ไม่ทราบ'}${c.outputTruncated?' · output ถูกตัด':''}`));
144
+ if(d.evidence?.notRun.length)more.append(el('p','ยังไม่ได้รัน: '+d.evidence.notRun.join(', ')));
145
+ more.append(el('p',`Deployment: ${d.production} · Database recovery: ${d.database}`,'muted'));detail.append(more);
146
+ }catch(e){detail.replaceChildren(el('p','ตรวจหลักฐานไม่สำเร็จ: '+e.message,'wb-status'));throw e;}
147
+ };
148
+ const load=async()=>{
149
+ status.replaceChildren(el('p','กำลังโหลดหลักฐาน…'));
150
+ try{const d=await act('recovery.evidence.list',{cursor,limit:5});status.replaceChildren();
151
+ if(!d.items.length)status.append(el('p','ยังไม่มีผล verify_changes ที่ผูก snapshot · สำเนาที่มีเป็น SAVED'));
152
+ for(const e of d.items){const row=el('div',undefined,'wb-card');row.append(el('p',`${new Date(e.createdAt).toLocaleString('th-TH')} · ${e.lastObservation?.state||'ยังไม่ได้ตรวจผล'} (ข้อมูลครั้งก่อน)`),button('ตรวจหลักฐานปัจจุบัน',()=>inspect(e.verificationId)));status.append(row);}
153
+ if(d.nextCursor!==null)status.append(button('หลักฐานหน้าถัดไป',async()=>{cursor=d.nextCursor;await load();}));
154
+ if(cursor)status.append(button('หลักฐานหน้าแรก',async()=>{cursor=0;await load();}));
155
+ const active=d.pointers.filter(p=>p.snapshotId);if(!active.length)status.append(el('p','ยังไม่ได้ตั้งชื่อ stable หรือชื่อสำเนาอื่น'));
156
+ for(const p of active){const row=el('div',undefined,'wb-card');row.append(el('p',`${p.name} · OWNER_MARKED_STABLE`),button('ถอนชื่อ '+p.name,async()=>{
157
+ if(!await confirmDanger('ถอนชื่อสำเนา?',`${p.name} จะไม่ป้องกัน retention อีกต่อไป สำเนาที่ pin ไว้ยังคงอยู่`,'ถอนชื่อ'))return;
158
+ await act('recovery.mark',{name:p.name,snapshotId:null,expectedRevision:p.revision,workspaceId:selected.workspaceId,workspaceEpoch:selected.workspaceEpoch,confirm:true});notice('ถอนชื่อแล้ว');await load();
159
+ }));const ref=el('details');ref.append(el('summary','สำเนาที่อ้างถึง'),el('p',p.snapshotId));row.append(ref);status.append(row);}
160
+ const history=el('details');history.append(el('summary','ประวัติการตั้งชื่อ (owner)'));for(const e of d.pointerEvents.items)history.append(el('p',`${new Date(e.createdAt).toLocaleString('th-TH')} · ${e.name} · ${e.previousId||'ยังไม่มี'} → ${e.snapshotId||'ถอนชื่อ'} · revision ${e.revision}`));status.append(history);
161
+ if(cursor===0&&d.items.length)await inspect(d.items[0].verificationId);
162
+ }catch(e){status.replaceChildren(el('p','โหลดหลักฐานไม่สำเร็จ: '+e.message,'wb-status'));}
163
+ };
164
+ box.append(button('โหลดหลักฐานอีกครั้ง',load));await load();
165
+ }
166
+ return recoveryPanel;
167
+ };
@@ -25,6 +25,10 @@
25
25
  .wb-form { display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--s4);margin:0; }
26
26
  .wb-form label:has(textarea),.wb-form>button { grid-column:1/-1; }
27
27
  .wb-form > .btn { margin-top:0; }
28
+ .wb-advanced { grid-column:1/-1;border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px 12px;background:var(--panel-2); }
29
+ .wb-advanced > summary { cursor:pointer;font-weight:700;color:var(--text); }
30
+ .wb-advanced > summary:focus-visible { outline:2px solid var(--focus);outline-offset:4px; }
31
+ .wb-advanced-grid { margin-top:var(--s4); }
28
32
  #workbench label { display:flex;flex-direction:column;gap:6px;font-size:.9rem;font-weight:600;margin:0; }
29
33
  #workbench input,#workbench select,#workbench textarea { width:100%;min-width:0;background:var(--code-bg);color:var(--text);border:1px solid var(--border-strong);border-radius:var(--radius-sm);padding:9px 11px;font:inherit; }
30
34
  #workbench input:focus-visible,#workbench select:focus-visible,#workbench textarea:focus-visible { outline:2px solid var(--focus);outline-offset:1px;border-color:var(--focus); }
@@ -39,6 +43,29 @@
39
43
  #workbench .wb-switch input[type="checkbox"]:checked::after { transform:translateX(18px);background:var(--accent-contrast); }
40
44
  #workbench .wb-switch input[type="checkbox"]:focus-visible { outline:2px solid var(--focus);outline-offset:2px; }
41
45
  @media(prefers-reduced-motion:reduce) { #workbench .wb-switch input[type="checkbox"],#workbench .wb-switch input[type="checkbox"]::after { transition:none; } }
46
+ .wb-tool-count { display:inline-flex;align-items:center;min-height:34px;margin:0;padding:7px 11px;border:1px solid var(--border);border-radius:999px;background:var(--panel-2);font-size:.84rem;font-weight:700;color:var(--text);overflow-wrap:anywhere; }
47
+ .wb-tool-controls { display:grid;grid-template-columns:minmax(220px,1fr) auto;gap:var(--s3);align-items:end; }
48
+ .wb-tool-actions,.wb-tool-group-actions { display:flex;gap:var(--s2);flex-wrap:wrap; }
49
+ .wb-tool-groups { display:grid;gap:var(--s3); }
50
+ .wb-tool-group { border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--panel-2);overflow:hidden; }
51
+ .wb-tool-group > summary { display:flex;align-items:center;justify-content:space-between;gap:var(--s3);padding:12px 14px;cursor:pointer;font-weight:700;overflow-wrap:anywhere; }
52
+ .wb-tool-group > summary:hover { background:color-mix(in srgb,var(--accent-soft) 40%,transparent); }
53
+ .wb-tool-group > summary:focus-visible { outline:2px solid var(--focus);outline-offset:-3px; }
54
+ .wb-tool-group-count { flex:none;color:var(--muted);font-size:.8rem;font-weight:600; }
55
+ .wb-tool-group-actions { padding:10px 14px;border-top:1px solid var(--border);border-bottom:1px solid var(--border); }
56
+ .wb-tool-list { display:grid; }
57
+ .wb-tool-row { display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--s3);align-items:center;padding:11px 14px;border-top:1px solid var(--border); }
58
+ .wb-tool-row:first-child { border-top:0; }
59
+ .wb-tool-row[hidden],.wb-tool-group[hidden] { display:none; }
60
+ #workbench .wb-tool-toggle { min-width:0;align-items:flex-start; }
61
+ #workbench .wb-tool-toggle input { margin-top:2px; }
62
+ .wb-tool-copy { min-width:0;display:flex;flex-direction:column;gap:3px; }
63
+ .wb-tool-name { font-family:var(--mono);font-size:.86rem;font-weight:800;color:var(--text);overflow-wrap:anywhere; }
64
+ .wb-tool-description { color:var(--muted);font-size:.82rem;font-weight:400;line-height:1.45;overflow-wrap:anywhere; }
65
+ .wb-tool-meta { color:var(--accent-strong);font-size:.74rem;font-weight:700;overflow-wrap:anywhere; }
66
+ .wb-tool-row-state { color:var(--muted);font-size:.78rem;font-weight:700;text-align:right;max-width:145px;overflow-wrap:anywhere; }
67
+ .wb-tool-toggle input:disabled { opacity:.55;cursor:not-allowed; }
68
+ .wb-tool-toggle:has(input:disabled) { cursor:not-allowed; }
42
69
  .wb-model-state { margin-top:10px;padding:10px;border:1px solid var(--border);border-radius:7px;background:var(--panel-2);color:var(--muted); }
43
70
  .wb-model-state.error { border-color:var(--danger);color:var(--danger); }
44
71
  .wb-model-state p { margin:0 0 8px; }
@@ -59,4 +86,4 @@
59
86
  .wb-receipt { border:1px solid var(--border);border-radius:8px;padding:12px;min-width:0; }
60
87
  .wb-receipt summary { cursor:pointer;font-size:.9rem;overflow-wrap:anywhere; }
61
88
  .wb-receipt summary:focus-visible { outline:2px solid var(--accent);outline-offset:4px; }
62
- @media(max-width:640px) { .wb-form { grid-template-columns:1fr; } .wb-section { padding:12px; } .workbench-nav { padding:10px 12px; } .wb-toolbar { align-items:stretch; } }
89
+ @media(max-width:640px) { .wb-form { grid-template-columns:1fr; } .wb-section { padding:12px; } .workbench-nav { padding:10px 12px; } .wb-toolbar { align-items:stretch; } .wb-tool-controls { grid-template-columns:1fr; } .wb-tool-row { grid-template-columns:1fr; } .wb-tool-row-state { max-width:none;text-align:left;padding-left:52px; } .wb-tool-group > summary { align-items:flex-start; } }