@mriekki/project-forge 4.12.0 → 4.13.1

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 (22) hide show
  1. package/README.md +2 -0
  2. package/assets/components.json +3 -3
  3. package/assets/core/distribution.json +2 -0
  4. package/assets/deployment/distribution.json +2 -0
  5. package/assets/generated/ASSEMBLY.json +35 -15
  6. package/assets/generated/core/core/instructions/project-forge-local-development.instructions.md +28 -0
  7. package/assets/generated/core/core/instructions/project-forge-workflow.instructions.md +7 -5
  8. package/assets/generated/core/core/process/HUMAN_GATES.md +21 -4
  9. package/assets/generated/core/core/process/INTERACTIVE_SESSION_GUIDE.md +15 -6
  10. package/assets/generated/core/core/process/LOCAL_DEVELOPMENT_COMMANDS.md +103 -0
  11. package/assets/generated/core/core/skills/build/SKILL.md +10 -5
  12. package/assets/generated/core/core/skills/run/SKILL.md +39 -13
  13. package/assets/generated/core/core/skills/start-work/SKILL.md +15 -6
  14. package/assets/generated/core/tools/vscode_work_item_status/extension/README.md +8 -4
  15. package/assets/generated/core/tools/vscode_work_item_status/extension/extension.js +79 -21
  16. package/assets/generated/core/tools/vscode_work_item_status/extension/package.json +26 -2
  17. package/assets/generated/deployment/docs/RECOVERY_READINESS.md +66 -0
  18. package/assets/generated/deployment/src/project_forge_deployment/recovery_scope.py +116 -0
  19. package/assets/generated/deployment/src/project_forge_deployment/sqlite_adapter.py +27 -2
  20. package/assets/generated/deployment/src/project_forge_deployment/sqlite_adapter_remote.py +9 -6
  21. package/assets/generated/deployment/src/project_forge_deployment/sqlite_cli.py +23 -3
  22. package/package.json +1 -1
@@ -14,6 +14,7 @@ from .app_runtime import APP_ROOT
14
14
  from .box_platform import platform_status
15
15
  from .errors import DeploymentKitError
16
16
  from .profiles import load_profile
17
+ from .recovery_scope import normalize_recovery_scope, public_recovery_scope, recovery_evidence, recovery_readiness
17
18
  from .sqlite_adapter_remote import REMOTE_SCRIPT
18
19
  from .ssh import ssh_args, verify_access
19
20
 
@@ -111,6 +112,7 @@ def normalize_sqlite_request(
111
112
  maintenance_environment: dict[str, str] | Iterable[tuple[str, str]],
112
113
  run_as: str | None = None, database_file: str | None = None,
113
114
  source_database: str | None = None, first_install: bool = False,
115
+ recovery_scope: dict[str, Any] | None = None,
114
116
  ) -> dict[str, Any]:
