run-workshop-quality-update.py 5.0 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
  1. """Capture dry/apply-once/verify results without shell quoting or blind retries."""
  2. import argparse
  3. import hashlib
  4. import json
  5. import os
  6. from pathlib import Path
  7. import subprocess
  8. import sys
  9. def main():
  10. parser = argparse.ArgumentParser()
  11. parser.add_argument('--out', required=True, help='New absolute result directory; never reuse')
  12. args = parser.parse_args()
  13. package = Path(__file__).resolve().parent
  14. output = Path(args.out).absolute()
  15. if (not Path(args.out).is_absolute() or output.exists() or
  16. any(p.is_symlink() for p in (output, *output.parents))):
  17. raise ValueError('NEW_ABSOLUTE_OUTPUT_DIRECTORY_REQUIRED')
  18. if not sys.platform.startswith('linux') or os.getuid() != 10000:
  19. raise ValueError('HERMES_UID_10000_REQUIRED')
  20. for line in (package / 'FILES.sha256').read_text(encoding='utf-8').splitlines():
  21. expected, name = line.split(' ', 1)
  22. parts = Path(name).parts
  23. if Path(name).is_absolute() or any(p in ('.', '..') for p in parts):
  24. raise ValueError('INVALID_PACKAGE_PATH')
  25. if hashlib.sha256((package / name).read_bytes()).hexdigest() != expected:
  26. raise ValueError('PACKAGE_HASH_MISMATCH')
  27. output.mkdir(mode=0o700, parents=False)
  28. steps = {}
  29. def run(step, *arguments):
  30. result = subprocess.run(['node', str(package / 'update-skill.mjs'), *arguments],
  31. cwd=package, capture_output=True, check=False)
  32. (output / (step + '.stdout.json')).write_bytes(result.stdout)
  33. (output / (step + '.stderr.json')).write_bytes(result.stderr)
  34. (output / (step + '.exit')).write_text(str(result.returncode) + '\n', encoding='utf-8')
  35. steps[step] = {'exitCode': result.returncode, 'stderrBytes': len(result.stderr)}
  36. if result.returncode != 0:
  37. raise ValueError(step.upper() + '_FAILED_SEE_SAVED_OUTPUT')
  38. payload = json.loads(result.stdout)
  39. if payload.get('ok') is not True:
  40. raise ValueError(step.upper() + '_NOT_OK')
  41. return payload
  42. receipt = {'schema': 'ksf.workshop-docs-install-run.v1', 'ok': False,
  43. 'releaseId': 'ksf-workshop-skill-update-20261109-r5', 'steps': steps,
  44. 'filesInstalled': False, 'agentLoaded': False,
  45. 'currentReadVerified': False, 'sameGeneration': False,
  46. 'actualBusinessWriteVerified': False, 'formalReportSubmitted': False,
  47. 'databaseWrites': 0, 'paidCalls': 0, 'serviceRestarted': False,
  48. 'credentialsExported': False}
  49. try:
  50. dry = run('dry', '--dry')
  51. expected_plan = {'r2': (17, 14, 3), 'r3': (16, 14, 2), 'r4': (9, 8, 1)}.get(dry.get('baselineId'))
  52. if dry.get('alreadyApplied') is True:
  53. if dry.get('changedFiles') != 0:
  54. raise ValueError('ALREADY_APPLIED_PLAN_MISMATCH')
  55. receipt.update(ok=True, filesInstalled=True, alreadyApplied=True,
  56. note='Full target matched; no apply or new backup performed.')
  57. else:
  58. if (expected_plan is None or dry.get('readOnly') is not True or
  59. dry.get('changedFiles') != expected_plan[0] or
  60. sum(c['action'] == 'update' for c in dry['changes']) != expected_plan[1] or
  61. sum(c['action'] == 'create' for c in dry['changes']) != expected_plan[2]):
  62. raise ValueError('UNEXPECTED_DRY_PLAN')
  63. applied = run('apply', '--apply')
  64. receipt['backup'] = applied['backup']
  65. verified = run('verify', '--verify', '--backup', applied['backup'])
  66. if verified.get('installedFilesVerified') != 41 or verified.get('changedFilesVerified') != expected_plan[0]:
  67. raise ValueError('UNEXPECTED_VERIFY_RESULT')
  68. receipt.update(ok=True, filesInstalled=True, alreadyApplied=False,
  69. installedFilesVerified=41, changedFiles=expected_plan[0], baselineId=dry['baselineId'],
  70. runtimeAndBindingPreserved=verified['runtimeAndBindingPreserved'],
  71. researchFilesUnchanged=verified['researchFilesUnchanged'],
  72. legacyEntrypointRetired=verified['legacyEntrypointRetired'])
  73. receipt['skillSha256'] = dry['skillSha256']
  74. receipt['manifestSha256'] = dry['manifestSha256']
  75. except Exception as exc:
  76. # Store only our fixed error identifiers, not raw exceptions or environment.
  77. text = str(exc)
  78. receipt['errorCode'] = text if text and all(c in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ_' for c in text) else 'RUN_FAILED_SEE_SAVED_OUTPUT'
  79. finally:
  80. (output / 'install-receipt.json').write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  81. print(json.dumps(receipt, ensure_ascii=False, indent=2))
  82. return 0 if receipt['ok'] else 1
  83. if __name__ == '__main__':
  84. try:
  85. sys.exit(main())
  86. except Exception:
  87. print(json.dumps({'ok': False, 'errorCode': 'PRECHECK_FAILED_NO_INSTALL_STARTED'}))
  88. sys.exit(1)