115
117
  request = {
116
118
  "ownerRepo": _owner_repo(owner_repo),
@@ -122,6 +124,7 @@ def normalize_sqlite_request(
122
124
  "runAs": _run_as(run_as) if run_as is not None else None,
123
125
  "databaseFile": _database_file(database_file) if database_file is not None else None,
124
126
  "sourceDatabase": _source_database(source_database) if source_database is not None else None,
127
+ "recoveryScope": normalize_recovery_scope(recovery_scope),
125
128
  }
126
129
  if request["sourceDatabase"] is not None:
127
130
  if request["runAs"] is None or request["databaseFile"] is None:
@@ -140,6 +143,7 @@ def _b64(value: str) -> str:
140
143
  def build_remote_script(spec: dict[str, Any] | None = None) -> str:
141
144
  spec = spec or {}
142
145
  env = spec.get("maintenanceEnvironment", {})
146
+ recovery_payload = json.dumps(spec.get("recoveryEvidence", {}), sort_keys=True, separators=(",", ":"))
143
147
  replacements = {
144
148
  "__APP_ROOT__": APP_ROOT,
145
149
  "__SQLITE_SCHEMA_VERSION__": str(SQLITE_SCHEMA_VERSION),
@@ -154,6 +158,7 @@ def build_remote_script(spec: dict[str, Any] | None = None) -> str:
154
158
  "__RESERVATION_SHA__": spec.get("reservationSha256", ""),
155
159
  "__ENV_KEYS__": " ".join(f"'{_b64(k)}'" for k in env),
156
160
  "__ENV_VALUES__": " ".join(f"'{_b64(v)}'" for v in env.values()),
161
+ "__RECOVERY_EVIDENCE_B64__": _b64(recovery_payload),
157
162
  }
158
163
  script = REMOTE_SCRIPT
159
164
  for placeholder, value in replacements.items():
@@ -291,6 +296,12 @@ def plan_sqlite_operation(
291
296
  spec.update(runAs=existing["runAs"], databaseFile=existing["databaseFile"], reservationSha256=existing["reservationSha256"])
292
297
  if action in {"backup", "migrate", "rollback-check"} and not existing["databasePresent"]:
293
298
  raise DeploymentKitError(f"SQLite {action} requires an existing database.")
299
+ creates_verified_backup = bool(action == "backup" or (action == "migrate" and not request["firstInstall"]))
300
+ recovery_scope = request["recoveryScope"]
301
+ spec["recoveryEvidence"] = recovery_evidence(
302
+ recovery_scope,
303
+ database_backup_verified=creates_verified_backup,
304
+ )
294
305
  return {
295
306
  "targetName": name, "action": action, "ownerRepo": request["ownerRepo"],
296
307
  "environment": request["environment"], "productionClassified": production_classified,
@@ -299,7 +310,10 @@ def plan_sqlite_operation(
299
310
  "sourceDatabase": request["sourceDatabase"] if action == "import" else None,
300
311
  "firstInstall": bool(request["firstInstall"] if action == "migrate" else False),
301
312
  "stopsManagedRuntime": bool(action == "migrate" and existing and existing["managedRuntimeRunning"]),
302
- "createsVerifiedBackup": bool(action == "backup" or (action == "migrate" and not request["firstInstall"])),
313
+ "createsVerifiedBackup": creates_verified_backup,
314
+ "verifiedBackupScope": "database" if creates_verified_backup else None,
315
+ "recoveryScope": public_recovery_scope(recovery_scope),
316
+ "applicationRecoveryReadyAfterOperation": False,
303
317
  "inventorySnapshot": _snapshot(inventory), "expectedReservationSha256": expected_sha, "spec": spec,
304
318
  }
305
319
 
@@ -319,11 +333,18 @@ def authorization_phrase(plan: dict[str, Any]) -> str:
319
333
 
320
334
 
321
335
  def apply_sqlite_operation(plan: dict[str, Any]) -> dict[str, Any]:
322
- required = {"targetName", "action", "ownerRepo", "environment", "productionClassified", "appSlug", "image", "databaseFile", "runAs", "sourceDatabase", "firstInstall", "stopsManagedRuntime", "createsVerifiedBackup", "inventorySnapshot", "expectedReservationSha256", "spec"}
336
+ required = {
337
+ "targetName", "action", "ownerRepo", "environment", "productionClassified", "appSlug", "image",
338
+ "databaseFile", "runAs", "sourceDatabase", "firstInstall", "stopsManagedRuntime", "createsVerifiedBackup",
339
+ "verifiedBackupScope", "recoveryScope", "applicationRecoveryReadyAfterOperation", "inventorySnapshot",
340
+ "expectedReservationSha256", "spec",
341
+ }
323
342
  if not isinstance(plan, dict) or set(plan) != required or not isinstance(plan["productionClassified"], bool):
324
343
  raise DeploymentKitError("Malformed SQLite operation plan.")
325
344
  if plan["environment"] == "production" and not plan["productionClassified"]:
326
345
  raise DeploymentKitError("Literal production environment must remain Production-classified.")
346
+ if plan["applicationRecoveryReadyAfterOperation"] is not False:
347
+ raise DeploymentKitError("SQLite operations cannot claim application recovery readiness.")
327
348
  _require_ready_platform(plan["targetName"])
328
349
  current = sqlite_inventory(plan["targetName"])
329
350
  if _snapshot(current) != plan["inventorySnapshot"]:
@@ -334,6 +355,10 @@ def apply_sqlite_operation(plan: dict[str, Any]) -> dict[str, Any]:
334
355
  result["mutated"] = True
335
356
  result["operation"] = plan["action"]; result["operationOutput"] = output.strip()
336
357
  result["productionClassified"] = plan["productionClassified"]
358
+ result["recoveryReadiness"] = recovery_readiness(
359
+ plan["spec"]["recoveryScope"],
360
+ database_backup_verified=plan["createsVerifiedBackup"],
361
+ )
337
362
  return result
338
363
 
339
364
 
@@ -19,6 +19,7 @@ FIRST_INSTALL="__FIRST_INSTALL__"
19
19
  RESERVATION_SHA="__RESERVATION_SHA__"
20
20
  ENV_KEYS=(__ENV_KEYS__)
21
21
  ENV_VALUES=(__ENV_VALUES__)
22
+ RECOVERY_EVIDENCE_B64="__RECOVERY_EVIDENCE_B64__"
22
23
 
23
24
  b64d() { printf '%s' "$1" | base64 --decode; }
24
25
  meta_value() {
@@ -121,6 +122,7 @@ decode_spec() {
121
122
  RUN_AS="$(b64d "$RUN_AS_B64")"
122
123
  DATABASE_FILE="$(b64d "$DB_FILE_B64")"
123
124
  SOURCE_DATABASE="$(b64d "$SOURCE_DB_B64")"
125
+ RECOVERY_EVIDENCE_JSON="$(b64d "$RECOVERY_EVIDENCE_B64")"
124
126
  valid_repo "$OWNER" || { echo "Invalid SQLite owner repository." >&2; exit 45; }
125
127
  valid_slug "$ENVIRONMENT" || { echo "Invalid SQLite environment." >&2; exit 45; }
126
128
  valid_slug "$APP_SLUG" || { echo "Invalid SQLite app slug." >&2; exit 45; }
@@ -129,6 +131,7 @@ decode_spec() {
129
131
  valid_db_file "$DATABASE_FILE" || { echo "Invalid SQLite database file name." >&2; exit 45; }
130
132
  [[ "$RESERVATION_SHA" =~ ^[0-9a-f]{64}$ ]] || { echo "Invalid SQLite reservation fingerprint." >&2; exit 45; }
131
133
  [[ "${#ENV_KEYS[@]}" -eq "${#ENV_VALUES[@]}" && "${#ENV_KEYS[@]}" -gt 0 ]] || { echo "Invalid SQLite maintenance environment payload." >&2; exit 45; }
134
+ [[ "$RECOVERY_EVIDENCE_JSON" == \{*\} ]] || { echo "Invalid SQLite recovery evidence payload." >&2; exit 45; }
132
135
  local index key
133
136
  for index in "${!ENV_KEYS[@]}"; do
134
137
  key="$(b64d "${ENV_KEYS[$index]}")"
@@ -210,8 +213,8 @@ run_migrate() {
210
213
  record_evidence() {
211
214
  local root="$1" action="$2" outcome="$3" artifact="${4:-}" timestamp
212
215
  timestamp="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
213
- printf '{"timestamp":"%s","action":"%s","ownerRepo":"%s","environment":"%s","appSlug":"%s","image":"%s","databaseFile":"%s","reservationSha256":"%s","artifact":"%s","outcome":"%s"}\n' \
214
- "$timestamp" "$action" "$OWNER" "$ENVIRONMENT" "$APP_SLUG" "$IMAGE" "$DATABASE_FILE" "$RESERVATION_SHA" "$artifact" "$outcome" >> "$root/sqlite-evidence.jsonl"
216
+ printf '{"timestamp":"%s","action":"%s","ownerRepo":"%s","environment":"%s","appSlug":"%s","image":"%s","databaseFile":"%s","reservationSha256":"%s","artifact":"%s","outcome":"%s","recovery":%s}\n' \
217
+ "$timestamp" "$action" "$OWNER" "$ENVIRONMENT" "$APP_SLUG" "$IMAGE" "$DATABASE_FILE" "$RESERVATION_SHA" "$artifact" "$outcome" "$RECOVERY_EVIDENCE_JSON" >> "$root/sqlite-evidence.jsonl"
215
218
  chmod 0600 "$root/sqlite-evidence.jsonl"
216
219
  }
217
220
 
@@ -287,7 +290,7 @@ operate() {
287
290
  backup_name="backup-$stamp-$DATABASE_FILE"
288
291
  run_backup "$DATA" "$BACKUPS" "$backup_name"
289
292
  record_evidence "$ROOT" backup backed-up "$backup_name"
290
- printf 'SQLite verified backup created: %s\n' "$BACKUPS/$backup_name"
293
+ printf 'SQLite verified database backup created: %s\n' "$BACKUPS/$backup_name"
291
294
  ;;
292
295
  migrate)
293
296
  if [[ "$FIRST_INSTALL" == true ]]; then
@@ -311,12 +314,12 @@ operate() {
311
314
  if [[ "$MANAGED_RUNNING" == true ]]; then docker stop "$MANAGED_CONTAINER" >/dev/null; fi
312
315
  if ! run_migrate "$DATA"; then
313
316
  record_evidence "$ROOT" migrate failed "$backup_name"
314
- echo "SQLite migration failed; verified pre-migration backup is retained and managed runtime remains stopped." >&2
317
+ echo "SQLite migration failed; verified pre-migration database backup is retained and managed runtime remains stopped." >&2
315
318
  exit 50
316
319
  fi
317
320
  run_check "$DATA"
318
321
  record_evidence "$ROOT" migrate migrated "$backup_name"
319
- printf 'SQLite migration completed; verified pre-migration backup: %s\n' "$BACKUPS/$backup_name"
322
+ printf 'SQLite migration completed; verified pre-migration database backup: %s\n' "$BACKUPS/$backup_name"
320
323
  fi
321
324
  ;;
322
325
  rollback-check)
@@ -337,4 +340,4 @@ case "$ACTION" in
337
340
  import|backup|migrate|rollback-check) operate ;;
338
341
  *) echo "Unsupported SQLite adapter action: $ACTION" >&2; exit 2 ;;
339
342
  esac
340
- '''
343
+ '''
@@ -1,6 +1,7 @@
1
1
  """CLI for the SQLite persistence adapter."""
2
2
  from __future__ import annotations
3
3
  import argparse, json, sys
4
+ from pathlib import Path
4
5
  from typing import Any
5
6
  from .errors import DeploymentKitError
6
7
  from .sqlite_adapter import authorization_phrase, apply_sqlite_operation, normalize_sqlite_request, plan_sqlite_operation, sqlite_inventory
@@ -28,6 +29,18 @@ def _parse_env(items: list[str]) -> list[tuple[str,str]]:
28
29
  return out
29
30
 
30
31
 
32
+ def _load_recovery_manifest(path: str | None) -> dict[str, Any] | None:
33
+ if path is None:
34
+ return None
35
+ try:
36
+ value = json.loads(Path(path).read_text(encoding='utf-8'))
37
+ except (OSError, UnicodeError, json.JSONDecodeError) as error:
38
+ raise DeploymentKitError(f"Could not read recovery manifest as UTF-8 JSON: {error}") from error
39
+ if not isinstance(value, dict):
40
+ raise DeploymentKitError("Recovery manifest root must be a JSON object.")
41
+ return value
42
+
43
+
31
44
  def _confirm(args: argparse.Namespace, phrase: str, action: str) -> None:
32
45
  if args.yes: return
33
46
  if input(f"Type {phrase} to {action}: ").strip() != phrase:
@@ -50,6 +63,10 @@ def _common(parser: argparse.ArgumentParser) -> None:
50
63
  parser.add_argument('--yes', action='store_true')
51
64
 
52
65
 
66
+ def _recovery_manifest_arg(parser: argparse.ArgumentParser) -> None:
67
+ parser.add_argument('--recovery-manifest', help='Optional strict non-secret JSON declaration of recovery dependencies and authentication policy')
68
+
69
+
53
70
  def _import_args(parser: argparse.ArgumentParser) -> None:
54
71
  parser.add_argument('--run-as', required=True)
55
72
  parser.add_argument('--database-file', required=True)
@@ -62,8 +79,8 @@ def build_parser() -> argparse.ArgumentParser:
62
79
  inv=sub.add_parser('inventory',help='Read SQLite persistence reservations without writes'); inv.add_argument('name'); inv.add_argument('--json',action='store_true')
63
80
  imp=sub.add_parser('import',help='Import an existing SQLite DB via the exact image online-backup contract'); _common(imp); _import_args(imp)
64
81
  recover=sub.add_parser('recover-import',help='Recover a verified failed import without overwriting or recopying the database'); _common(recover); _import_args(recover)
65
- backup=sub.add_parser('backup',help='Create a verified SQLite backup with the exact image'); _common(backup)
66
- migrate=sub.add_parser('migrate',help='Create required backup and run exact-image SQLite migration'); _common(migrate)
82
+ backup=sub.add_parser('backup',help='Create a verified SQLite database backup with the exact image'); _common(backup); _recovery_manifest_arg(backup)
83
+ migrate=sub.add_parser('migrate',help='Create required database backup and run exact-image SQLite migration'); _common(migrate); _recovery_manifest_arg(migrate)
67
84
  migrate.add_argument('--first-install',action='store_true'); migrate.add_argument('--run-as'); migrate.add_argument('--database-file')
68
85
  check=sub.add_parser('rollback-check',help='Verify that an exact rollback image accepts the current SQLite state'); _common(check)
69
86
  return parser
@@ -73,10 +90,13 @@ def main(argv: list[str]|None=None) -> int:
73
90
  args=build_parser().parse_args(argv)
74
91
  try:
75
92
  if args.command=='inventory': _print(sqlite_inventory(args.name),as_json=args.json); return 0
93
+ if args.command=='migrate' and getattr(args,'first_install',False) and getattr(args,'recovery_manifest',None):
94
+ raise DeploymentKitError("--recovery-manifest is supported only for existing-database SQLite migration, not --first-install.")
76
95
  production_classified = args.production or args.environment == 'production'
77
96
  request=normalize_sqlite_request(owner_repo=args.owner_repo,environment=args.environment,app_slug=args.app_slug,image=args.image,
78
97
  maintenance_environment=_parse_env(args.maintenance_env),run_as=getattr(args,'run_as',None),database_file=getattr(args,'database_file',None),
79
- source_database=getattr(args,'source_database',None),first_install=getattr(args,'first_install',False))
98
+ source_database=getattr(args,'source_database',None),first_install=getattr(args,'first_install',False),
99
+ recovery_scope=_load_recovery_manifest(getattr(args,'recovery_manifest',None)))
80
100
  if args.command=='recover-import':
81
101
  plan=plan_sqlite_import_recovery(args.name,request,production=production_classified)
82
102
  _print({k:v for k,v in plan.items() if k!='request'},as_json=False)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "4.12.0",
3
+ "version": "4.13.1",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {