diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 703bbaf6..a9bfc24f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,6 +26,15 @@ jobs: TULDOK_TRAINING_PYTHON: ${{ github.workspace }}/build/workspace-training/bin/python TULDOK_NATIVE_READER_PYTHON: ${{ github.workspace }}/build/workspace-training/bin/python TULDOK_DATABASE_URL: postgresql://tuldok_test:isolated-ci-fixture@127.0.0.1:5432/tuldok_test + TULDOK_PROJECT_FILES_COLD_DATABASE_URL: postgresql://tuldok_project_cold:isolated-cold-fixture@127.0.0.1:5432/tuldok_project_cold + TULDOK_QA_OUTPUT_ROOT: build/qa/external-project + TULDOK_M2_FREEZE_MANIFEST: build/qa/external-project/product-manifest.json + TULDOK_M3_FREEZE_MANIFEST: build/qa/external-project/product-manifest.json + TULDOK_M4_FREEZE_MANIFEST: build/qa/external-project/product-manifest.json + TULDOK_OWNER_MCP_MANIFEST: build/qa/external-project/product-manifest.json + TULDOK_M5_CLIENTS_MANIFEST: build/qa/external-project/product-manifest.json + TULDOK_M5_UI_FREEZE_MANIFEST: build/qa/external-project/product-manifest.json + TULDOK_NATIVE_PUBLIC_FREEZE_MANIFEST: build/qa/external-project/product-manifest.json steps: - uses: actions/checkout@v4 - name: Fetch the exact authored fixture used by canonical Release qualification @@ -47,6 +56,33 @@ jobs: - name: Install independent WAV consumer run: sudo apt-get update && sudo apt-get install -y ffmpeg - run: python -m pip install -r requirements.txt -r requirements-spatial-qualification.txt + - name: Prepare isolated external-project cold reconstruction and exact source subject + run: | + python - <<'PY' + import hashlib, json, os, pathlib, subprocess + import psycopg + from psycopg import sql + source = os.environ['TULDOK_DATABASE_URL'] + database = psycopg.conninfo.conninfo_to_dict(source)['dbname'] + # This job owns the disposable service and these fixed fixture names. + with psycopg.connect(source, autocommit=True) as connection: + connection.execute("CREATE ROLE tuldok_project_cold LOGIN PASSWORD 'isolated-cold-fixture' NOSUPERUSER NOCREATEDB NOCREATEROLE") + connection.execute('CREATE DATABASE tuldok_project_cold OWNER tuldok_project_cold') + connection.execute(sql.SQL('REVOKE CONNECT ON DATABASE {} FROM PUBLIC').format(sql.Identifier(database))) + with psycopg.connect(os.environ['TULDOK_PROJECT_FILES_COLD_DATABASE_URL']) as connection: + current, allowed = connection.execute('SELECT current_database(),has_database_privilege(current_user,%s,\'CONNECT\')', (database,)).fetchone() + assert current != database and allowed is False + paths = subprocess.check_output(['git', 'ls-files', '-z']).decode().split('\0') + paths = sorted(p for p in paths if p and ((p.endswith('.py') and '/' not in p) or p.startswith(('static/', 'tests/', 'extension_schemas/', 'tuldok_extension_sdk/')))) + subject = {'baseline': subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode().strip(), + 'qualification_copy': str(pathlib.Path.cwd()), 'parent_manifest_sha256': None, + 'files': {p: hashlib.sha256(pathlib.Path(p).read_bytes()).hexdigest() for p in paths}} + target = pathlib.Path(os.environ['TULDOK_M2_FREEZE_MANIFEST']) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(subject, sort_keys=True, separators=(',', ':')) + '\n') + print('External-project product files:', len(subject['files'])) + assert subject['files'] + PY - name: Verify field output preflight and unchanged admission state run: python -m unittest discover -s tests -p 'test_run_preflight*.py' -v - name: Verify same-attempt publication retention and recovery boundaries @@ -75,6 +111,27 @@ jobs: - run: build/workspace-training/bin/python -m unittest discover -s tests -p test_volume_assets.py - run: build/workspace-training/bin/python -m unittest discover -s tests -p test_rheon_cpu_training.py - run: build/workspace-training/bin/python -m unittest discover -s tests -p test_rheon_evaluation.py + - name: Verify actual native project Run fork Study custody and cold accounting + env: + PYTHONPATH: tests + TULDOK_NATIVE_PROJECT_GO: '1' + TULDOK_NATIVE_RETENTION_DIR: ${{ github.workspace }}/build/qa/external-project/native-retained + run: | + build/workspace-training/bin/python - <<'PY' + import os, sys, unittest + import numpy, sklearn, psycopg, torch + assert os.environ['TULDOK_TRAINING_PYTHON'] == sys.executable + names = [ + 'test_project_native_run.NativeRunSaveAsTests.test_one_actual_native_run_save_as_cold_role_and_metadata_only_git_clone', + 'test_project_native_study.NativeStudyCustodyTests.test_actual_native_study_spent_budget_and_cold_history_require_fresh_admission', + 'test_project_native_study.NativeStudyCustodyTests.test_native_study_admission_canonical_ack_then_sql_failure_never_starts_training', + ] + loader = unittest.TestLoader() + suite = loader.loadTestsFromNames(names) + assert not loader.errors and suite.countTestCases() == len(names) + result = unittest.TextTestRunner(verbosity=2).run(suite) + sys.exit(0 if result.wasSuccessful() and result.testsRun == len(names) and not result.skipped else 1) + PY - name: Create isolated optional agent verification environment run: python -m venv build/workspace-agent - run: build/workspace-agent/bin/python -m pip install -r requirements.txt @@ -88,7 +145,111 @@ jobs: - run: node --check static/experiment-lineage.js - run: node tests/test_experiment_lineage_controller.cjs - run: build/workspace-agent/bin/python -m unittest discover -s tests -p test_artifact_segments.py + - name: Verify real external-project SDK CLI MCP and current authority + env: + PYTHONPATH: tests + run: | + build/workspace-agent/bin/python - <<'PY' + import mcp, sys, unittest + loader = unittest.TestLoader() + suite = loader.loadTestsFromName('test_project_files_m2_http.ProjectFilesM2HTTP.test_real_sdk_cli_mcp_roles_lost_reply_and_stale_binding') + assert not loader.errors and suite.countTestCases() == 1 + result = unittest.TextTestRunner(verbosity=2).run(suite) + sys.exit(0 if result.wasSuccessful() and result.testsRun == 1 and not result.skipped else 1) + PY + - name: Verify independent fork and extraction across actual owner transports + env: + PYTHONPATH: tests + run: | + build/workspace-agent/bin/python - <<'PY' + import mcp, sys, unittest + names = [ + 'test_project_files_m4_clients_http.ProjectFilesM4ClientsHTTP.test_exact_file_revisions_save_as_and_current_role_through_sdk_cli_mcp', + 'test_project_transfer_clients_http.ProjectTransferClientsHTTP.test_owner_handoff_and_single_extraction_through_sdk_cli_and_real_mcp', + ] + loader = unittest.TestLoader() + suite = loader.loadTestsFromNames(names) + assert not loader.errors and suite.countTestCases() == len(names) + result = unittest.TextTestRunner(verbosity=2).run(suite) + sys.exit(0 if result.wasSuccessful() and result.testsRun == len(names) and not result.skipped else 1) + PY + - name: Verify finite owner MCP modes and denial boundaries + env: + PYTHONPATH: tests + run: | + build/workspace-agent/bin/python - <<'PY' + import mcp, sys, unittest + loader = unittest.TestLoader() + suite = loader.loadTestsFromName('test_project_files_owner_mcp') + selected = suite.countTestCases() + assert not loader.errors and selected > 0 + result = unittest.TextTestRunner(verbosity=2).run(suite) + sys.exit(0 if result.wasSuccessful() and result.testsRun == selected and not result.skipped else 1) + PY + - name: Verify real native project SDK CLI MCP and zero-GO recovery + env: + PYTHONPATH: tests + TULDOK_NATIVE_PROJECT_GO: '1' + run: | + build/workspace-agent/bin/python - <<'PY' + import mcp, sys, unittest + loader = unittest.TestLoader() + suite = loader.loadTestsFromName('test_project_native_clients_http.NativePublicClientsHTTP.test_real_native_preflight_replay_zero_go_failure_and_original_phase_recovery') + assert not loader.errors and suite.countTestCases() == 1 + result = unittest.TextTestRunner(verbosity=2).run(suite) + sys.exit(0 if result.wasSuccessful() and result.testsRun == 1 and not result.skipped else 1) + PY + - name: Verify explicit note deletion history and cold fork across owner transports + env: + PYTHONPATH: tests + TULDOK_NOTE_DELETION_GO: '1' + run: | + build/workspace-agent/bin/python - <<'PY' + import mcp, sys, unittest + loader = unittest.TestLoader() + suite = loader.loadTestsFromName('test_project_file_deletion_clients_http.NoteDeletionClientsHTTP.test_real_note_deletion_restore_history_and_cold_independent_fork') + assert not loader.errors and suite.countTestCases() == 1 + result = unittest.TextTestRunner(verbosity=2).run(suite) + sys.exit(0 if result.wasSuccessful() and result.testsRun == 1 and not result.skipped else 1) + PY - run: python -m unittest discover -s tests + - name: Verify external-project authoring and cold reconstruction in Chromium + run: node tests/browser_project_files_m2.cjs + env: + BROWSER: /usr/bin/google-chrome + - name: Verify named independent fork and inert cold clone in Chromium + run: node tests/browser_project_files_m4.cjs + env: + BROWSER: /usr/bin/google-chrome + - name: Verify explicit runtime failure recovery and inert cold Open in Chromium + run: node tests/browser_project_files_m3.cjs + env: + BROWSER: /usr/bin/google-chrome + - name: Verify source stop and independent extraction handoff in Chromium + run: node tests/test_workspace_project_transfer.cjs --browser + env: + BROWSER: /usr/bin/google-chrome + TULDOK_M5_UI_BROWSER_GO: '1' + - name: Verify named native preflight and original-phase recovery in Chromium + run: node tests/browser_project_files_native.cjs + env: + BROWSER: /usr/bin/google-chrome + TULDOK_NATIVE_PROJECT_GO: '1' + - name: Verify explicit note deletion restore and inert cold fork in Chromium + run: node tests/browser_project_file_deletion.cjs + env: + BROWSER: /usr/bin/google-chrome + TULDOK_NOTE_DELETION_GO: '1' + - name: Verify external-project working file and recovery controls + run: | + node tests/test_workspace_project_files_m4.cjs + node tests/test_workspace_project_fork_text.cjs + node tests/test_workspace_project_runtime.cjs + node tests/test_workspace_project_candidate.cjs + node tests/test_workspace_project_transfer.cjs + node tests/test_workspace_project_native.cjs + node tests/test_workspace_project_native_validation.cjs + node tests/test_workspace_project_file_deletion.cjs - run: node --check static/auth.js - run: node tests/test_auth_helpers.cjs - run: node tests/test_auth_controller.cjs diff --git a/access.py b/access.py index 7c8ea13a..742d09b0 100644 --- a/access.py +++ b/access.py @@ -2,7 +2,7 @@ import argparse from contextlib import contextmanager from contextvars import ContextVar -from dataclasses import dataclass +from dataclasses import dataclass, replace from functools import wraps import hashlib import json @@ -24,6 +24,11 @@ HASH = re.compile(r'^[a-f0-9]{64}$') +def current_context(): + """Read the immutable caller context; this observation grants no authority.""" + return _CURRENT.get() + + class AccessError(Exception): def __init__(self, message, code='forbidden', status=403): super().__init__(message) @@ -79,11 +84,29 @@ def guarded(self, *args, **kwargs): class Access: - def __init__(self, db, catalog_path): + def __init__(self, db, catalog_path, authority=None): self.db, self.catalog_path = db, Path(catalog_path) self.catalog_id, self.lock = db.dataset_id, db.lock + # Only current host credentials cross this bridge. Job admissions, + # execution grants and their consumption remain in this local projection. + if authority is not None and (not isinstance(authority, Access) or authority.authority is not None): + raise ValueError('A project bridge requires a direct host authority.') + self.authority = authority + + def _host_authentication(self): + if self.authority is not None: + raise AccessError('Authenticate and administer credentials through the current host.', 'forbidden', 403) + + def actor_exists(self, actor_id): + """Current host actor membership, never an imported scientific identity.""" + if self.authority is not None: + return self.authority.actor_exists(actor_id) + with self.lock: + return self.db.execute('SELECT id FROM access_actors WHERE id=%s', (actor_id,)).fetchone() is not None def _credential(self, credential_id): + if self.authority is not None: + return self.authority._credential(credential_id) row = self.db.execute('''SELECT c.id,c.actor_id,c.kind AS credential_kind,c.scopes_json,c.expires_at, c.revoked,c.consumed,a.kind,a.label FROM access_credentials c JOIN access_actors a ON a.id=c.actor_id WHERE c.id=%s''', (credential_id,)).fetchone() @@ -92,6 +115,7 @@ def _credential(self, credential_id): return dict(row) def _resolve(self, token, *, login=False): + self._host_authentication() if type(token) is not str or not 32 <= len(token) <= 256 or not token.isascii(): raise AccessError('A valid credential is required.', 'unauthenticated', 401) row = self.db.execute('SELECT id,actor_id,kind,expires_at,revoked,consumed FROM access_credentials WHERE token_sha256=%s', @@ -104,6 +128,7 @@ def _resolve(self, token, *, login=False): @contextmanager def bind(self, token): + self._host_authentication() try: with self.lock: row = self._resolve(token) @@ -121,6 +146,19 @@ def _context(self): context = _CURRENT.get() if context is None: raise AccessError('An explicit actor context is required.', 'unauthenticated', 401) + if self.authority is not None and self.catalog_id == self.authority.catalog_id: + raise AccessError('This project identity collides with the host authority. Save an independent project before execution.', 'unavailable', 409) + if self.authority is not None and context.mode not in ('credential','job','system'): + raise AccessError('Unsupported project actor context.') + if self.authority is not None and context.mode == 'credential': + if context.catalog_id != self.authority.catalog_id: + raise AccessError('Project execution requires a current host credential context.') + # Recheck both credential identity and liveness, including for APIs + # that use _context directly rather than require(). + row = self.authority._credential(context.credential_id) + if row['actor_id'] != context.actor_id: + raise AccessError('Credential actor identity changed.') + return replace(context, catalog_id=self.catalog_id) if context.catalog_id != self.catalog_id: raise AccessError('Actor context belongs to another catalog.') return context @@ -223,6 +261,7 @@ def denial(self, error, action, target=None, *, anonymous=False): error.audited = True def _mint(self, actor_id, kind, scopes, ttl): + self._host_authentication() token = secrets.token_urlsafe(32) credential_id = uuid.uuid4().hex self.db.execute('INSERT INTO access_credentials VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)', @@ -232,6 +271,7 @@ def _mint(self, actor_id, kind, scopes, ttl): def _local_owner_token(self, *, initial=False, require_existing=False): """Explicit local administration only; never exposed by a transport route.""" + self._host_authentication() with self.lock, self.db.transaction(): owners = self.db.execute("SELECT id FROM access_actors WHERE kind='owner'").fetchall() if require_existing and not owners: @@ -250,6 +290,7 @@ def _local_owner_token(self, *, initial=False, require_existing=False): return token def exchange(self, token): + self._host_authentication() try: with self.lock, self.db.transaction(): row = self._resolve(token, login=True) @@ -270,6 +311,7 @@ def exchange(self, token): @authorized('admin', 'access.issue', owner_only=True) def issue(self, body): + self._host_authentication() if type(body) is not dict or set(body) - {'label', 'scopes', 'ttl_seconds'} or not {'label', 'scopes'} <= set(body): raise AccessError('Credential fields must be label, scopes and optional ttl_seconds.', 'invalid', 400) label = _text(body['label'], 'Label') @@ -293,6 +335,7 @@ def access(self): @authorized('admin', 'access.revoke', owner_only=True) def revoke(self, credential_id): + self._host_authentication() with self.lock, self.db.transaction(): changed = self.db.execute('UPDATE access_credentials SET revoked=1 WHERE id=%s RETURNING id', (credential_id,)).fetchone() if changed is None: @@ -302,6 +345,7 @@ def revoke(self, credential_id): return {'revoked': True, 'credential_id': credential_id} def logout(self): + self._host_authentication() context = self.require('inspect', 'access.logout') if context.mode != 'credential': raise AccessError('Only a credential session can log out.') @@ -366,7 +410,7 @@ def grant_execution(self, material_sha256, consumer_actor_id, ttl_seconds=600): raise AccessError('Approval lifetime must be 60 through 3600 seconds.', 'invalid', 400) with self.lock, self.db.transaction(): context = self.require('approve-execution', 'access.grant_execution', owner_only=True) - if self.db.execute('SELECT id FROM access_actors WHERE id=%s', (consumer_actor_id,)).fetchone() is None: + if not self.actor_exists(consumer_actor_id): raise AccessError('Consuming actor does not belong to this catalog.', 'unavailable', 404) created = int(time.time()) grant = dict(id=uuid.uuid4().hex, catalog_id=self.catalog_id, diff --git a/agent_api.py b/agent_api.py index 0aa9a18b..818a38b2 100644 --- a/agent_api.py +++ b/agent_api.py @@ -6,6 +6,7 @@ import base64 import binascii import copy +from collections import OrderedDict from contextlib import contextmanager import hashlib import http.client @@ -37,14 +38,15 @@ class AgentAPIError(Exception): """Safe transport error: never contains a credential or remote traceback.""" - def __init__(self, message, code='invalid', status=400, *, preflight_report=None): + def __init__(self, message, code='invalid', status=400, *, preflight_report=None, receipt=None): super().__init__(message) self.code, self.status = code, status self.preflight_report = copy.deepcopy(preflight_report) + self.receipt = copy.deepcopy(receipt) -def encoded(value): - return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=True, +def encoded(value, *, ensure_ascii=True): + return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=ensure_ascii, allow_nan=False).encode('utf-8') @@ -727,10 +729,659 @@ def model_operation(name,method,path,properties=None,required=None,*,output=None 'point':obj({'field_id':REF, **{axis:integer(0,127) for axis in 'xyz'}})}, output=JSON_OBJECT, description='Read exact retained validation field-point diagnostics and scalar compatibility; no scientific criterion or compute is invented.') +# External file projects have independent lifecycle and text-only contracts. +# Keep these additive operations outside the installed legacy registry projection. +_PROJECT_FILES_ACTIONS = ['new', 'open', 'save', 'close', 'query', 'get', 'import_text', 'annotate'] +_PROJECT_FILES_GROUPS = dict(GROUPS, uniqueItems=True) +_PROJECT_FILES_PARENTS = dict(array(text(120), 30), uniqueItems=True) +_PROJECT_FILES_PROVENANCE = obj({'method': const('import'), 'rights': text(1000), + 'normalization': const('NFC; LF newlines')}) +_PROJECT_FILES_RECORD_FIELDS = { + 'id': ID, 'kind': const('text'), 'name': text(200), 'text': text(200000), + 'groups': _PROJECT_FILES_GROUPS, 'parents': _PROJECT_FILES_PARENTS, + 'provenance': _PROJECT_FILES_PROVENANCE, 'review': {'enum': ['draft', 'human_reviewed']}, + 'revision': SAFE_POS, 'source_revision': const(1), + 'created_at': text(80), 'updated_at': text(80)} +_PROJECT_FILES_TASKS = ('text_classification', 'text_entities', 'text_corpus') +PROJECT_FILES_RECORD = {'oneOf': [obj(dict(_PROJECT_FILES_RECORD_FIELDS, + task=const(task), annotation={'oneOf': [{'type': 'null'}, ANNOTATIONS[task]]})) + for task in _PROJECT_FILES_TASKS]} +PROJECT_FILES_SUMMARY = obj({key: _PROJECT_FILES_RECORD_FIELDS[key] for key in + ('id', 'kind', 'name', 'review', 'revision', 'source_revision', 'created_at', 'updated_at')} | + {'task': {'enum': list(_PROJECT_FILES_TASKS)}}) +_PROJECT_FILES_PROJECT = obj({'project_id': ID, 'name': text(200), 'checkpoint': SHA}) +_PROJECT_FILES_RECOVERY = obj({'action': const('open'), 'root': text(160), 'directory': text(512), + 'reason': {'enum': ['unknown_publication', 'projection_unavailable']}, + 'request_key': {'oneOf': [KEY, {'type': 'null'}]}}) +PROJECT_FILES_RECEIPT = {'oneOf': [ + obj({'committed': const(True), 'selected': const(True), 'project_id': ID, + 'checkpoint': SHA, 'request_key': KEY}), + obj({'committed': const(True), 'selected': const(False), 'project_id': ID, + 'checkpoint': SHA, 'request_key': KEY, 'root': text(160), 'directory': text(512)})]} +_PROJECT_FILES_STATE_FIELDS = {'format': const('project_files_state_v1'), 'binding': ID, + 'roots': dict(array(text(160), 100), uniqueItems=True), + 'available_actions': dict(array({'enum': _PROJECT_FILES_ACTIONS}, 8), uniqueItems=True)} +PROJECT_FILES_STATE = {'oneOf': [ + obj(dict(_PROJECT_FILES_STATE_FIELDS, state=const('no_project_open'), project={'type': 'null'})), + obj(dict(_PROJECT_FILES_STATE_FIELDS, state=const('open'), + project=_PROJECT_FILES_PROJECT)), + obj(dict(_PROJECT_FILES_STATE_FIELDS, state=const('recovery_required'), + project=_PROJECT_FILES_PROJECT, recovery=_PROJECT_FILES_RECOVERY, + available_actions=const(['open'])))]} +_PROJECT_FILES_BOUND_RESULT = {'binding': ID, 'project_id': ID, 'checkpoint': SHA} +PROJECT_FILES_RECORD_RESULT = obj(dict(_PROJECT_FILES_BOUND_RESULT, record=PROJECT_FILES_RECORD, + history=array(PROJECT_FILES_RECORD, 4), + next_history_before_revision={'oneOf': [SAFE_POS, {'type': 'null'}]})) +_PROJECT_FILES_MUTATION = {'binding': ID, 'expected_checkpoint': SHA, 'request_key': KEY} +model_operation('project_files.status', 'GET', '/api/workspace/project-files', output=PROJECT_FILES_STATE) +for _action in ('new', 'open'): + _fields = dict(_PROJECT_FILES_MUTATION, expected_checkpoint={'oneOf': [SHA, {'type': 'null'}]}, + root=text(160), directory=text(512)) + if _action == 'new': + _fields['name'] = text(200) + model_operation('project_files.' + _action, 'POST', '/api/workspace/project-files/' + _action, + _fields, output=PROJECT_FILES_STATE, scopes=('inspect', 'edit'), mutation=True, + description='Explicit file project ' + _action + '; requires the current process binding and checkpoint. Never executes project code.') +for _action in ('save', 'close'): + model_operation('project_files.' + _action, 'POST', '/api/workspace/project-files/' + _action, + _PROJECT_FILES_MUTATION, output=PROJECT_FILES_STATE, scopes=('inspect', 'edit'), mutation=True) +model_operation('project_files.query', 'GET', '/api/workspace/project-files/records', + {'binding': ID, 'checkpoint': SHA, 'after_id': ID, 'limit': integer(1, 100)}, + required=['binding', 'checkpoint'], query=('binding', 'checkpoint', 'after_id', 'limit'), + output=obj(dict(_PROJECT_FILES_BOUND_RESULT, items=array(PROJECT_FILES_SUMMARY, 100), + next_after_id={'oneOf': [ID, {'type': 'null'}]}))) +model_operation('project_files.get', 'GET', '/api/workspace/project-files/records/{record_id}', + {'record_id': ID, 'binding': ID, 'checkpoint': SHA, 'before_revision': SAFE_POS, + 'history_limit': integer(1, 4)}, required=['record_id', 'binding', 'checkpoint'], + query=('binding', 'checkpoint', 'before_revision', 'history_limit'), output=PROJECT_FILES_RECORD_RESULT) +model_operation('project_files.import_text', 'POST', '/api/workspace/project-files/import-text', + dict(_PROJECT_FILES_MUTATION, asset=obj({'text': text(200000), 'name': text(200), + 'groups': _PROJECT_FILES_GROUPS, 'parents': _PROJECT_FILES_PARENTS, 'rights': text(1000)}, + required=['text', 'groups'])), output=PROJECT_FILES_RECORD_RESULT, + scopes=('inspect', 'edit'), mutation=True) +model_operation('project_files.annotate', 'POST', '/api/workspace/project-files/records/{record_id}', + dict(_PROJECT_FILES_MUTATION, record_id=ID, annotation={'oneOf': [obj({ + 'revision': SAFE_POS, 'source_revision': const(1), 'task': const(task), + 'annotation': ANNOTATIONS[task], 'groups': _PROJECT_FILES_GROUPS, + 'review': {'enum': ['draft', 'human_reviewed']}}) for task in _PROJECT_FILES_TASKS]}), + output=PROJECT_FILES_RECORD_RESULT, scopes=('inspect', 'edit'), mutation=True, + description='Save ordinary human text annotation or review at the exact checkpoint. Human review additionally requires the existing owner approve-review authority.') +_PROJECT_FILES_RECEIPT_IDS = frozenset('project_files.' + action + for action in ('new', 'open', 'save', 'close', 'import_text', 'annotate')) +_MODEL_RECEIPT_IDS.update(_PROJECT_FILES_RECEIPT_IDS) +_OWNER_OPERATION_IDS.update('project_files.' + action for action in ('new', 'open', 'save', 'close')) +for _op in _OPERATIONS: + if _op['operation_id'].startswith('project_files.'): + _op['outputSchema']['oneOf'][1]['properties']['result']['properties']['receipt'] = PROJECT_FILES_RECEIPT + +# M2 operations are fixed domain projections. Domain owners validate versioned +# documents; the public boundary owns selection, bounds and outcome correlation. +PROJECT_FILES_M2 = set() +PROJECT_FILES_M3 = set() +PROJECT_FILES_M4 = set() +_PROJECT_FILES_PAGE_INPUT = {'offset': integer(0, 1000000), 'limit': integer(1, 40)} +_PROJECT_FILES_PAGE = obj({'items': array(JSON_OBJECT, 40), 'offset': integer(0, 1000000), + 'total': integer(0, 1000000), 'limit': integer(1, 40)}) +_PROJECT_FILES_READ = {'binding': ID, 'checkpoint': SHA} +_PROJECT_FILES_ORIGIN = {'origin_catalog_id': ID} + + +def _project_file_operation(action, method, suffix, fields=None, *, required=None, + output=JSON_OBJECT, mutation=False, owner=False, scopes=None, + domain=None, page=False, milestone=2, receipt=True): + fields = copy.deepcopy(fields or {}) + needed = list(fields) if required is None else list(required) + if domain is not None: + original = next(op for op in _OPERATIONS if op['operation_id'] == domain) + fields.update(copy.deepcopy(original['inputSchema'].get('properties', {}))) + needed += original['inputSchema'].get('required', []) + bound = (_PROJECT_FILES_MUTATION if receipt else {"binding": ID, "expected_checkpoint": SHA}) if mutation else _PROJECT_FILES_READ + fields.update(bound) + needed = list(dict.fromkeys(needed + list(bound))) + if action == 'data_query': + fields['limit'] = integer(1, 40) + if page: + fields.update(copy.deepcopy(_PROJECT_FILES_PAGE_INPUT)) + output = copy.deepcopy(_PROJECT_FILES_PAGE) + if action in ('data_history', 'data_response_history', 'data_preference_history'): + fields['limit'] = integer(1, 4) + output['properties']['items']['maxItems'] = 4 + output['properties']['limit'] = integer(1, 4) + name = 'project_files.' + action + model_operation(name, method, '/api/workspace/project-files' + suffix, + fields, needed, output=obj(dict(_PROJECT_FILES_BOUND_RESULT, value=output)), + query=tuple(key for key in fields if '{' + key + '}' not in suffix) if method == 'GET' else (), + scopes=scopes or (('inspect', 'edit') if mutation else ('inspect',)), mutation=mutation, + description=action.replace('_', ' ') + '; exact selected project checkpoint, existing domain authorization and validation.' + (' Historical content never grants current execution permission.' if milestone == 3 else ' No execution.')) + if domain is not None: + _OPERATIONS[-1]['binary_fields'] = copy.deepcopy(original['binary_fields']) + {2:PROJECT_FILES_M2,3:PROJECT_FILES_M3,4:PROJECT_FILES_M4}[milestone].add(name) + if mutation and receipt: + _MODEL_RECEIPT_IDS.add(name) + if owner: + _OWNER_OPERATION_IDS.add(name) + _OPERATIONS[-1]['outputSchema']['oneOf'][1]['properties']['result']['properties']['receipt'] = PROJECT_FILES_RECEIPT + + +_project_file_operation('definitions', 'GET', '/definitions') + +# Actual data and its authored histories. Inline source imports retain the +# existing decoded-byte bound; compound sources use their existing parsers. +_project_file_operation('data_query', 'GET', '/data', domain='data.query') +_project_file_operation('data_get', 'GET', '/data/{record_id}', {'record_id': ID}) +_project_file_operation('data_availability', 'GET', '/data/{record_id}/availability', {'record_id': ID}) +for _action, _suffix in [('data_history', 'history'), ('data_responses', 'responses'), + ('data_response_history', 'response-history'), ('data_preferences', 'preferences'), + ('data_preference_history', 'preference-history')]: + _project_file_operation(_action, 'GET', '/data/{record_id}/' + _suffix, {'record_id': ID}, page=True) +for _kind in ('text', 'image', 'audio', 'mesh', 'pointcloud', 'volume', 'sequence'): + _project_file_operation('data_import_' + _kind, 'POST', '/data/import-' + _kind, + domain='data.import_' + _kind, mutation=True) +_project_file_operation('source_register', 'POST', '/sources/register', + {'root': text(160), 'manifest_path': text(512)}, mutation=True, owner=True) +_project_file_operation('data_import_scientific', 'POST', '/data/import-scientific', { + 'snapshot': obj({'origin_catalog_id': ID, 'project_id': ID, 'manifest_sha256': SHA}), + 'reference_id': REF, 'expected_sha256': SHA, + 'document': obj(OPTIONAL_META, required=['groups'])}, mutation=True) +_project_file_operation('data_annotate', 'POST', '/data/{record_id}', { + 'record_id': ID, 'revision': SAFE_POS, 'source_revision': SAFE_POS, + 'task': {'enum': list(ANNOTATIONS)}, 'annotation': JSON_OBJECT, + 'groups': _PROJECT_FILES_GROUPS, 'review': {'enum': ['draft', 'human_reviewed']}}, mutation=True) +_project_file_operation('data_rights', 'POST', '/data/{record_id}/rights', { + 'record_id': ID, 'revision': SAFE_POS, 'source_revision': SAFE_POS, + 'note': text(1000, True)}, mutation=True) +for _action, _suffix in [('data_source_save', '/source'), ('data_source_delete', '/source/delete')]: + _project_file_operation(_action, 'POST', '/data/{record_id}' + _suffix, + {'record_id': ID, 'document': JSON_OBJECT}, mutation=True, + scopes=('inspect', 'edit', 'approve-review') if _action == 'data_source_save' else ('inspect', 'edit')) +_project_file_operation('data_response_save', 'POST', '/responses', { + 'document': obj({'id': ID, 'prompt_id': ID, 'revision': SAFE_NAT, + 'parent_revision': SAFE_POS, 'source_revision': SAFE_POS, 'completion': text(200000), + 'review': {'enum': ['draft', 'human_reviewed']}})}, mutation=True) +for _action, _suffix in [('data_preference_save', ''), ('data_preference_delete', '/delete')]: + _project_file_operation(_action, 'POST', '/preferences' + _suffix, + {'document': JSON_OBJECT}, mutation=True) +for _singular, _plural, _key in [('selection', 'selections', 'selection_id'), ('search', 'searches', 'search_id')]: + _project_file_operation(_plural, 'GET', '/' + _plural, page=True) + if _singular == 'selection': + _project_file_operation('selection_get', 'GET', '/selections/{selection_id}', {'selection_id': ID}) + _project_file_operation(_singular + '_create', 'POST', '/' + _plural, + {'document': JSON_OBJECT}, mutation=True) + for _verb in ('rename', 'delete'): + _project_file_operation(_singular + '_' + _verb, 'POST', '/' + _plural + '/{' + _key + '}' + ('/delete' if _verb == 'delete' else ''), + {_key: ID, 'revision': SAFE_POS, **({'name': text(120)} if _verb == 'rename' else {})}, mutation=True) +for _action in ('preview', 'freeze'): + _project_file_operation('release_' + _action, 'POST', '/releases/' + _action, + domain='releases.' + _action, mutation=_action == 'freeze', + scopes=('inspect', 'freeze') if _action == 'freeze' else ('inspect',)) + +# Current metadata uses the same Model/Pipeline validators, with canonical +# publication wrapped by the project session. Runtime capture remains absent. +for _plural, _key, _domain in [('models', 'model_id', 'models'), ('graphs', 'graph_id', 'graphs')]: + _project_file_operation(_plural, 'GET', '/' + _plural, page=True) + for _verb in ('get', 'create', 'save'): + _project_file_operation(('model_' if _plural == 'models' else 'graph_') + _verb, + 'GET' if _verb == 'get' else 'POST', '/' + _plural + ('/{' + _key + '}' if _verb != 'create' else ''), + domain=_domain + '.' + _verb, mutation=_verb != 'get') +_project_file_operation('graph_create_external', 'POST', '/graphs/external', + domain='graphs.create_external', mutation=True) +_project_file_operation('graph_clone', 'POST', '/graphs/{graph_id}/clone', domain='graphs.clone', mutation=True) +_project_file_operation('model_publish', 'POST', '/models/{model_id}/publish', domain='models.publish', mutation=True) +_project_file_operation('recipes', 'GET', '/recipes', page=True) +for _verb in ('get', 'create', 'save'): + _project_file_operation('recipe_' + _verb, 'GET' if _verb == 'get' else 'POST', + '/recipes' + ('/{recipe_id}' if _verb != 'create' else ''), domain='recipes.' + _verb, + mutation=_verb != 'get') +_project_file_operation('recipe_prepare', 'POST', '/recipes/{graph_id}/prepare', { + 'graph_id': ID, 'revision': SAFE_POS, 'model': MODEL_REF, + 'builtin': {'type': 'boolean'}, 'reconcile_capture': {'type': 'boolean'}}, + required=['graph_id', 'revision', 'model'], mutation=True) +for _kind, _ref in [('model', MODEL_REF), ('recipe', MATERIAL_REF), ('runtime', material_ref('runtime'))]: + _project_file_operation(_kind + '_material', 'POST', '/materials/' + _kind, + {'ref': _ref, 'verify': {'type': 'boolean'}}, required=['ref']) +_project_file_operation('snapshot', 'POST', '/materials/snapshot', { + 'project_id': ID, 'manifest_sha256': SHA, 'origin_catalog_id': ID, 'verify': {'type': 'boolean'}}, + required=['project_id', 'manifest_sha256', 'origin_catalog_id']) + +# Imported origin identity is an explicit selector, never a current grant. +for _action, _suffix, _key in [('study', 'studies', 'study_id'), ('trial', 'trials', 'trial_id'), + ('run', 'runs', 'run_id'), ('candidate', 'candidates', 'candidate_id')]: + _project_file_operation(_action, 'GET', '/' + _suffix + '/{' + _key + '}', + {_key: ID, **_PROJECT_FILES_ORIGIN, **({'verify': {'type': 'boolean'}} if _action == 'candidate' else {})}, + required=[_key, 'origin_catalog_id']) +# Study reservations are independently bounded while charge totals remain complete. +_study_operation = next(op for op in _OPERATIONS if op['operation_id'] == 'project_files.study') +_study_operation['inputSchema']['properties'].update(_PROJECT_FILES_PAGE_INPUT | {'revision': SAFE_POS}) +_study_operation['query'].extend(['offset', 'limit', 'revision']) +_run_operation = next(op for op in _OPERATIONS if op['operation_id'] == 'project_files.run') +_run_operation['inputSchema']['properties'].update(_PROJECT_FILES_PAGE_INPUT) +_run_operation['query'].extend(['offset', 'limit']) +_PROJECT_RUN_DOCUMENT_SEGMENT = obj({'format': const('project_run_document_segment_v1'), + 'origin_catalog_id': ID, 'run_id': ID, 'sha256': SHA, 'bytes': SAFE_NAT, + 'offset': SAFE_NAT, 'limit': integer(1, 262144), + 'base64': {'type': 'string', 'maxLength': 349528, 'pattern': '^[A-Za-z0-9+/]*={0,2}$'}, + 'next_offset': {'oneOf': [SAFE_NAT, {'type': 'null'}]}}) +_project_file_operation('run_document_segment', 'GET', '/runs/{run_id}/document-segment', + {'run_id': ID, 'origin_catalog_id': ID, 'expected_sha256': SHA, + 'offset': SAFE_NAT, 'limit': integer(1, 262144)}, + required=['run_id', 'origin_catalog_id', 'expected_sha256', 'offset', 'limit'], + output=_PROJECT_RUN_DOCUMENT_SEGMENT) +_project_file_operation('source_references', 'GET', '/sources/{project_id}/references', + {'origin_catalog_id': ID, 'project_id': ID, 'manifest_sha256': SHA}, page=True) +_project_file_operation('publication_result', 'GET', '/runs/{run_id}/publication-result', + {'run_id': ID, **_PROJECT_FILES_ORIGIN}) +_project_file_operation('package_state', 'GET', '/candidates/{candidate_id}/package-state', + {'candidate_id': ID, **_PROJECT_FILES_ORIGIN}) +_project_file_operation('dependencies', 'GET', '/runs/{run_id}/dependencies', + {'run_id': ID, **_PROJECT_FILES_ORIGIN, **_PROJECT_FILES_PAGE_INPUT}, + required=['run_id', 'origin_catalog_id']) +for _action in ('studies', 'runs', 'lineage', 'policies', 'artifacts', 'materials'): + _project_file_operation(_action, 'GET', '/' + _action, _PROJECT_FILES_ORIGIN, + required=[] if _action in ('studies', 'runs', 'artifacts', 'materials') else ['origin_catalog_id'], page=True) +# Intrinsic artifact identities span all origins in the selected project. +_artifacts_operation = next(op for op in _OPERATIONS if op['operation_id'] == 'project_files.artifacts') +_artifacts_operation['inputSchema']['properties'].pop('origin_catalog_id') +_artifacts_operation['query'].remove('origin_catalog_id') +_project_file_operation('policy', 'POST', '/policies/get', {'ref': POLICY_REF}) +for _verb in ('create', 'save'): + _project_file_operation('study_' + _verb, 'POST', '/studies' + ('/{study_id}' if _verb == 'save' else ''), + domain='studies.' + _verb, mutation=True, owner=True) +_project_file_operation('trial_prepare', 'POST', '/studies/{study_id}/trials', { + 'study_id': ID, 'revision': SAFE_POS, 'material': MATERIAL_REF, 'replicate': SAFE_NAT}, mutation=True) +_project_file_operation('lineage_record', 'POST', '/lineage', {'trial': _LINEAGE_REF, 'intent': _LINEAGE_INTENT}, mutation=True) +_project_file_operation('candidate_select', 'POST', '/studies/{study_id}/candidates', + domain='studies.select_candidate', mutation=True) +_project_file_operation('evidence', 'POST', '/runs/{run_id}/evidence', { + 'run_id': ID, **_PROJECT_FILES_ORIGIN, 'selectors': JSON_OBJECT}, + required=['run_id', 'origin_catalog_id'], output=_EVIDENCE_RESULT) +for _action, _suffix in [('retained_analysis', ''), ('retained_analysis_export', '/export')]: + _project_file_operation(_action, 'POST', '/runs/{run_id}/analysis' + _suffix, + {**_retained_request['properties'], **_PROJECT_FILES_ORIGIN}, + required=[*_retained_request['required'], 'origin_catalog_id'], output=_retained_result) +_project_file_operation('artifact_status', 'POST', '/artifacts/status', {'sha256': SHA}) +_project_file_operation('artifact_resolve', 'POST', '/artifacts/resolve', + {'sha256': SHA, 'root': text(160), 'path': text(512)}, mutation=True, owner=True, + output=obj({'format': const('project_artifact_resolution_v1'), 'sha256': SHA, 'bytes': SAFE_NAT, + 'actor_id': ID, 'observed_at': text(80)})) +_project_file_operation('expose_holdout', 'POST', '/runs/{run_id}/disclose', + {'run_id': ID, **_PROJECT_FILES_ORIGIN, 'reason': text(500)}, mutation=True, + scopes=('inspect', 'expose-holdout')) +_PROJECT_FILES_PRODUCER_FAMILY = {'enum': ['generation_history', 'grounded_history', 'caption_proposal_history', 'text_proposal_history']} +_project_file_operation('producers', 'GET', '/producers', + {'family': _PROJECT_FILES_PRODUCER_FAMILY, **_PROJECT_FILES_ORIGIN}, required=['family'], page=True) +_project_file_operation('producer', 'GET', '/producers/{origin_catalog_id}/{producer_id}', + {'family': _PROJECT_FILES_PRODUCER_FAMILY, 'origin_catalog_id': ID, 'producer_id': ID, + 'entries_offset': integer(0, 1000000), 'entries_limit': integer(1, 40)}, + required=['family', 'origin_catalog_id', 'producer_id']) +PROJECT_FILES_M2 = frozenset(PROJECT_FILES_M2) +_PROJECT_FILES_RECEIPT_IDS = _PROJECT_FILES_RECEIPT_IDS | frozenset( + name for name in PROJECT_FILES_M2 if next(op for op in _OPERATIONS if op['operation_id'] == name)['side_effects']) + +# Explicit current-local execution is separate from imported history. +_PROJECT_CURRENT_GRANT = obj({'id': ID, 'catalog_id': ID, 'approver_actor_id': ID, + 'approver_credential_id': {'oneOf': [ID, {'type': 'null'}]}, 'consumer_actor_id': ID, + 'material_sha256': SHA, 'created_at': SAFE_NAT, 'expires_at': SAFE_NAT, + 'revoked_at': {'oneOf': [SAFE_NAT, {'type': 'null'}]}}) +PROJECT_CURRENT_APPROVAL = obj({'format': const('project_current_execution_approval_v1'), + 'current_local': const(True), 'grant': _PROJECT_CURRENT_GRANT}) +PROJECT_RUN_ADMISSION = obj({'format': const('project_run_admission_v1'), + 'origin_catalog_id': ID, 'run_id': ID, 'request_sha256': SHA}) +_PROJECT_EXECUTION_HEALTH = obj({'format': const('project_execution_health_v1'), + 'active_run_ids': array(ID, 40), 'active_count': SAFE_NAT, 'offset': SAFE_NAT, + 'limit': integer(1, 40), 'local_owner_active': {'type': 'boolean'}, 'available': {'type': 'boolean'}, + 'drain_required': {'type': 'boolean'}}) +_project_file_operation('execution_health', 'GET', '/execution', _PROJECT_FILES_PAGE_INPUT, + required=[], output=_PROJECT_EXECUTION_HEALTH, milestone=3) +_project_file_operation('execution_preflight_external', 'POST', '/execution/preflight', + {'graph_id': ID, 'revision': SAFE_POS}, milestone=3) +_project_file_operation('execution_approve_external', 'POST', '/execution/approvals', + {'graph_id': ID, 'graph_revision': SAFE_POS, 'expected_digest': SHA, + 'consumer_actor_id': ID, 'ttl_seconds': integer(60, 3600)}, + required=['graph_id', 'graph_revision', 'expected_digest', 'consumer_actor_id'], + output=PROJECT_CURRENT_APPROVAL, mutation=True, owner=True, + scopes=('inspect', 'approve-execution'), milestone=3) +_project_file_operation('execution_submit_external', 'POST', '/execution/runs', + {'graph_id': ID, 'graph_revision': SAFE_POS, 'expected_digest': SHA, 'approval_id': ID}, + required=['graph_id', 'graph_revision', 'expected_digest'], output=PROJECT_RUN_ADMISSION, + mutation=True, scopes=('inspect', 'execute'), milestone=3) +_OPERATIONS[-1]['inputSchema']['properties']['request_key'] = copy.deepcopy(next(op for op in _OPERATIONS if op['operation_id'] == 'runs.submit_external')['inputSchema']['properties']['request_key']) +_project_file_operation('execution_cancel', 'POST', '/execution/runs/{run_id}/cancel', + {'run_id': ID}, output=PROJECT_RUN_ADMISSION, mutation=True, + scopes=('inspect', 'cancel'), milestone=3) +_project_file_operation('execution_study_continue', 'POST', '/execution/studies/{study_id}/continue', + {'study_id': ID, 'revision': SAFE_POS, 'admission_epoch': SAFE_NAT}, output=_STUDY, + mutation=True, owner=True, scopes=('inspect', 'execute'), milestone=3) +_project_file_operation('execution_study_approve', 'POST', '/execution/studies/{study_id}/approvals', + {'study_id': ID, 'trial_id': ID, 'expected_material_sha256': SHA, 'consumer_actor_id': ID, + 'ttl_seconds': integer(60, 3600)}, + required=['study_id', 'trial_id', 'expected_material_sha256', 'consumer_actor_id'], + output=PROJECT_CURRENT_APPROVAL, mutation=True, owner=True, + scopes=('inspect', 'approve-execution'), milestone=3) +_project_file_operation('execution_study_admit', 'POST', '/execution/studies/{study_id}/runs', + {'study_id': ID, 'revision': SAFE_POS, 'admission_epoch': SAFE_NAT, 'trial_id': ID, + 'approval_id': ID, 'parent_run_id': ID}, + required=['study_id', 'revision', 'admission_epoch', 'trial_id'], + output=obj({'format': const('project_study_admission_v1'), 'origin_catalog_id': ID, + 'study_id': ID, 'trial_id': ID, 'run_id': ID, 'reservation': _RESERVATION}), + mutation=True, scopes=('inspect', 'execute'), milestone=3) +_project_file_operation('execution_study_stop', 'POST', '/execution/studies/{study_id}/stop', + {'study_id': ID, 'revision': SAFE_POS}, + output=obj({'format': const('project_study_stop_v1'), 'origin_catalog_id': ID, + 'study_id': ID, 'receipt': obj({'study_id': ID, 'stop_receipt_id': ID, + 'admission_epoch': SAFE_NAT, 'run_ids': array(ID, 100000)}), + 'study_revision': SAFE_POS, 'state': {'enum': ['draft', 'open', 'stopping', 'stopped', 'unknown']}, + 'open': {'type': 'boolean'}}), mutation=True, scopes=('inspect', 'cancel'), milestone=3) +_project_file_operation('execution_reconcile_current', 'POST', '/execution/reconcile-current', + output=obj({'format': const('project_execution_reconciliation_v1'), 'origin_catalog_id': ID, + 'run_ids': array(ID, 100000), 'available': {'type': 'boolean'}}), + mutation=True, receipt=False, owner=True, scopes=('inspect', 'cancel'), milestone=3) + +# Finite native producer grammars. Each closed document remains validated by +# its domain owner; no operation name or callable can be supplied by a client. +_PROJECT_PRODUCER_MODE = {'protocol': const('pumas_typed_v1'), + 'profile': {'oneOf': [{'type': 'null'}, {'type': 'string', 'maxLength': 128, 'pattern': '^[A-Za-z0-9_.-]*$'}]}} +_PROJECT_PRODUCER_SOURCE = {'source_id': ID, 'revision': SAFE_POS, 'source_revision': SAFE_POS, + 'server_url': text(2048), 'model': text(256), 'instruction': text(2000), + 'seed': {'oneOf': [integer(0, 4294967295), {'type': 'null'}]}} +_PROJECT_PRODUCER_LABELS = array(text(80), 30, 1) +_PROJECT_PRODUCER_START_DOCUMENTS = { + 'generation_history': obj({'server_url': text(2048), 'model': text(256), 'prompt': text(4000), + 'count': integer(1, 10000), 'strategy': {'enum': ['varied', 'repeat']}, 'session_id': text(120), + 'width': SAFE_POS, 'height': SAFE_POS, 'seed': integer(0, 4294967295), + 'prompt_url': text(2048), 'prompt_model': text(256), 'book_id': text(120, True), + 'split': {'enum': ['unassigned', 'train', 'validation', 'test']}, **_PROJECT_PRODUCER_MODE}, + required=['server_url', 'model', 'prompt', 'count', 'strategy', 'session_id']), + 'grounded_history': obj(dict(_PROJECT_PRODUCER_SOURCE, count=integer(1, 10), **_PROJECT_PRODUCER_MODE), + required=[*_PROJECT_PRODUCER_SOURCE, 'count']), + 'caption_proposal_history': obj(dict(_PROJECT_PRODUCER_SOURCE, request_id=ID, **_PROJECT_PRODUCER_MODE), + required=[*_PROJECT_PRODUCER_SOURCE, 'request_id']), + 'text_proposal_history': obj(dict(_PROJECT_PRODUCER_SOURCE, request_id=ID, + labels=_PROJECT_PRODUCER_LABELS, **_PROJECT_PRODUCER_MODE), + required=[*_PROJECT_PRODUCER_SOURCE, 'request_id', 'labels'])} +for _document in _PROJECT_PRODUCER_START_DOCUMENTS.values(): + _document['dependentRequired'] = {'protocol': ['profile'], 'profile': ['protocol']} +_PROJECT_PRODUCER_ACTION = obj({'format': const('project_producer_action_v1'), + 'family': _PROJECT_FILES_PRODUCER_FAMILY, 'origin_catalog_id': ID, 'producer_id': ID, + 'action': {'enum': ['start', 'resume', 'review', 'decide', 'cancel']}}) +_project_file_operation('execution_producer_start', 'POST', '/execution/producers', + {'family': _PROJECT_FILES_PRODUCER_FAMILY, 'document': JSON_OBJECT}, + output=_PROJECT_PRODUCER_ACTION, mutation=True, owner=True, scopes=('inspect', 'execute'), milestone=3) +_OPERATIONS[-1]['inputSchema']['allOf'] = [ + {'if': {'properties': {'family': const(family)}}, + 'then': {'properties': {'document': document}}} + for family, document in _PROJECT_PRODUCER_START_DOCUMENTS.items()] +_project_file_operation('execution_generation_resume', 'POST', '/execution/generation/{producer_id}/resume', + {'producer_id': ID}, output=_PROJECT_PRODUCER_ACTION, mutation=True, owner=True, + scopes=('inspect', 'execute'), milestone=3) +_project_file_operation('execution_grounded_review', 'POST', '/execution/grounded/{producer_id}/review', + {'producer_id': ID, 'document': obj({'revision': SAFE_POS, 'candidate_id': ID, + 'decision': {'enum': ['reject', 'admit_draft']}, 'note': text(1000)})}, + output=_PROJECT_PRODUCER_ACTION, mutation=True, scopes=('inspect', 'edit'), milestone=3) +_project_file_operation('execution_producer_decide', 'POST', '/execution/producers/{producer_id}/decide', + {'family': {'enum': ['caption_proposal_history', 'text_proposal_history']}, 'producer_id': ID, + 'document': obj({'revision': SAFE_POS, 'decision': {'enum': ['reject', 'apply_draft']}, + 'labels': _PROJECT_PRODUCER_LABELS}, required=['revision', 'decision'])}, + output=_PROJECT_PRODUCER_ACTION, mutation=True, scopes=('inspect', 'edit'), milestone=3) +_project_file_operation('execution_producer_cancel', 'POST', '/execution/producers/{producer_id}/cancel', + {'family': _PROJECT_FILES_PRODUCER_FAMILY, 'producer_id': ID}, output=_PROJECT_PRODUCER_ACTION, + mutation=True, scopes=('inspect', 'cancel'), milestone=3) +_project_file_operation('execution_producer_health', 'GET', '/execution/producers/health', + _PROJECT_FILES_PAGE_INPUT, required=[], + output=obj({'format': const('project_producer_health_v1'), + 'items': array(obj({'family': _PROJECT_FILES_PRODUCER_FAMILY, 'producer_id': ID, + 'status': text(120), 'provider_outcome': {'oneOf': [{'type': 'null'}, text(120)]}, + 'worker_alive': {'type': 'boolean'}}), 40), + 'total': SAFE_NAT, 'offset': SAFE_NAT, 'limit': integer(1, 40), 'fenced': {'type': 'boolean'}}), milestone=3) +# Fixed current-host CPU capture projections. Bounds mirror runtime_snapshots +# and recipe_process; native inventories stay canonical, never transport grants. +_PROJECT_RUNTIME_SOURCE = {'type':'string','pattern':'^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$'} +_PROJECT_RUNTIME_FILE = obj({'path':text(1024),'bytes':integer(0,512*1024*1024), + 'sha256':SHA,'mode':{'enum':[0o400,0o500]},'kind':{'enum':['data','elf']}}) +_PROJECT_RUNTIME_LAUNCH = obj({**{field:text(1024) for field in ('python','loader','home','stdlib','dynload','packages')}, + 'library_dirs':dict(array(text(1024),32,1),uniqueItems=True)}) +_PROJECT_RUNTIME_REF = obj({'kind':const('runtime'),'catalog_id':ID,'id':SHA,'sha256':SHA}) +_PROJECT_RUNTIME_SUMMARY = {'project_id':ID,'manifest_sha256':SHA,'file_count':integer(1,40000), + 'total_bytes':integer(0,2*1024*1024*1024),'actor_id':ID,'observed_at':text(80),'request_key':KEY} +_PROJECT_RUNTIME_INTENT = obj(dict(_PROJECT_RUNTIME_SUMMARY,format=const('project_runtime_capture_intent_v1'))) +_PROJECT_RUNTIME_CAPTURE = obj(dict(_PROJECT_RUNTIME_SUMMARY,format=const('project_runtime_capture_v1'),runtime_ref=_PROJECT_RUNTIME_REF)) +_project_file_operation('runtime_sources','GET','/runtime/sources',_PROJECT_FILES_PAGE_INPUT,required=[], + output=obj(dict(_PROJECT_FILES_PAGE['properties'],items=array(obj({'source':_PROJECT_RUNTIME_SOURCE}),40))), + owner=True,scopes=('inspect','admin'),milestone=3) +_project_file_operation('runtime_inspect','GET','/runtime/inspect',{'source':_PROJECT_RUNTIME_SOURCE,**_PROJECT_FILES_PAGE_INPUT},required=['source'], + output=obj({'format':const('project_runtime_inspection_v1'),'source':_PROJECT_RUNTIME_SOURCE,'inventory_sha256':SHA, + 'profile':const('captured_cpu_python_v1'),'python_version':const('3.12'),'file_count':integer(1,40000), + 'total_bytes':integer(1,2*1024*1024*1024),'manifest_bytes':integer(1,16*1024*1024),'free_bytes':SAFE_NAT, + 'packages':array(obj({'name':text(128),'version':text(128)}),256,2),'launch':_PROJECT_RUNTIME_LAUNCH, + 'files':obj(dict(_PROJECT_FILES_PAGE['properties'],items=array(_PROJECT_RUNTIME_FILE,40)))}), + owner=True,scopes=('inspect','admin'),milestone=3) +_project_file_operation('runtime_capture','POST','/runtime/capture',{'source':_PROJECT_RUNTIME_SOURCE, + 'expected_inventory_sha256':SHA,'reserve_bytes':SAFE_NAT},required=['source','expected_inventory_sha256'], + output=_PROJECT_RUNTIME_CAPTURE,mutation=True,owner=True,scopes=('inspect','admin'),milestone=3) +_project_file_operation('runtime_status','GET','/runtime/status',{'capture_key':KEY}, + output=obj({'state':{'enum':['absent','intent_recorded','captured']}, + 'intent':{'oneOf':[{'type':'null'},_PROJECT_RUNTIME_INTENT]}, + 'result':{'oneOf':[{'type':'null'},_PROJECT_RUNTIME_CAPTURE]}}),milestone=3) +# Current Candidate package preparation is distinct from approval/admission. +_PROJECT_PREPARATION_KEY={'type':'string','pattern':'^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$'} +_PROJECT_PREPARATION_NAME={'type':'string','pattern':'^[A-Za-z0-9][A-Za-z0-9_.-]{0,119}$'} +for _kind,_suffix in (('package','packages'),('verifier','verifiers')): + _result=dict(_PACKAGE_PREPARED_FIELDS) + if _kind=='verifier':_result.update(package_run_id=ID,package_sha256=SHA) + _project_file_operation('execution_study_prepare_'+_kind,'POST','/execution/studies/{study_id}/'+_suffix, + domain='studies.prepare_'+_kind,output=obj(_result),mutation=True,owner=True,scopes=('inspect','edit'),milestone=3) + _OPERATIONS[-1]['inputSchema']['properties']['directory']=_PROJECT_PREPARATION_NAME +_PROJECT_PREPARATION_ABANDON=obj({'format':const('project_candidate_preparation_abandon_v1'), + 'origin_catalog_id':ID,'study_id':ID,'preparation_key':_PROJECT_PREPARATION_KEY, + 'preparation_operation':{'enum':['study_execution.prepare_package','study_execution.prepare_verifier']}, + 'input_sha256':SHA,'candidate':material_ref('candidate'),'actor_id':ID,'reason':text(1000), + 'observed_checkpoint':SHA,'occurred_at':text(80),'state':const('abandoned')}) +_project_file_operation('execution_study_abandon_preparation','POST','/execution/studies/{study_id}/preparations/abandon', + {'study_id':ID,'preparation_key':_PROJECT_PREPARATION_KEY,'reason':text(1000)}, + output=_PROJECT_PREPARATION_ABANDON,mutation=True,owner=True,scopes=('inspect','cancel'),milestone=3) +_PROJECT_PREPARATION_COMPLETED={'oneOf':[obj(_PACKAGE_PREPARED_FIELDS),obj(dict(_PACKAGE_PREPARED_FIELDS,package_run_id=ID,package_sha256=SHA))]} +_project_file_operation('execution_study_reconcile_preparation','POST','/execution/studies/{study_id}/preparations/reconcile', + {'study_id':ID,'preparation_key':_PROJECT_PREPARATION_KEY},output=_PROJECT_PREPARATION_COMPLETED, + mutation=True,owner=True,scopes=('inspect','cancel'),milestone=3,receipt=False) +# Explicitly abandon only a current local stopped copy, retaining its science. +_PROJECT_RUNTIME_ABANDON=obj({'format':const('project_runtime_capture_abandon_v1'),'project_id':ID, + 'capture_key':KEY,'input_sha256':SHA,'manifest_sha256':SHA,'actor_id':ID,'reason':text(1000), + 'observed_checkpoint':SHA,'observed_at':text(80)}) +_project_file_operation('runtime_abandon','POST','/runtime/abandon',{'capture_key':KEY,'reason':text(1000)}, + output=_PROJECT_RUNTIME_ABANDON,mutation=True,owner=True,scopes=('inspect','admin'),milestone=3) +_runtime_status=next(op for op in _OPERATIONS if op['operation_id']=='project_files.runtime_status') +_runtime_status_value=_runtime_status['outputSchema']['oneOf'][0]['properties']['result']['properties']['value'] +_runtime_status['outputSchema']['oneOf'][0]['properties']['result']['properties']['value']={'oneOf':[ + copy.deepcopy(_runtime_status_value),obj({'state':const('abandoned'),'intent':_PROJECT_RUNTIME_INTENT, + 'result':const(None),'abandonment':_PROJECT_RUNTIME_ABANDON})]} +# Explicit current native probes and Runs. Current operator runtime is selected +# by the native owner; no imported descriptor or serialized probe is authority. +_PROJECT_NATIVE_PHASE = obj({'format':const('project_native_phase_v1'),'origin_catalog_id':ID, + 'phase_id':ID,'phase':{'enum':['probe','train','verify']},'run_id':{'oneOf':[const(None),ID]}, + 'state':{'enum':['prepared','ready','go_intent','completed','failed','unknown']}, + 'spec_sha256':SHA,'receipt_sha256':{'oneOf':[const(None),SHA]}}) +_project_file_operation('native_preflight','POST','/execution/native/preflight', + {'graph_id':ID,'revision':SAFE_POS},mutation=True,owner=True,scopes=('inspect','execute'),milestone=3) +_project_file_operation('native_submit','POST','/execution/native/runs', + {'graph_id':ID,'graph_revision':SAFE_POS,'validation_token':SHA},output=PROJECT_RUN_ADMISSION, + mutation=True,owner=True,scopes=('inspect','execute'),milestone=3) +_project_file_operation('native_retry','POST','/execution/native/runs/{run_id}/retry', + {'run_id':ID},output=PROJECT_RUN_ADMISSION,mutation=True,owner=True,scopes=('inspect','execute'),milestone=3) +_project_file_operation('native_reconcile','POST','/execution/native/phases/{phase_id}/reconcile', + {'phase_id':ID},output=_PROJECT_NATIVE_PHASE,mutation=True,owner=True, + scopes=('inspect','cancel'),milestone=3,receipt=False) +PROJECT_FILES_M3 = frozenset(PROJECT_FILES_M3) +_PROJECT_FILES_RECEIPT_IDS |= frozenset(name for name in PROJECT_FILES_M3 + if next(op for op in _OPERATIONS if op['operation_id'] == name)['side_effects'] and name not in ('project_files.execution_reconcile_current','project_files.execution_study_reconcile_preparation','project_files.native_reconcile')) + +# M4 adds independent Save As and explicit inert working-file adoption without +# changing the frozen lifecycle state/actions or earlier per-operation pins. +_PROJECT_FILE_KIND={'enum':['script','note']} +_PROJECT_FILE_VERSION_META={'revision':SAFE_POS,'sha256':SHA,'bytes':integer(0,1048576), + 'adopted_at':text(80),'actor_id':ID} +_PROJECT_FILE_DELETED_META={'revision':integer(2,9007199254740991),'deleted':const(True),'sha256':{'type':'null'}, + 'bytes':const(0),'adopted_at':text(80),'actor_id':ID} +_PROJECT_FILE_RESULT=obj({'id':SHA,'project_id':ID,'kind':_PROJECT_FILE_KIND,'path':text(512), + 'revision':SAFE_POS,'sha256':SHA,'bytes':integer(0,1048576),'adopted_at':text(80),'changed':{'type':'boolean'}}) +_PROJECT_FORK_RESULT=obj({'source_project_id':ID,'source_checkpoint':SHA,'project_id':ID,'checkpoint':SHA, + 'root':text(160),'directory':text(512),'request_key':KEY,'reconciled':{'type':'boolean'}, + 'mapping_count':SAFE_NAT,'copied_count':SAFE_NAT,'missing_regenerable_count':SAFE_NAT}) +model_operation('project_files.save_as','POST','/api/workspace/project-files/save-as', + dict(_PROJECT_FILES_MUTATION,root=text(160),directory=text(512),name=text(200)), + output=obj({'state':PROJECT_FILES_STATE,'fork':_PROJECT_FORK_RESULT}), + scopes=('inspect','edit'),mutation=True,description='Create an independent idle project fork at an explicit configured local destination; retain inert origins and exact copy completeness.') +_OPERATIONS[-1]['outputSchema']['oneOf'][1]['properties']['result']['properties']['receipt']=PROJECT_FILES_RECEIPT +PROJECT_FILES_M4.add('project_files.save_as');_OWNER_OPERATION_IDS.add('project_files.save_as');_MODEL_RECEIPT_IDS.add('project_files.save_as') +_PROJECT_FILE_INSPECTION=obj({'id':SHA,'kind':_PROJECT_FILE_KIND,'path':text(512),'revision':SAFE_POS, + 'expected_sha256':{'oneOf':[SHA,{'type':'null'}]},'expected_bytes':integer(0,1048576), + 'observed_sha256':{'oneOf':[SHA,{'type':'null'}]},'observed_bytes':{'oneOf':[integer(0,1048576),{'type':'null'}]}, + 'state':{'enum':['unchanged','changed','missing','invalid','deleted']}, + 'reason':{'oneOf':[{'type':'null'},{'enum':['missing','unsafe_path','too_large','invalid_utf8','unreadable','unstable']}]}}) +_project_file_operation('files_inspect','GET','/files',_PROJECT_FILES_PAGE_INPUT,required=[], + output=obj(dict(_PROJECT_FILES_PAGE['properties'],items=array(_PROJECT_FILE_INSPECTION,40))),milestone=4) +_project_file_operation('file_get','GET','/files/{file_id}',{'file_id':SHA,'revision':SAFE_POS},required=['file_id'], + output=obj({'id':SHA,'project_id':ID,'kind':_PROJECT_FILE_KIND,'path':text(512), + 'version':{'oneOf':[obj(dict(_PROJECT_FILE_VERSION_META,text=text(1048576,empty=True))), + obj(dict(_PROJECT_FILE_DELETED_META,text={'type':'null'}))]}}),milestone=4) +_project_file_operation('file_history','GET','/files/{file_id}/history',{'file_id':SHA,**_PROJECT_FILES_PAGE_INPUT},required=['file_id'], + output=obj(dict(_PROJECT_FILES_PAGE['properties'],id=SHA,items=array({'oneOf':[obj(_PROJECT_FILE_VERSION_META),obj(_PROJECT_FILE_DELETED_META)]},40))),milestone=4) +_project_file_operation('file_adopt','POST','/files/adopt',{'kind':_PROJECT_FILE_KIND,'path':text(512), + 'expected_revision':SAFE_NAT,'expected_sha256':SHA},output=_PROJECT_FILE_RESULT,mutation=True,milestone=4) +_project_file_operation('file_adopt_deletion','POST','/files/adopt-deletion',{'path':text(512), + 'expected_revision':SAFE_POS,'expected_sha256':SHA},output=obj(dict(_PROJECT_FILE_RESULT['properties'], + kind=const('note'),revision=integer(2,9007199254740991),deleted=const(True),sha256={'type':'null'},bytes=const(0),changed=const(True))), + mutation=True,milestone=4) +_PROJECT_INTENT_FAMILY={'enum':['model_definition','pipeline_graph','recipe_definition']} +_PROJECT_INTENT_FIELDS={'enum':['task','specification','source_selector','preprocessing_policy','output_policy', + 'recipe_source','inputs','runtime','release','nodes','data','evaluation_profile','model','trainer']} +_PROJECT_INTENT_RESELECT=dict(array(_PROJECT_INTENT_FIELDS,14),uniqueItems=True) +_PROJECT_FORK_ORIGIN=obj({'project_id':ID,'checkpoint':SHA,'family':_PROJECT_INTENT_FAMILY, + 'id':text(512),'revision':SAFE_POS,'created_at':text(80)}) +_PROJECT_INTENT_CURRENT=obj({'format':const('project_material_fork_intent_v1'),'catalog_id':ID,'id':ID, + 'revision':const(1),'name':text(200),'definition_family':_PROJECT_INTENT_FAMILY, + 'definition':JSON_OBJECT,'required_reselection':_PROJECT_INTENT_RESELECT}) +_project_file_operation('material_intents','GET','/material-intents',_PROJECT_FILES_PAGE_INPUT,required=[], + output=obj(dict(_PROJECT_FILES_PAGE['properties'],items=array(obj({'family':_PROJECT_INTENT_FAMILY, + 'id':text(65),'catalog_id':ID,'definition_id':ID,'name':text(200),'revision':const(1), + 'required_reselection':_PROJECT_INTENT_RESELECT,'fork_origin':_PROJECT_FORK_ORIGIN}),40))),milestone=4) +_project_file_operation('material_intent','GET','/material-intents/{family}/{definition_id}', + {'family':_PROJECT_INTENT_FAMILY,'definition_id':ID},output=obj({'family':_PROJECT_INTENT_FAMILY, + 'id':text(65),'current':_PROJECT_INTENT_CURRENT,'fork_origin':_PROJECT_FORK_ORIGIN}),milestone=4) +_project_file_operation('material_intent_capabilities','GET','/material-intents/capabilities',_PROJECT_FILES_PAGE_INPUT,required=[], + output=obj(dict(_PROJECT_FILES_PAGE['properties'],items=array(obj({'family':_PROJECT_INTENT_FAMILY, + 'id':text(65),'requires_reselection':_PROJECT_INTENT_RESELECT}),40))),milestone=4) +_PROJECT_FORK_MAPPING=obj({'family':text(80),'source_id':text(512),'source_revision':{'oneOf':[SAFE_POS,{'type':'null'}]},'destination_id':text(512)}) +_project_file_operation('fork_info','GET','/fork',_PROJECT_FILES_PAGE_INPUT,required=[],output=obj({ + 'source':{'oneOf':[{'type':'null'},obj({'project_id':ID,'checkpoint':SHA})]},'project_id':ID, + 'mapping':obj(dict(_PROJECT_FILES_PAGE['properties'],items=array(_PROJECT_FORK_MAPPING,40))), + 'archived_count':SAFE_NAT,'copied_count':SAFE_NAT,'missing_regenerable_count':SAFE_NAT}),milestone=4) +PROJECT_FILES_M4=frozenset(PROJECT_FILES_M4) +_PROJECT_FILES_RECEIPT_IDS |= frozenset(name for name in PROJECT_FILES_M4 if next(op for op in _OPERATIONS if op['operation_id']==name)['side_effects']) + +# M5 is a separate current-owner controller, never a project-open authority. +PROJECT_TRANSFER_OPERATIONS=frozenset('project_transfer.'+action for action in ('status','preview','report_page','stop','extract')) +_PROJECT_TRANSFER_KEY={'type':'string','pattern':'^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$'} +_PROJECT_TRANSFER_ALIAS={'type':'string','pattern':'^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$'} +_PROJECT_TRANSFER_DIRECTORY={'type':'string','minLength':1,'maxLength':200,'pattern':r'^[^/\\\x00-\x1f]+$','not':{'enum':['.','..']}} +_PROJECT_TRANSFER_ROOTS={ + 'records':ID,'responses':ID,'preferences':ID,'selections':ID,'searches':ID,'graphs':ID, + 'recipe_drafts':ID,'model_definitions':ID,'studies':ID,'runs':ID,'releases':SHA,'exposures':ID, + 'model_materials':SHA,'recipe_materials':SHA,'runtime_materials':SHA} +_PROJECT_TRANSFER_FAMILIES=['workbench_record','workbench_response','workbench_preference','saved_selection','saved_search', + 'pipeline_graph','recipe_definition','model_definition','study_history','run_history','dataset_release','dataset_exposure', + 'model_material','recipe_material','runtime_material','trial_history','candidate_history','project_snapshot', + 'generation_history','grounded_history','caption_proposal_history','text_proposal_history','project_artifact','project_file'] +_PROJECT_TRANSFER_SECTION={'enum':['included','required','unassigned','excluded','blockers','warnings','artifacts','working_files']} +_PROJECT_TRANSFER_LOCATOR={'family':{'enum':_PROJECT_TRANSFER_FAMILIES},'id':text(128)} +_PROJECT_TRANSFER_SELECTION=obj({'project_snapshot':obj({'id':ID,'manifest_sha256':SHA}), + 'roots':obj({**{name:dict(array(schema,10000),uniqueItems=True) for name,schema in _PROJECT_TRANSFER_ROOTS.items()}, + 'producer_jobs':dict(array(obj({'kind':{'enum':['generation','grounded','caption','text_proposal']},'id':ID}),10000),uniqueItems=True), + 'working_files':dict(array(obj({'kind':{'enum':['script','note']},'path':text(512)}),1000),uniqueItems=True)},required=[]), + 'decisions':array(obj(dict(_PROJECT_TRANSFER_LOCATOR,include={'type':'boolean'},reason=text(1000))),10000)}) +_PROJECT_TRANSFER_SOURCE=obj({'catalog_id':ID,'schema_version':const(10),'catalog_signature':SHA, + 'snapshot_sha256':SHA,'backup_receipt_sha256':{'oneOf':[SHA,{'type':'null'}]},'external_inventory_sha256':SHA}) +_PROJECT_TRANSFER_COUNTS=obj({name:SAFE_NAT for name in _PROJECT_TRANSFER_SECTION['enum']}) +_PROJECT_TRANSFER_REPORT=obj({'report_sha256':SHA,'selection_sha256':SHA,'source':_PROJECT_TRANSFER_SOURCE, + 'can_extract':{'type':'boolean'},'counts':_PROJECT_TRANSFER_COUNTS}) +_PROJECT_TRANSFER_RECEIPT=obj({'format':const('project_transfer_receipt_v1'),'committed':const(True), + 'project_id':ID,'checkpoint':SHA,'request_key':_PROJECT_TRANSFER_KEY,'input_sha256':SHA,'report_sha256':SHA, + 'source':_PROJECT_TRANSFER_SOURCE,'artifacts':obj({'copied_count':SAFE_NAT,'missing_regenerable_count':SAFE_NAT}), + 'verification':obj({'checkpoint':SHA,'active_data_records':SAFE_NAT}),'replayed':{'type':'boolean'}}) +_PROJECT_TRANSFER_PARTIAL=obj({'committed':const(True),'selected':const(False),'project_id':ID,'checkpoint':SHA, + 'request_key':_PROJECT_TRANSFER_KEY,'directory':_PROJECT_TRANSFER_DIRECTORY}) +_PROJECT_TRANSFER_FAILURE=obj({'action':{'enum':['stop','extract']},'code':text(120),'detail':text(1000,empty=True), + 'staging_directory':text(200)},required=['action','code','detail']) +PROJECT_TRANSFER_STATE=obj({'format':const('project_transfer_state_v1'),'binding':ID, + 'phase':{'enum':['source_live','source_stopped','source_fenced']},'source_catalog_id':ID,'control_catalog_id':ID, + 'roots':dict(array(_PROJECT_TRANSFER_ALIAS,100),uniqueItems=True), + 'available_actions':dict(array({'enum':['status','preview','report_page','stop','extract']},5,1),uniqueItems=True), + 'report':{'oneOf':[{'type':'null'},_PROJECT_TRANSFER_REPORT]}, + 'backup':{'oneOf':[{'type':'null'},obj({'root':_PROJECT_TRANSFER_ALIAS,'directory':_PROJECT_TRANSFER_DIRECTORY,'receipt_sha256':SHA})]}, + 'destination':{'oneOf':[{'type':'null'},obj({'root':_PROJECT_TRANSFER_ALIAS,'directory':_PROJECT_TRANSFER_DIRECTORY})]}, + 'failure':{'oneOf':[{'type':'null'},_PROJECT_TRANSFER_FAILURE]}, + 'last_receipt':{'oneOf':[{'type':'null'},_PROJECT_TRANSFER_RECEIPT,_PROJECT_TRANSFER_PARTIAL]}}) +_PROJECT_TRANSFER_ITEMS={ + 'included':obj(dict(_PROJECT_TRANSFER_LOCATOR,revision={'oneOf':[SAFE_POS,{'type':'null'}]})), + 'unassigned':obj(dict(_PROJECT_TRANSFER_LOCATOR,revision={'oneOf':[SAFE_POS,{'type':'null'}]})), + 'required':obj(_PROJECT_TRANSFER_LOCATOR),'excluded':obj(_PROJECT_TRANSFER_LOCATOR), + 'blockers':obj({'family':{'enum':_PROJECT_TRANSFER_FAMILIES+['source','closure']},'id':text(128),'code':text(120),'detail':text(1000,empty=True)}), + 'warnings':obj({'family':{'enum':_PROJECT_TRANSFER_FAMILIES+['source','closure']},'id':text(128),'code':text(120),'detail':text(1000,empty=True)}), + 'artifacts':obj({'sha256':SHA,'bytes':integer(0,2**63-1),'preservation':{'enum':['unique','regenerable']},'state':{'enum':['verified','missing','corrupt']}}), + 'working_files':obj({'kind':{'enum':['script','note']},'path':text(512),'sha256':SHA,'bytes':integer(0,1048576)})} +_PROJECT_TRANSFER_PAGE=obj({'format':const('project_transfer_report_page_v1'),'binding':ID,'report_sha256':SHA, + 'section':_PROJECT_TRANSFER_SECTION,'items':array({'oneOf':list(_PROJECT_TRANSFER_ITEMS.values())},40), + 'offset':integer(0,1000000),'limit':integer(1,40),'total':SAFE_NAT}) +# Some sections share item schemas; section-specific validation below selects one. +_PROJECT_TRANSFER_PAGE['properties']['items']['items']={} +_PROJECT_TRANSFER_PAGE['allOf']=[{'if':{'properties':{'section':const(section)}},'then':{'properties':{'items':array(schema,40)}}} + for section,schema in _PROJECT_TRANSFER_ITEMS.items()] +_PROJECT_TRANSFER_RESULT=obj({'format':const('project_transfer_extraction_result_v1'),'binding':ID, + 'receipt':_PROJECT_TRANSFER_RECEIPT,'selection':obj({'selected':{'type':'boolean'}, + 'state':{'oneOf':[{'type':'null'},PROJECT_FILES_STATE]}, + 'error':{'oneOf':[{'type':'null'},obj({'code':text(120),'detail':text(1000,empty=True)})]}})}) +for _action,_method,_suffix,_fields,_needed,_output,_mutation in ( + ('status','GET','',{},[],PROJECT_TRANSFER_STATE,False), + ('preview','POST','/preview',{'binding':ID,'selection':_PROJECT_TRANSFER_SELECTION},None,PROJECT_TRANSFER_STATE,True), + ('report_page','GET','/report',{'binding':ID,'report_sha256':SHA,'section':_PROJECT_TRANSFER_SECTION, + 'offset':integer(0,1000000),'limit':integer(1,40)},['binding','report_sha256','section'],_PROJECT_TRANSFER_PAGE,False), + ('stop','POST','/stop',{'binding':ID,'report_sha256':SHA,'backup_root':_PROJECT_TRANSFER_ALIAS, + 'backup_directory':_PROJECT_TRANSFER_DIRECTORY,'destination_root':_PROJECT_TRANSFER_ALIAS,'directory':_PROJECT_TRANSFER_DIRECTORY},None,PROJECT_TRANSFER_STATE,True), + ('extract','POST','/extract',{'binding':ID,'report_sha256':SHA,'destination_root':_PROJECT_TRANSFER_ALIAS, + 'directory':_PROJECT_TRANSFER_DIRECTORY,'name':text(200),'request_key':_PROJECT_TRANSFER_KEY},None,_PROJECT_TRANSFER_RESULT,True)): + _name='project_transfer.'+_action + model_operation(_name,_method,'/api/workspace/project-transfer'+_suffix,_fields,_needed, + output=_output,scopes=('inspect','admin'),mutation=_mutation,query=tuple(_fields) if _method=='GET' else (), + description='Explicit current-owner legacy extraction '+_action.replace('_',' ')+'. Source and control authority remain separate; no execution or credential transfer.') + _OPERATIONS[-1]['outputSchema']['oneOf'][1]['properties']['result']['properties']['receipt']=_PROJECT_TRANSFER_PARTIAL + _OWNER_OPERATION_IDS.add(_name) +_MODEL_RECEIPT_IDS.add('project_transfer.extract') + _OWNER_SNAPSHOT_HTTP_IDS=frozenset(('project_snapshots.list','project_snapshots.get','project_snapshots.open')) _LOOKUP = {key: operation for operation in _OPERATIONS for key in (operation['operation_id'], operation['mcp_name'])} # Explicit locally implemented versions. No remotely supplied code or routes. _CONTRACTS = {op['operation_id']: {1: copy.deepcopy(op)} for op in _OPERATIONS} +# v1 remains the closed Run-only current custody view. v2 additionally exposes +# bounded original local native phases; imported history never populates it. +_native_health=copy.deepcopy(_CONTRACTS['project_files.execution_health'][1]) +_native_health_value=_native_health['outputSchema']['oneOf'][0]['properties']['result']['properties']['value'] +_native_health_value['properties']['native_phases']=obj({'items':array(_PROJECT_NATIVE_PHASE,40), + 'total':SAFE_NAT,'offset':SAFE_NAT,'limit':integer(1,40)}) +_native_health_value['required'].append('native_phases') +_CONTRACTS['project_files.execution_health'][2]=_native_health + +# Fork-origin text requires an additive result contract; import-only v1 remains +# exact. Both record and each historical revision retain the actual origin. +_PROJECT_FILES_FORK_PROVENANCE=obj({'method':const('project_fork'),'rights':text(1000), + 'origin':obj({'project_id':ID,'checkpoint':SHA,'family':{'enum':['workbench_text','workbench_record']}, + 'id':ID,'revision':SAFE_POS,'source_revision':SAFE_POS})}) +for _name in ('project_files.get','project_files.annotate'): + _fork_text=copy.deepcopy(_CONTRACTS[_name][1]) + _fork_text['description']+=' Version 2 retains exact independent-fork provenance.' + _result=_fork_text['outputSchema']['oneOf'][0]['properties']['result']['properties'] + for _record_schema in (_result['record'],_result['history']['items']): + for _variant in _record_schema['oneOf']: + _variant['properties']['provenance']={'oneOf':[copy.deepcopy(_PROJECT_FILES_PROVENANCE),copy.deepcopy(_PROJECT_FILES_FORK_PROVENANCE)]} + _CONTRACTS[_name][2]=_fork_text + # Activity changes the closed output schema, so installed snapshot v1 contracts # retain their exact identity and wire projection. for _name in ('project_snapshots.get','project_snapshots.open'): @@ -958,6 +1609,19 @@ def model_operation(name,method,path,properties=None,required=None,*,output=None obj({'actor_id': ID, 'request_key': KEY, 'request_sha256': SHA})]} _publication_status_output['required'].append('continuation_request') _CONTRACTS['runs.publication_status'][2] = _publication_status_request +# Project mode is an additive consumer of the current fixed graph grammars. +# Copy only inputs after the domain versions have been composed; installed +# legacy graph contracts and all project binding/outcome fields retain owners. +for _project_name, _domain_name in ( + ('project_files.graph_create', 'graphs.create'), + ('project_files.graph_create_external', 'graphs.create_external'), + ('project_files.graph_save', 'graphs.save')): + _current_graph_input = copy.deepcopy(_CONTRACTS[_domain_name][max(_CONTRACTS[_domain_name])]['inputSchema']) + _current_graph_input['properties'].update(copy.deepcopy(_PROJECT_FILES_MUTATION)) + _current_graph_input['required'] = list(dict.fromkeys( + _current_graph_input['required'] + list(_PROJECT_FILES_MUTATION))) + _CONTRACTS[_project_name][1]['inputSchema'] = _current_graph_input + CONTRACT_HEADER = 'Tuldok-Operation-Contract' CAPABILITIES_LIMIT = 1024 * 1024 MAX_OPERATIONS = 256 @@ -1001,23 +1665,29 @@ def contract(name, version=1): 'authority': 'owner' if name in _OWNER_OPERATION_IDS else 'agent', 'effects': 'mutation' if op['side_effects'] else 'read', 'retry': retry, 'bounds': {'request_bytes': REQUEST_LIMIT, 'response_bytes': RESPONSE_LIMIT, 'inline_decoded_bytes': INLINE_LIMIT, 'json_depth': DEPTH_LIMIT}} + if name in PROJECT_TRANSFER_OPERATIONS:public['bounds']['response_bytes']=REQUEST_LIMIT identity = dict(public, id=name, method=op['method'], path=op['path'], query=op['query'], injected=op['injected'], binary_fields=op['binary_fields'], mcp_name=op['mcp_name']) public['sha256'] = hashlib.sha256(encoded(identity)).hexdigest() return public -def capabilities(catalog_id): +def capabilities(catalog_id, profile='catalog'): if type(catalog_id) is not str or not re.fullmatch('[a-f0-9]{32}', catalog_id): raise AgentAPIError('Invalid capability catalog identity.', 'registry_invalid', 503) if len(_CONTRACTS) > MAX_OPERATIONS: raise AgentAPIError('Operation registry exceeds its capability bound.', 'registry_invalid', 503) + if profile not in ('catalog','project'): + raise AgentAPIError('Unsupported capability profile.','registry_invalid',503) operations = [] for name, versions in _CONTRACTS.items(): + if (profile=='catalog' and name.startswith('project_files.') + or profile=='project' and not name.startswith(('project_files.','project_transfer.')) and name not in ('context.get','identity.get')): + continue if not 1 <= len(versions) <= MAX_CONTRACTS: raise AgentAPIError('Contract version count exceeds its bound.', 'registry_invalid', 503) operations.append({'id': name, 'contracts': [contract(name, version) for version in sorted(versions)], - 'availability': 'available'}) + 'availability': 'unavailable' if name in PROJECT_TRANSFER_OPERATIONS else 'available'}) value = {'format': 'agent_capabilities_v1', 'envelope_version': 1, 'catalog_id': catalog_id, 'operations': operations, 'limits': {'request_bytes': REQUEST_LIMIT, 'response_bytes': RESPONSE_LIMIT, 'max_operations': MAX_OPERATIONS}} @@ -1155,7 +1825,7 @@ def validate_selected(selection, method, target, body): arguments.update({key: unquote(value) for key, value in zip(fields, match.groups())}) properties = op['inputSchema'].get('properties', {}) for key, value in pairs: - if properties.get(key, {}).get('type') in ('integer', 'number'): + if properties.get(key, {}).get('type') in ('integer', 'number', 'boolean'): try: value = decode(value) except AgentAPIError: @@ -1189,11 +1859,38 @@ def operation(name, version=None): return copy.deepcopy(versions[max(versions) if version is None else version]) -def _finite(value, depth=0): +def _native_validation_result_ok(value, revision): + """Check the finite public truth fields without interpreting native runtime data. + + The native owner validates its scientific release/runtime observation. This + transport check prevents contradictory success and coercion into an ACK. + """ + fields={'status','valid','errors','validation_token','graph_revision','execution_sha256','release','runtime'} + def sha(raw): + return type(raw) is str and re.fullmatch('[a-f0-9]{64}',raw) is not None + if (type(value) is not dict or not fields.issubset(value) or set(value)-fields-{'selection_policy'} + or type(value['graph_revision']) is not int or value['graph_revision']!=revision + or type(value['valid']) is not bool or type(value['status']) is not str + or not value['status'].strip() or len(value['status'])>120 + or not sha(value['execution_sha256']) or type(value['errors']) is not list or len(value['errors'])>100 + or any(type(value[field]) is not dict and value[field] is not None for field in ('release','runtime'))): + return False + errors=value['errors'] + if any(type(error) is not dict or set(error)!={'path','message','code'} + or any(type(part) is not str or len(part)>2000 for part in error.values()) for error in errors): + return False + if value['valid']: + return (value['status']=='valid' and not errors and sha(value['validation_token']) + and value['release'] is not None and value['runtime'] is not None + and value['runtime'].get('available') is True) + return bool(errors) and value['status']==errors[0]['code'] and value['validation_token'] is None + + +def _finite(value, depth=0, *, nul_path=None, _path=()): if depth > DEPTH_LIMIT: raise AgentAPIError('JSON nesting exceeds the transport bound.') if isinstance(value, str): - if any(0xD800 <= ord(ch) <= 0xDFFF for ch in value) or '\x00' in value: + if any(0xD800 <= ord(ch) <= 0xDFFF for ch in value) or ('\x00' in value and _path != nul_path): raise AgentAPIError('Text contains an invalid code point.') elif isinstance(value, float) and not math.isfinite(value): raise AgentAPIError('JSON numbers must be finite.') @@ -1201,15 +1898,16 @@ def _finite(value, depth=0): for key, child in value.items(): if type(key) is not str: raise AgentAPIError('JSON object keys must be strings.') - _finite(key, depth+1); _finite(child, depth+1) + _finite(key, depth+1) + _finite(child, depth+1, nul_path=nul_path, _path=_path+(key,)) elif isinstance(value, list): - for child in value: - _finite(child, depth+1) + for index, child in enumerate(value): + _finite(child, depth+1, nul_path=nul_path, _path=_path+(index,)) elif value is not None and type(value) not in (bool, int, float): raise AgentAPIError('Unsupported JSON value.') -def decode(raw): +def decode(raw, *, nul_path=None): def pairs(items): result = {} for key, value in items: @@ -1219,13 +1917,48 @@ def pairs(items): return result try: value = json.loads(raw, object_pairs_hook=pairs, parse_constant=lambda _value: (_ for _ in ()).throw(ValueError())) - _finite(value) + _finite(value, nul_path=nul_path) return value except (ValueError, UnicodeError, RecursionError): raise AgentAPIError('Invalid bounded JSON.') from None +# Compiled validators contain only schema data, never requests or credentials. +# Bound both the exact source bytes retained and entry count; cache keys also +# isolate validators from later mutation of a caller's schema dictionary. +_SCHEMA_CACHE_LIMIT_BYTES = 8 * 1024 * 1024 +_SCHEMA_CACHE_LIMIT_ENTRIES = 1024 +_SCHEMA_CACHE = OrderedDict() +_SCHEMA_CACHE_BYTES = 0 +_SCHEMA_CACHE_LOCK = threading.Lock() + + def _validator(schema): + global _SCHEMA_CACHE_BYTES + raw = encoded(schema) + with _SCHEMA_CACHE_LOCK: + validator = _SCHEMA_CACHE.get(raw) + if validator is not None: + _SCHEMA_CACHE.move_to_end(raw) + return validator + validator = _compile_validator(json.loads(raw)) + if len(raw) > _SCHEMA_CACHE_LIMIT_BYTES: + return validator + with _SCHEMA_CACHE_LOCK: + existing = _SCHEMA_CACHE.get(raw) + if existing is not None: + _SCHEMA_CACHE.move_to_end(raw) + return existing + while _SCHEMA_CACHE and (_SCHEMA_CACHE_BYTES + len(raw) > _SCHEMA_CACHE_LIMIT_BYTES + or len(_SCHEMA_CACHE) >= _SCHEMA_CACHE_LIMIT_ENTRIES): + previous, _ = _SCHEMA_CACHE.popitem(last=False) + _SCHEMA_CACHE_BYTES -= len(previous) + _SCHEMA_CACHE[raw] = validator + _SCHEMA_CACHE_BYTES += len(raw) + return validator + + +def _compile_validator(schema): try: from jsonschema import Draft202012Validator from jsonschema.exceptions import SchemaError @@ -1262,10 +1995,55 @@ def validate_registry(*, checkpoint=None): def validate_output(name, envelope, version=None): - _finite(envelope) op = operation(name, version) + # A captured UTF-8 file is an exact inert byte witness, including NUL. This + # exception never admits NUL in input, metadata, error or other operation. + nul_path = ('result','value','version','text') if (op['operation_id']=='project_files.file_get' + and type(envelope) is dict and type(envelope.get('status')) is int and envelope['status']<400) else None + _finite(envelope, nul_path=nul_path) if next(_validator(op['outputSchema']).iter_errors(envelope), None) is not None: raise AgentAPIError('HTTP result does not match its operation envelope.', 'transport', 502) + if op['operation_id'] in PROJECT_TRANSFER_OPERATIONS and envelope['status']<400: + value=envelope['result'] + if op['operation_id'] in ('project_transfer.status','project_transfer.preview','project_transfer.stop'): + phase,report=value['phase'],value['report'] + actions=['status'] + if phase in ('source_live','source_stopped'):actions+=['preview','report_page'] + if phase=='source_live' and report is not None:actions.append('stop') + if phase=='source_stopped' and report is not None:actions.append('extract') + if (value['source_catalog_id']==value['control_catalog_id'] or value['roots']!=sorted(set(value['roots'])) + or value['available_actions']!=actions + or phase=='source_stopped' and (value['backup'] is None or value['destination'] is None) + or phase=='source_fenced' and report is not None + or report is not None and (report['source']['catalog_id']!=value['source_catalog_id'] + or report['can_extract']!=(report['counts']['blockers']==0) + or phase=='source_live' and report['can_extract'] + or phase=='source_stopped' and report['source']['backup_receipt_sha256']!=value['backup']['receipt_sha256'])): + raise AgentAPIError('Extraction state differs from its source, phase or stopped backup.','transport',502) + if op['operation_id']=='project_transfer.extract': + receipt,selection=value['receipt'],value['selection'] + if (receipt['project_id']==receipt['source']['catalog_id'] or receipt['verification']['checkpoint']!=receipt['checkpoint'] + or selection['selected'] and (selection['error'] is not None or selection['state'] is None + or selection['state']['state']!='open' or selection['state']['project']['project_id']!=receipt['project_id'] + or selection['state']['project']['checkpoint']!=receipt['checkpoint']) + or not selection['selected'] and selection['error'] is None): + raise AgentAPIError('Extraction receipt differs from its independent project or selected state.','transport',502) + if op['operation_id'].startswith('project_files.') and envelope['status'] < 400: + value = envelope['result'] + if 'record' in value: + record, history = value['record'], value['history'] + revisions = [item['revision'] for item in history] + cursor = value['next_history_before_revision'] + if (any(item['id'] != record['id'] or item['revision'] > record['revision'] for item in history) + or revisions != sorted(set(revisions), reverse=True) + or cursor is not None and (not history or cursor != history[-1]['revision'])): + raise AgentAPIError('Project annotation history differs from its record.', 'transport', 502) + if 'items' in value: + identities = [item['id'] for item in value['items']] + cursor = value['next_after_id'] + if (identities != sorted(set(identities)) + or cursor is not None and (not identities or cursor != identities[-1])): + raise AgentAPIError('Project record page identity or continuation differs.', 'transport', 502) if op['operation_id'] == 'runs.artifact_segments' and envelope['status'] < 400: validate_artifact_segments(envelope['result']) if op['operation_id'] in ('runs.phase_timings', 'graphs.preparation_timings') and envelope['status'] < 400: @@ -1411,7 +2189,7 @@ def content_sha(value): def validate_arguments(name, arguments, version=None): op = operation(name, version) _finite(arguments) - if len(encoded(arguments)) > REQUEST_LIMIT: + if len(encoded(arguments, ensure_ascii=not op['operation_id'].startswith('project_files.'))) > REQUEST_LIMIT: raise AgentAPIError('Request exceeds the agent transport bound.', 'too_large', 413) if (op['operation_id'] == 'data.import_sequence' and type(arguments) is dict and type(arguments.get('files')) is dict @@ -1449,7 +2227,8 @@ def project_request(name, arguments, version=None): path = op['path'] for key in segments: path = path.replace('{'+key+'}', quote(arguments[key], safe='')) - query = {key: arguments[key] for key in op['query'] if key in arguments} + query = {key: ('true' if arguments[key] else 'false') if type(arguments[key]) is bool else arguments[key] + for key in op['query'] if key in arguments} if query: path += '?' + urlencode(query) body = {key: value for key, value in arguments.items() if key not in segments and key not in op['query']} @@ -1581,7 +2360,7 @@ def watch_read(): timer.daemon = True timer.start() try: - raw = encoded(body) if body is not None else None + raw = encoded(body, ensure_ascii=not (selection is not None and selection[0].startswith('project_files.'))) if body is not None else None if raw is not None and len(raw) > REQUEST_LIMIT: raise AgentAPIError('Request exceeds the agent transport bound.', 'too_large', 413) headers = {'Authorization': 'Bearer '+self._token, 'Accept': 'application/json', 'Accept-Encoding': 'identity', 'Connection': 'close'} @@ -1611,7 +2390,8 @@ def watch_read(): or response.getheader('Content-Type', '').split(';')[0].strip() != 'application/json'): raise AgentAPIError('HTTP response framing or content type is unsupported.', 'transport', 502) length = int(lengths[0]) - if length > (CAPABILITIES_LIMIT if path == '/api/agent/capabilities' else RESPONSE_LIMIT): + receive_limit=CAPABILITIES_LIMIT if path=='/api/agent/capabilities' or selection is not None and selection[0] in PROJECT_TRANSFER_OPERATIONS else RESPONSE_LIMIT + if length > receive_limit: raise AgentAPIError('HTTP response exceeds the agent receive bound.', 'response_too_large', 413) data = bytearray() while len(data) < length: @@ -1627,7 +2407,9 @@ def watch_read(): data.extend(chunk) if time.monotonic() > deadline: raise TimeoutError() - value = decode(data) + nul_path = ('value','version','text') if (selection is not None + and selection[0]=='project_files.file_get' and response.status<400) else None + value = decode(data, nul_path=nul_path) self._check_read_budget() if response.status >= 400: if not isinstance(value, dict) or not isinstance(value.get('error'), str): @@ -1640,7 +2422,20 @@ def watch_read(): report = value['preflight_report'] if has_report else None if has_report and self._token in json.dumps(report, ensure_ascii=False): raise AgentAPIError('Preflight report contains credential material.', 'transport', 502) + receipt = None + if selection is not None and selection[0].startswith('project_files.') and 'receipt' in value: + receipt = value['receipt'] + if (next(_validator(PROJECT_FILES_RECEIPT).iter_errors(receipt), None) is not None + or self._token in json.dumps(receipt, ensure_ascii=False)): + raise AgentAPIError('Malformed project outcome receipt.', 'transport', 502) + if selection is not None and selection[0] in PROJECT_TRANSFER_OPERATIONS and 'receipt' in value: + receipt=value['receipt'] + if (next(_validator(_PROJECT_TRANSFER_PARTIAL).iter_errors(receipt),None) is not None + or self._token in json.dumps(receipt,ensure_ascii=False)): + raise AgentAPIError('Malformed extraction outcome receipt.','transport',502) value = {'code': code, 'error': value['error'].replace(self._token, '[redacted]')[:1000]} + if receipt is not None: + value['receipt'] = copy.deepcopy(receipt) if has_report: value['preflight_report'] = report return {'status': response.status, 'result': value} @@ -1746,6 +2541,234 @@ def call(self, name, arguments, *, observer=None, observation=None): envelope = self._request(method, path, body, selection=selection) result = validate_output(name, envelope, version) self._check_read_budget() + if op['operation_id'] in PROJECT_TRANSFER_OPERATIONS: + value=result['result'];action=op['operation_id'].split('.')[1] + if result['status']>=400: + if value.get('receipt') is not None and value['receipt']['request_key']!=arguments.get('request_key'): + raise AgentAPIError('Extraction outcome belongs to a different request.','transport',502) + elif action in ('status','preview','stop'): + if action=='stop': + if (value['source_catalog_id']!=self.catalog_id or value['phase']!='source_stopped' + or value['binding']==arguments['binding'] or value['report'] is not None + or value['backup']['root']!=arguments['backup_root'] or value['backup']['directory']!=arguments['backup_directory'] + or value['destination']!={'root':arguments['destination_root'],'directory':arguments['directory']}): + raise AgentAPIError('Stopped-source result differs from the explicit backup or destination.','transport',502) + else: + current=value['source_catalog_id'] if value['phase']=='source_live' else value['control_catalog_id'] + if current!=self.catalog_id or action=='preview' and (value['binding']!=arguments['binding'] or value['report'] is None): + raise AgentAPIError('Extraction state belongs to another current host or process binding.','transport',502) + elif action=='report_page': + offset,limit=arguments.get('offset',0),arguments.get('limit',40) + if (any(value[field]!=arguments[field] for field in ('binding','report_sha256','section')) + or value['offset']!=offset or value['limit']!=limit + or len(value['items'])!=min(limit,max(0,value['total']-offset))): + raise AgentAPIError('Extraction report page differs from its reviewed report or continuation.','transport',502) + elif (value['binding']!=arguments['binding'] or value['receipt']['request_key']!=arguments['request_key'] + or value['receipt']['report_sha256']!=arguments['report_sha256']): + raise AgentAPIError('Extraction receipt belongs to another controller report or request.','transport',502) + if op['operation_id']=='project_files.save_as' and result['status']<400: + value=result['result'];fork=value['fork'];state=value['state'] + if (state['state']!='open' or state['project']['project_id']!=fork['project_id'] + or state['project']['checkpoint']!=fork['checkpoint'] or state['project']['name']!=arguments['name'] or state['binding']==arguments['binding'] + or fork['project_id']==fork['source_project_id'] + or fork['source_checkpoint']!=arguments['expected_checkpoint'] + or any(fork[field]!=arguments[field] for field in ('root','directory','request_key'))): + raise AgentAPIError('Save As result differs from its source, destination or selected state.','transport',502) + elif op['operation_id'] in PROJECT_FILES_M2 | PROJECT_FILES_M3 | PROJECT_FILES_M4: + value = result['result'] + if result['status'] >= 400: + receipt = value.get('receipt') + if receipt is not None and receipt['request_key'] != arguments.get('request_key'): + raise AgentAPIError('Project receipt belongs to a different request.', 'transport', 502) + else: + if value['binding'] != arguments['binding']: + raise AgentAPIError('Project response belongs to a different session binding.', 'transport', 502) + if not op['side_effects'] and value['checkpoint'] != arguments['checkpoint']: + raise AgentAPIError('Project read response belongs to a different checkpoint.', 'transport', 502) + page = value['value'] + action=op['operation_id'].removeprefix('project_files.') + if action in ('file_get','file_adopt','file_adopt_deletion'): + kind,path=page['kind'],page['path'] + expected_id=hashlib.sha256((value['project_id']+'\0'+kind+'\0'+path).encode()).hexdigest() + if page['id']!=expected_id or page['project_id']!=value['project_id']: + raise AgentAPIError('Working file belongs to a different project or path.','transport',502) + if action=='file_get': + version=page['version'];deleted=version.get('deleted') is True + raw=None if deleted else version['text'].encode('utf-8') + if (deleted and kind!='note' or page['id']!=arguments['file_id'] or arguments.get('revision',version['revision'])!=version['revision'] + or not deleted and (len(raw)!=version['bytes'] or len(raw)>1048576 or hashlib.sha256(raw).hexdigest()!=version['sha256'])): + raise AgentAPIError('Captured working file differs from its exact revision or bytes.','transport',502) + elif action=='file_adopt_deletion': + if (kind!='note' or path!=arguments['path'] or page['revision']!=arguments['expected_revision']+1): + raise AgentAPIError('Note deletion differs from the reviewed path or revision.','transport',502) + elif (kind!=arguments['kind'] or path!=arguments['path'] or page['sha256']!=arguments['expected_sha256'] + or page['revision']!=arguments['expected_revision']+int(page['changed'])): + raise AgentAPIError('Working-file adoption differs from the reviewed current bytes.','transport',502) + if action=='file_history' and page['id']!=arguments['file_id']: + raise AgentAPIError('Working-file history belongs to another file.','transport',502) + if action=='files_inspect': + for item in page['items']: + identity=hashlib.sha256((value['project_id']+'\0'+item['kind']+'\0'+item['path']).encode()).hexdigest() + if (item['id']!=identity or item['state']=='unchanged' and (item['observed_sha256']!=item['expected_sha256'] + or item['observed_bytes']!=item['expected_bytes'] or item['reason'] is not None) + or item['expected_sha256'] is None and (item['kind']!='note' or item['expected_bytes']!=0 or item['state'] in ('unchanged','missing')) + or item['state']=='deleted' and (item['expected_sha256'] is not None or item['observed_sha256'] is not None or item['observed_bytes'] is not None or item['reason'] is not None)): + raise AgentAPIError('Working-file observation differs from its selected identity or declared bytes.','transport',502) + if action=='material_intents': + for item in page['items']: + if (item['catalog_id']!=value['project_id'] or item['id']!=value['project_id']+'.'+item['definition_id'] + or item['fork_origin']['family']!=item['family']): + raise AgentAPIError('Material intent summary belongs to another definition.','transport',502) + if action=='material_intent_capabilities' and any(not item['id'].startswith(value['project_id']+'.') + or not re.fullmatch('[a-f0-9]{32}',item['id'][33:]) for item in page['items']): + raise AgentAPIError('Material readiness belongs to another selected project.','transport',502) + if action=='material_intent' and (page['family']!=arguments['family'] or page['current']['id']!=arguments['definition_id'] + or page['current']['definition_family']!=arguments['family'] or page['fork_origin']['family']!=arguments['family'] + or page['current']['catalog_id']!=value['project_id'] or page['id']!=value['project_id']+'.'+arguments['definition_id']): + raise AgentAPIError('Material intent belongs to a different definition.','transport',502) + if action=='fork_info': + if page['project_id']!=value['project_id']: + raise AgentAPIError('Fork mapping belongs to a different selected project.','transport',502) + mapping=page['mapping'];offset=arguments.get('offset',0);limit=arguments.get('limit',40) + if (mapping['offset']!=offset or mapping['limit']!=limit + or len(mapping['items'])!=min(limit,max(0,mapping['total']-offset))): + raise AgentAPIError('Fork mapping differs from its requested continuation.','transport',502) + if isinstance(page, dict) and {'items', 'offset', 'limit', 'total'} <= page.keys(): + requested = arguments.get('limit', 1 if op['operation_id'] in ('project_files.data_history', 'project_files.data_response_history', 'project_files.data_preference_history') else 40) + if (page['offset'] != arguments.get('offset', 0) or page['limit'] != requested + or len(page['items']) > requested or (len(page['items']) > 0 and page['total'] < page['offset'] + len(page['items'])) + or len(page['items']) != min(requested, max(0, page['total'] - page['offset']))): + raise AgentAPIError('Project page differs from its requested continuation.', 'transport', 502) + if 'origin_catalog_id' in arguments and isinstance(page, dict) and 'origin_catalog_id' in page: + if page['origin_catalog_id'] != arguments['origin_catalog_id']: + raise AgentAPIError('Historical response belongs to a different origin.', 'transport', 502) + action = op['operation_id'].split('.')[1] + if action == 'execution_health': + requested = arguments.get('limit', 40) + if (page['offset'] != arguments.get('offset', 0) or page['limit'] != requested + or page['active_run_ids'] != sorted(set(page['active_run_ids'])) + or len(page['active_run_ids']) != min(requested, max(0, page['active_count'] - page['offset']))): + raise AgentAPIError('Current custody page differs from its requested continuation.', 'transport', 502) + if version>=2: + phases=page['native_phases'];identities=[item['phase_id'] for item in phases['items']] + if (phases['offset']!=arguments.get('offset',0) or phases['limit']!=requested + or len(phases['items'])!=min(requested,max(0,phases['total']-phases['offset'])) + or identities!=sorted(set(identities)) + or any(item['origin_catalog_id']!=value['project_id'] for item in phases['items'])): + raise AgentAPIError('Current native phase page differs from its selected project or continuation.','transport',502) + if action in ('execution_study_prepare_package','execution_study_prepare_verifier','execution_study_reconcile_preparation'): + if (page['trial']['study_id']!=arguments['study_id'] or page['trial']['material']['catalog_id']!=value['project_id'] + or page['model']['catalog_id']!=value['project_id'] + or action!='execution_study_reconcile_preparation' and (page['candidate_id']!=arguments['candidate_id'] + or page['runtime_snapshot_sha256']!=arguments['runtime_snapshot_sha256']) + or action=='execution_study_prepare_verifier' and any(page[field]!=arguments[field] for field in ('package_run_id','package_sha256'))): + raise AgentAPIError('Candidate preparation belongs to a different Study, Candidate or reviewed runtime.','transport',502) + if action=='execution_study_abandon_preparation' and (page['origin_catalog_id']!=value['project_id'] + or page['study_id']!=arguments['study_id'] or page['preparation_key']!=arguments['preparation_key'] + or page['reason']!=arguments['reason'] or page['candidate']['catalog_id']!=value['project_id']): + raise AgentAPIError('Preparation abandonment differs from its current exact intent and reason.','transport',502) + if action=='runtime_inspect': + inventory=page['files'];offset=arguments.get('offset',0);limit=arguments.get('limit',40) + if (page['source']!=arguments['source'] or inventory['offset']!=offset or inventory['limit']!=limit + or inventory['total']!=page['file_count'] + or len(inventory['items'])!=min(limit,max(0,inventory['total']-offset)) + or [item['path'] for item in inventory['items']]!=sorted(set(item['path'] for item in inventory['items'])) + or [item['name'] for item in page['packages']]!=sorted(set(item['name'] for item in page['packages']))): + raise AgentAPIError('Runtime inventory differs from its configured source or continuation.','transport',502) + if action=='runtime_capture': + ref=page['runtime_ref'] + if (page['project_id']!=value['project_id'] or page['manifest_sha256']!=arguments['expected_inventory_sha256'] + or page['request_key']!=arguments['request_key'] or ref['catalog_id']!=value['project_id'] or ref['id']!=ref['sha256']): + raise AgentAPIError('Runtime capture differs from its exact reviewed inventory or request.','transport',502) + if action=='runtime_abandon' and (page['project_id']!=value['project_id'] + or page['capture_key']!=arguments['capture_key'] or page['reason']!=arguments['reason']): + raise AgentAPIError('Runtime abandonment differs from its current exact capture and reason.','transport',502) + if action=='runtime_status': + intent,complete=page['intent'],page['result'] + abandonment=page.get('abandonment') + expected='abandoned' if abandonment is not None else 'captured' if complete is not None else 'intent_recorded' if intent is not None else 'absent' + if (page['state']!=expected or complete is not None and intent is None + or abandonment is not None and (intent is None or complete is not None + or abandonment['project_id']!=value['project_id'] or abandonment['capture_key']!=arguments['capture_key'] + or abandonment['manifest_sha256']!=intent['manifest_sha256']) + or any(item['project_id']!=value['project_id'] or item['request_key']!=arguments['capture_key'] + for item in (intent,complete) if item is not None) + or complete is not None and (any(intent[field]!=complete[field] for field in + ('project_id','manifest_sha256','file_count','total_bytes','request_key')) + or complete['runtime_ref']['catalog_id']!=value['project_id'] + or complete['runtime_ref']['id']!=complete['runtime_ref']['sha256'])): + raise AgentAPIError('Runtime status differs from its retained capture request.','transport',502) + if action=='native_preflight' and not _native_validation_result_ok(page,arguments['revision']): + raise AgentAPIError('Native validation differs from the saved graph revision or result.','transport',502) + if action=='native_reconcile' and (page['origin_catalog_id']!=value['project_id'] or page['phase_id']!=arguments['phase_id']): + raise AgentAPIError('Native phase observation differs from the selected current phase.','transport',502) + if action in ('native_submit','native_retry') and (page['origin_catalog_id']!=value['project_id'] + or action=='native_retry' and page['run_id']==arguments['run_id']): + raise AgentAPIError('Native admission differs from the selected project.','transport',502) + if action in ('execution_approve_external', 'execution_study_approve'): + grant = page['grant'] + expected = arguments['expected_digest'] if action == 'execution_approve_external' else arguments['expected_material_sha256'] + if (grant['catalog_id'] != value['project_id'] or grant['material_sha256'] != expected + or grant['consumer_actor_id'] != arguments['consumer_actor_id'] + or grant['revoked_at'] is not None or grant['expires_at'] <= grant['created_at']): + raise AgentAPIError('Current local approval differs from its reviewed material or actor.', 'transport', 502) + if action in ('execution_submit_external', 'execution_cancel', 'execution_reconcile_current', + 'execution_study_admit', 'execution_study_stop') or action.startswith('execution_producer_') or action in ('execution_generation_resume', 'execution_grounded_review'): + if 'origin_catalog_id' in page and page['origin_catalog_id'] != value['project_id']: + raise AgentAPIError('Current execution acknowledgement belongs to another project.', 'transport', 502) + if action == 'execution_cancel' and page['run_id'] != arguments['run_id']: + raise AgentAPIError('Cancellation acknowledgement belongs to another Run.', 'transport', 502) + if action == 'execution_study_continue' and page['id'] != arguments['study_id']: + raise AgentAPIError('Study continuation belongs to another Study.', 'transport', 502) + if action in ('execution_study_admit', 'execution_study_stop') and page['study_id'] != arguments['study_id']: + raise AgentAPIError('Study acknowledgement belongs to another Study.', 'transport', 502) + if action == 'execution_study_admit' and (page['trial_id'] != arguments['trial_id'] + or page['reservation']['trial_id'] != page['trial_id'] or page['reservation']['run_id'] != page['run_id']): + raise AgentAPIError('Study admission differs from its selected Trial reservation.', 'transport', 502) + if action == 'execution_study_stop' and (page['receipt']['study_id'] != page['study_id'] + or len(page['receipt']['run_ids']) != len(set(page['receipt']['run_ids']))): + raise AgentAPIError('Study epoch closure receipt is inconsistent.', 'transport', 502) + producer_actions = {'execution_producer_start': 'start', 'execution_generation_resume': 'resume', + 'execution_grounded_review': 'review', 'execution_producer_decide': 'decide', 'execution_producer_cancel': 'cancel'} + if action in producer_actions: + family = arguments.get('family', 'generation_history' if action == 'execution_generation_resume' else 'grounded_history') + if (page['family'] != family or page['action'] != producer_actions[action] + or 'producer_id' in arguments and page['producer_id'] != arguments['producer_id'] + or action == 'execution_producer_start' and 'request_id' in arguments['document'] and page['producer_id'] != arguments['document']['request_id']): + raise AgentAPIError('Producer acknowledgement differs from its exact current action.', 'transport', 502) + if op['operation_id'] == 'project_files.run_document_segment': + try: + segment = base64.b64decode(page['base64'], validate=True) + except (ValueError, binascii.Error): + raise AgentAPIError('Invalid retained Run document segment bytes.', 'transport', 502) from None + end = arguments['offset'] + len(segment) + if (page['origin_catalog_id'] != arguments['origin_catalog_id'] or page['run_id'] != arguments['run_id'] + or page['sha256'] != arguments['expected_sha256'] or page['offset'] != arguments['offset'] + or page['limit'] != arguments['limit'] or page['offset'] > page['bytes'] + or len(segment) != min(page['limit'], page['bytes'] - page['offset']) + or page['next_offset'] != (end if end < page['bytes'] else None)): + raise AgentAPIError('Retained Run document segment differs from its exact selection.', 'transport', 502) + target_key = {'data_get': 'record_id', 'model_get': 'model_id', 'graph_get': 'graph_id', + 'recipe_get': 'recipe_id', 'trial': 'trial_id'}.get(op['operation_id'].split('.')[1]) + if target_key and page.get('id') != arguments[target_key]: + raise AgentAPIError('Project response belongs to a different record.', 'transport', 502) + if op['operation_id'].startswith('project_files.') and result['status'] < 400: + value = result['result']; action = op['operation_id'].split('.')[1] + if action in ('query', 'get', 'import_text', 'annotate', 'save') and value['binding'] != arguments['binding']: + raise AgentAPIError('Project response belongs to a different session binding.', 'transport', 502) + if action in ('query', 'get') and value['checkpoint'] != arguments['checkpoint']: + raise AgentAPIError('Project read response belongs to a different checkpoint.', 'transport', 502) + if action == 'query' and (len(value['items']) > arguments.get('limit', 40) + or any(item['id'] <= arguments.get('after_id', '') for item in value['items'])): + raise AgentAPIError('Project record page differs from its requested continuation.', 'transport', 502) + if action in ('get', 'import_text', 'annotate'): + if (len(value['history']) > arguments.get('history_limit', 1) + or 'before_revision' in arguments and any(item['revision'] >= arguments['before_revision'] for item in value['history'])): + raise AgentAPIError('Project history page differs from its requested continuation.', 'transport', 502) + if action in ('get', 'annotate') and value['record']['id'] != arguments['record_id']: + raise AgentAPIError('Project response belongs to a different record.', 'transport', 502) + if action == 'save' and (value['state'] != 'open' or value['project']['checkpoint'] != arguments['expected_checkpoint']): + raise AgentAPIError('Project Save response differs from its selected checkpoint.', 'transport', 502) if op['operation_id'] in ('runs.coordinator_health', 'runs.recover_coordinator') and result['status'] < 400: value = result['result']; request = value['recovery_request'] if value['catalog_id'] != self.catalog_id: @@ -1857,6 +2880,61 @@ def call(self, name, arguments, *, observer=None, observation=None): return result +class ProjectFilesOwnerClient(AgentClient): + """Explicit owner file-project access; no unrelated owner operations.""" + OPERATIONS = frozenset('project_files.' + action for action in + ('status', 'new', 'open', 'save', 'close', 'query', 'get', 'import_text', 'annotate')) | PROJECT_FILES_M2 | PROJECT_FILES_M3 | PROJECT_FILES_M4 + + def bootstrap(self): + result = self._bootstrap('owner') + self._selected = {name: version for name, version in self._selected.items() if name in self.OPERATIONS} + result['operations'] = sorted(self._selected) + return result + + def call(self, name, arguments, **kwargs): + if name not in self.OPERATIONS: + raise AgentAPIError('Owner project client only supports explicit file-project operations.', 'operation_unsupported', 409) + return super().call(name, arguments, **kwargs) + + +class ProjectTransferOwnerClient(AgentClient): + """Five current-owner extraction calls, with explicit authority handoff. + + After a dispatched Stop, discard this client. Construct another instance + with the independently obtained current host credential and catalog ID. + """ + OPERATIONS=PROJECT_TRANSFER_OPERATIONS + + def __init__(self,*args,**kwargs): + super().__init__(*args,**kwargs) + self._handoff_required=False + + def bootstrap(self): + if self._handoff_required: + raise AgentAPIError('Stop requires an independently authenticated current-host client.','authentication_handoff',409) + result=self._bootstrap('owner') + self._selected={name:version for name,version in self._selected.items() if name in self.OPERATIONS} + result['operations']=sorted(self._selected) + return result + + def call(self,name,arguments,**kwargs): + if name not in self.OPERATIONS: + raise AgentAPIError('Transfer owner client only supports five explicit extraction operations.','operation_unsupported',409) + if self._handoff_required: + raise AgentAPIError('Discard the source client and authenticate the current host independently.','authentication_handoff',409) + try: + result=super().call(name,arguments,**kwargs) + except AgentAPIError as error: + if name=='project_transfer.stop' and (error.status>=500 or error.code in ('transport','unknown','outcome_unknown')): + self._handoff_required=True + raise + if name=='project_transfer.stop': + # Even a failed stop can rotate binding or close source authority. + # No response causes an automatic credential/catalog substitution. + self._handoff_required=True + return result + + class PublicationOwnerClient(AgentClient): """Explicit owner publication actions only; ordinary AgentClient stays agent-only.""" OPERATIONS = frozenset(('runs.publication_status', 'runs.recover_publication', 'runs.cancel_publication', diff --git a/app.py b/app.py index d4e39bfa..c7b7d25e 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,6 @@ """Tuldok: local image capture and corner-label dataset workspace.""" import argparse +from contextlib import contextmanager import psycopg import ai import ai_codex @@ -45,8 +46,11 @@ import re import shutil from metadata import Metadata, StorageError, CommitOutcomeUnknown, CatalogBusy +from project_store import ProjectStoreError +from image_sources import ImageSources, Conflict, now, metadata, validate_annotation, CORNERS, VISIBILITY, SPLITS import tempfile import threading +import time import uuid import zipfile from datetime import datetime, timezone @@ -57,94 +61,20 @@ from PIL import Image, ImageOps ROOT = Path(__file__).resolve().parent -CORNERS = ('top_left', 'top_right', 'bottom_right', 'bottom_left') -VISIBILITY = ('visible', 'occluded', 'out_of_frame') -SPLITS = ('unassigned', 'train', 'validation', 'test') MAX_BODY = 40 * 1024 * 1024 -class Conflict(ValueError): - pass - - -def now(): - return datetime.now(timezone.utc).isoformat() - - -def metadata(body): - result = {} - for key in ('book_id', 'session_id'): - value = body.get(key, '') - if not isinstance(value, str) or len(value) > 120: - raise ValueError(key + ' must be text, up to 120 characters.') - result[key] = value.strip() - if not result['session_id']: - raise ValueError('Enter a recording session ID.') - result['split'] = body.get('split', 'unassigned') - if result['split'] not in SPLITS: - raise ValueError('Invalid dataset split.') - return result - - -def validate_annotation(body): - if not isinstance(body, dict): - raise ValueError('Expected an annotation object.') - suggested_by = body.get('suggested_by') - extra = {} - if suggested_by is not None: - if not isinstance(suggested_by, dict) or suggested_by.get('provider') not in ai.PROVIDERS: - raise ValueError('Invalid AI suggestion provenance.') - model = ai.model_id(suggested_by.get('model')) - timestamp = suggested_by.get('suggested_at') - if not isinstance(timestamp, str) or len(timestamp) > 60: - raise ValueError('Invalid AI suggestion timestamp.') - try: - datetime.fromisoformat(timestamp) - except ValueError: - raise ValueError('Invalid AI suggestion timestamp.') from None - extra['suggested_by'] = dict(provider=suggested_by['provider'], model=model, suggested_at=timestamp) - reference = body.get('corner_reference', 'book') - if reference not in ('book', 'image'): - raise ValueError('Choose book or image orientation for corner labels.') - present, suitable = body.get('book_present'), body.get('crop_suitable') - if type(present) is not bool or type(suitable) is not bool: - raise ValueError('Choose book presence and crop suitability.') - if not present: - if suitable: - raise ValueError('A scene without a book cannot be suitable for cropping.') - return dict(book_present=False, crop_suitable=False, corners=[], corner_reference=reference, **extra) - corners = body.get('corners') - if not isinstance(corners, list) or len(corners) != 4: - raise ValueError('Label all four corners.') - result = [] - for name, corner in zip(CORNERS, corners): - if not isinstance(corner, dict) or corner.get('name') != name or corner.get('visibility') not in VISIBILITY: - raise ValueError('Corners must be ordered top-left, top-right, bottom-right, bottom-left.') - visible = corner['visibility'] == 'visible' - x, y = corner.get('x'), corner.get('y') - if visible: - if any(type(v) not in (float, int) or not math.isfinite(v) or not 0 <= v <= 1 for v in (x, y)): - raise ValueError('Place every visible corner inside the image.') - elif x is not None or y is not None: - raise ValueError('Hidden or off-screen corners must not have guessed coordinates.') - result.append(dict(name=name, visibility=corner['visibility'], x=x, y=y)) - if suitable and any(c['visibility'] != 'visible' for c in result): - raise ValueError('A complete crop requires four visible corners.') - if all(c['visibility'] == 'visible' for c in result): - points = [(c['x'], c['y']) for c in result] - crosses = [] - for i in range(4): - a, b, c = points[i], points[(i+1) % 4], points[(i+2) % 4] - crosses.append((b[0]-a[0])*(c[1]-b[1])-(b[1]-a[1])*(c[0]-b[0])) - if any(c <= 0.00001 for c in crosses): - raise ValueError('Corners must form a clockwise, non-crossing outline.') - if reference == 'image' and (points[0][1] + points[1][1] >= points[2][1] + points[3][1] or points[0][0] + points[3][0] >= points[1][0] + points[2][0]): - raise ValueError('Start at the image top-left and label clockwise.') - return dict(book_present=present, crop_suitable=suitable, corners=result, corner_reference=reference, **extra) - - -class Dataset: +class Dataset(ImageSources): def __init__(self, path, *, dsn=None, project_roots=None): + self._close_lock = threading.RLock() + self._closed = False + self._extraction_condition = threading.Condition() + self._extraction_requests = {} + self._extraction_stopping = False + self._extraction_binding = uuid.uuid4().hex + self._extraction_guard = None + self._extraction_closer = None + self._extraction_close_error = None self.path = Path(path).resolve() self.path.mkdir(parents=True, exist_ok=True) (self.path / 'images').mkdir(exist_ok=True) @@ -180,197 +110,88 @@ def __init__(self, path, *, dsn=None, project_roots=None): raise def close(self): - with self.access._reconcile(): - self.runs.close() - self.text_classification_proposals.close() - self.caption_proposals.close() - self.grounded.close() - self.generation_jobs.close() - self.projects.close() - self.db.close() - - @authorized('inspect', 'dataset.rows') - def rows(self): - with self.lock: - rows = [dict(r) for r in self.db.execute('SELECT * FROM samples ORDER BY created_at, id')] - provenance = {} - for record in self.db.execute('SELECT data FROM generation_entries'): - entry = json.loads(record[0]) - if entry.get('sample_id'): - provenance[entry['sample_id']] = entry - for row in rows: - if row['id'] in provenance: - entry = provenance[row['id']] - row['generation'] = {key: entry[key] for key in ('job_id', 'ordinal', 'prompt', 'metadata', 'model', 'width', 'height', 'requested_seed') if key in entry} - row['annotation'] = json.loads(row['annotation']) if row['annotation'] else None - if row['annotation'] is not None: - row['annotation'].setdefault('corner_reference', 'image') - return rows - - @authorized('inspect', 'dataset.sample') - def sample(self, sample_id): - return next((row for row in self.rows() if row['id'] == sample_id), None) + with self._close_lock: + if self._closed: + return + with self.access._reconcile(): + self.runs.close() + self.text_classification_proposals.close() + self.caption_proposals.close() + self.grounded.close() + self.generation_jobs.close() + self.projects.close() + self.db.close() + self._closed = True - def _resolve_split(self, meta, exclude=''): - key = 'book_id' if meta['book_id'] else 'session_id' - where = key + '=%s' + (" AND book_id=''" if key == 'session_id' else '') - args = (meta[key], exclude) - assigned = {r[0] for r in self.db.execute( - 'SELECT DISTINCT split FROM samples WHERE ' + where + " AND id<>%s AND split<>'unassigned'", args)} - desired = meta['split'] - if assigned and desired != 'unassigned' and desired not in assigned: - raise Conflict('This book/session already belongs to ' + next(iter(assigned)) + '. Keep the same split.') - chosen = next(iter(assigned), desired) - # Existing unassigned members move together. Each change invalidates stale editors. - if chosen != 'unassigned': - self.db.execute("UPDATE samples SET split=%s, revision=revision+1 WHERE " + where + - " AND id<>%s AND split='unassigned'", (chosen, *args)) - return chosen + def close_for_project_extraction(self, *, deadline): + """Bound this explicit stop without treating a timeout as cessation.""" + guard = self._extraction_guard + if guard is None: + raise workbench.WorkbenchError('A drained original controller is required.', 'source_busy', 409) + guard.require(self) + if type(deadline) not in (int, float) or not math.isfinite(deadline): + raise workbench.WorkbenchError('An exact stop deadline is required.') + with self._extraction_condition: + if self._extraction_closer is None: + def close_owned(): + try: + self.close() + except BaseException as error: + self._extraction_close_error = error + self._extraction_closer = threading.Thread( + target=close_owned, name='tuldok-legacy-extraction-close', daemon=True) + try: + self._extraction_closer.start() + except BaseException as error: + self._extraction_close_error = error + raise workbench.WorkbenchError('Owned legacy closure could not start; source remains fenced.', 'unknown', 503) from error + closer = self._extraction_closer + closer.join(max(0, deadline - time.monotonic())) + if closer.is_alive(): + raise workbench.WorkbenchError('Owned legacy workers have not drained; source remains fenced.', 'source_busy', 409) + if self._extraction_close_error is not None: + raise workbench.WorkbenchError('Owned legacy closure failed; source remains fenced.', 'unknown', 503) from self._extraction_close_error + if not self._closed: + raise workbench.WorkbenchError('Owned legacy closure is unconfirmed; source remains fenced.', 'unknown', 503) + guard.require(self) - def add(self, body, generation=None, *, enrollment=None): - if generation is None: - self.access.require('edit', 'dataset.add') - else: - entry, job, generated_metadata = generation - self.access.require_job('synthetic', job['id'], job.get('admission_id'), - self.generation_jobs._request_digest(job)) - self.access.require('edit', 'dataset.add_generated', job['id']) - meta = metadata(body) - try: - raw = base64.b64decode(body.get('image', ''), validate=True) - except (ValueError, TypeError) as error: - raise ValueError('Invalid image data.') from error - if not raw or len(raw) > 25 * 1024 * 1024: - raise ValueError('Choose an image smaller than 25 MB.') + @contextmanager + def _project_request(self): + """Fence new legacy handlers before an explicit extraction stop.""" + identity = threading.get_ident() + with self._extraction_condition: + if self._extraction_stopping: + raise workbench.WorkbenchError('This catalog is stopped for project extraction.', 'source_stopping', 409) + self._extraction_requests[identity] = self._extraction_requests.get(identity, 0) + 1 try: - with Image.open(io.BytesIO(raw)) as source: - if source.width * source.height > 40_000_000: - raise ValueError('Images must be at most 40 megapixels.') - source.load() - image = ImageOps.exif_transpose(source).convert('RGB') - except (OSError, Image.DecompressionBombError) as error: - raise ValueError('Unsupported or damaged image. Use JPEG, PNG, or WebP.') from error - digest = hashlib.sha256(raw).hexdigest() - name = body.get('filename', 'camera.jpg') - if not isinstance(name, str): - raise ValueError('Invalid filename.') - name = name.replace('\\', '/').rsplit('/', 1)[-1][:200] - sample_id, timestamp = uuid.uuid4().hex, now() - with self.lock: - if self.db.execute('SELECT id FROM samples WHERE sha256=%s', (digest,)).fetchone(): - raise Conflict('This exact image is already in the dataset.') - folder = self.path / 'images' / sample_id - folder.mkdir() - try: - (folder / 'source').write_bytes(raw) - image.save(folder / 'image.png', 'PNG') - thumbnail = image.copy() - thumbnail.thumbnail((160, 120)) - thumbnail.save(folder / 'thumb.jpg', 'JPEG', quality=85) - with self.db.transaction(): - self.db.on_rollback(lambda: shutil.rmtree(folder, ignore_errors=True)) - split = self._resolve_split(meta) - self.db.execute('INSERT INTO samples VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)', - (sample_id, digest, name, image.width, image.height, meta['book_id'], - meta['session_id'], split, timestamp, timestamp, 1, None)) - if generation is not None: - self.generation_jobs.record_output(sample_id, *generation) - if enrollment is not None: - # Imported Workbench source metadata shares acquisition's - # commit and original-file cleanup on admission failure. - self.workbench._enroll_import(sample_id, *enrollment) - self.access.audit('dataset.add_generated' if generation else 'dataset.add', - job['id'] if generation else sample_id, - {'sample_id': sample_id, 'revision': 1, 'source_sha256': digest}) - if generation is not None: - # Return only this admitted publication, without catalog-wide reads. - generated = dict(self.db.execute('SELECT * FROM samples WHERE id=%s', (sample_id,)).fetchone()) - generated['generation'] = {key: entry[key] for key in - ('job_id', 'ordinal', 'prompt', 'metadata', 'model', 'width', 'height', 'requested_seed') if key in entry} - except CommitOutcomeUnknown: - raise - except Exception: - shutil.rmtree(folder, ignore_errors=True) - raise - return generated if generation is not None else self.sample(sample_id) - - def _recover_deletions(self): - staging = self.path / '.deleting-images' - if not staging.exists(): - return - for folder in staging.iterdir(): - if not folder.is_dir() or len(folder.name) != 32 or any(c not in '0123456789abcdef' for c in folder.name): - continue - if self.db.execute('SELECT 1 FROM samples WHERE id=%s', (folder.name,)).fetchone(): - folder.rename(self.path / 'images' / folder.name) - else: - shutil.rmtree(folder) + yield + finally: + with self._extraction_condition: + self._extraction_requests[identity] -= 1 + if not self._extraction_requests[identity]: + del self._extraction_requests[identity] + self._extraction_condition.notify_all() - @authorized('edit', 'dataset.delete') - def delete(self, sample_id, body): - with self.lock: - row = self.db.execute('SELECT revision FROM samples WHERE id=%s', (sample_id,)).fetchone() - if not row: - raise ValueError('Image not found. It may already have been deleted.') - if type(body.get('revision')) is not int or body['revision'] != row['revision']: - raise Conflict('This image changed in another tab. Reload it before deleting.') - # Enrol before moving bytes; retain lineage in the deletion transaction. - with self.db.transaction(): - self.workbench._sync_images() - folder = self.path / 'images' / sample_id - staging = self.path / '.deleting-images' - staging.mkdir(exist_ok=True) - staged = staging / sample_id - # Rename first, then commit the row deletion. Startup restores staged - # files if a crash occurred before commit, or removes them afterwards. - folder.rename(staged) - cleanup_warning = [] - def restore_files(): - if staged.exists(): - staged.rename(folder) - def remove_files(): - try: - shutil.rmtree(staged) - except OSError: - cleanup_warning.append('Image removed from the dataset. Remaining file cleanup will be retried when Tuldok restarts.') - try: - with self.db.transaction(): - self.db.on_rollback(restore_files) - self.db.on_commit(remove_files) - for record in self.db.execute('SELECT data FROM generation_entries').fetchall(): - entry = json.loads(record[0]) - if entry.get('sample_id') == sample_id: - entry.update(status='deleted', sample_id=None, deleted_at=now()) - self.generation_jobs._entry(entry) - self.workbench.preserve_deleted_source(sample_id) - self.db.execute('DELETE FROM samples WHERE id=%s', (sample_id,)) - self.access.audit('dataset.delete', sample_id) - except CommitOutcomeUnknown: - raise - except Exception: - restore_files() - raise - return dict(self.generation_jobs.snapshot(), samples=self.rows(), deleted=sample_id, - warning=cleanup_warning[0] if cleanup_warning else '') + def stop_for_project_extraction(self, backup_parent_fd, backup_directory, external_roots=None): + """Explicit owner action; ordinary preview/Open never enters this path.""" + deadline = time.monotonic() + 60 + identity = threading.get_ident() + with self._extraction_condition: + if self._extraction_stopping: + raise workbench.WorkbenchError('Catalog extraction stop already has an owner.', 'source_stopping', 409) + self.access.require('admin', 'project_transfer.stop_source', owner_only=True) + self._extraction_stopping = True + guard = _LegacyExtractionGuard(self, self._extraction_binding, identity) + self._extraction_guard = guard + while any(thread != identity and count for thread, count in self._extraction_requests.items()): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise workbench.WorkbenchError('Existing requests have not drained; catalog remains fenced.', 'source_busy', 409) + self._extraction_condition.wait(min(.1, remaining)) + from project_transfer import stop_source + return stop_source(self, backup_parent_fd=backup_parent_fd, backup_directory=backup_directory, + external_roots=external_roots, controller_guard=guard) - @authorized('approve-review', 'dataset.save') - def save(self, sample_id, body): - annotation = validate_annotation(body.get('annotation', {})) - meta = metadata(body) - if annotation['book_present'] and not meta['book_id']: - raise ValueError('Enter a book ID for images containing a book.') - with self.lock, self.db.transaction(): - row = self.db.execute('SELECT revision FROM samples WHERE id=%s', (sample_id,)).fetchone() - if not row: - raise ValueError('Image not found.') - if body.get('revision') != row['revision']: - raise Conflict('This image changed in another tab. Reload it before saving.') - split = self._resolve_split(meta, sample_id) - self.db.execute('UPDATE samples SET annotation=%s,book_id=%s,session_id=%s,split=%s,updated_at=%s,revision=revision+1 WHERE id=%s', - (json.dumps(annotation), meta['book_id'], meta['session_id'], split, now(), sample_id)) - self.access.audit('dataset.save', sample_id, {'before_revision': body['revision'], - 'revision': body['revision'] + 1, 'annotation_sha256': hashlib.sha256(workbench.encode(annotation).encode()).hexdigest()}) - return self.sample(sample_id) @authorized('execute', 'dataset.suggest', owner_only=True) def suggest(self, body): @@ -427,24 +248,114 @@ def _export(self): raise -def make_handler(dataset, observer=None): +class _LegacyExtractionGuard: + """An in-process controller proof, never a transport or project record.""" + def __init__(self, dataset, binding, owner_thread): + self.dataset, self.binding, self.owner_thread = dataset, binding, owner_thread + + def require(self, dataset): + with dataset._extraction_condition: + if (dataset is not self.dataset or dataset._extraction_guard is not self + or dataset._extraction_binding != self.binding or not dataset._extraction_stopping + or threading.get_ident() != self.owner_thread + or any(thread != self.owner_thread and count for thread, count in dataset._extraction_requests.items())): + raise workbench.WorkbenchError('Extraction requires the drained original catalog controller.', 'source_busy', 409) + + +def make_handler(dataset, observer=None, *, transfer_controller=None): class Handler(BaseHTTPRequestHandler): def log_message(self, *_): pass def _observe_request(self, handler): + self._handoff_stopped_source() name, version = (agent_api.observation_operation(self.command, self.path, self.headers) if observer is not None else (None, None)) self._observation_operation, self._observation_version = name, version self._observation = observe_begin(observer, self.command, name, selected_version=version) self._observation_finished = False + self._contract_selection = None try: + if (urlsplit(self.path).path == '/api/workspace/project-transfer' + or urlsplit(self.path).path.startswith('/api/workspace/project-transfer/')): + return self._handle_project_transfer() + if isinstance(dataset, Dataset): + try: + with dataset._project_request(): + return handler() + except workbench.WorkbenchError as error: + return self.reply({'error': str(error), 'code': error.code}, error.status) return handler() finally: if not self._observation_finished: observe_stage(observer, self._observation, 'terminal', outcome='unknown', delivery='incomplete') self._observation_finished = True + def _handoff_stopped_source(self): + nonlocal dataset + if transfer_controller is None or transfer_controller.phase == 'source_live': + return + source = transfer_controller.dataset + # A failed stop may still have old handlers draining. Keep their + # original Dataset closure until the source's request gate proves + # them absent; new ordinary requests remain fenced meanwhile. + with source._extraction_condition: + if source._extraction_stopping and not any(source._extraction_requests.values()): + dataset = transfer_controller.control_workspace + + def _handle_project_transfer(self): + """One explicit source-to-control authority transition, never Open.""" + nonlocal dataset + routes = {('GET', '/api/workspace/project-transfer'): 'status', + ('POST', '/api/workspace/project-transfer/preview'): 'preview', + ('GET', '/api/workspace/project-transfer/report'): 'report_page', + ('POST', '/api/workspace/project-transfer/stop'): 'stop', + ('POST', '/api/workspace/project-transfer/extract'): 'extract'} + try: + if transfer_controller is None: + raise workbench.WorkbenchError('Configure an explicit existing legacy extraction controller first.', 'unavailable', 409) + action = routes.get((self.command, urlsplit(self.path).path)) + if action is None: + raise workbench.WorkbenchError('Unknown extraction operation.', 'unsupported', 404) + name = 'project_transfer.' + action + token, bearer = self._credential() + authority = transfer_controller.access + with authority.bind(token): + self._same_origin(self.command == 'POST' and not bearer) + selected = self._select_contract() + agent_api.require_selection(selected, name) + body = None + if self.command == 'POST': + if self.headers.get_content_type() != 'application/json': + raise agent_api.AgentAPIError('Send a bounded JSON request.', 'invalid', 400) + length = int(self.headers.get('Content-Length', '0')) + if not 0 < length <= agent_api.REQUEST_LIMIT: + raise agent_api.AgentAPIError('Extraction request exceeds its bound.', 'too_large', 413) + raw = self.rfile.read(length) + observe_request_bytes(observer, self._observation, len(raw)) + body = agent_api.decode(raw) + arguments = agent_api.validate_selected(selected, self.command, self.path, body) + self._observe_dispatch(body) + try: + if action == 'status': value = transfer_controller.status() + elif action == 'preview': value = transfer_controller.preview(**arguments) + elif action == 'report_page': value = transfer_controller.report_page(**arguments) + elif action == 'stop': value = transfer_controller.stop(**arguments) + else: value = transfer_controller.extract(**arguments) + finally: + self._handoff_stopped_source() + return self.reply(value) + except (workbench.WorkbenchError, ProjectStoreError, agent_api.AgentAPIError, access.AccessError) as error: + result = {'error': str(error), 'code': error.code} + if hasattr(error, 'receipt'): + result['receipt'] = error.receipt + return self.reply(result, error.status) + except (CommitOutcomeUnknown, psycopg.Error): + return self.reply({'error': 'Extraction outcome requires explicit inspection.', 'code': 'storage'}, 503) + except OSError: + return self.reply({'error': 'Extraction storage outcome requires explicit status inspection; retained staging was not removed.', + 'code': 'storage'}, 503) + def _observe_dispatch(self, body=None): observe_selection(observer, getattr(self, '_observation', None), self._observation_operation, body, self._observation_version) @@ -504,6 +415,14 @@ def reply(self, data, status=200, content_type='application/json', headers=()): self.end_headers() self._write_observed((data,), status) + def project_error(self, error): + value = {'error': str(error), 'code': error.code} + # Only the typed project boundary can attach a non-secret durable + # receipt. Selected output validation checks its closed schema. + if getattr(dataset, 'project_files_mode', False) and hasattr(error, 'receipt'): + value['receipt'] = error.receipt + return self.reply(value, error.status) + def reply_stream(self, stream, metadata): # The owner has already verified a private spool before HTTP success. observe_stage(observer, getattr(self, '_observation', None), 'returned', @@ -702,6 +621,10 @@ def _handle_POST(self): if path.startswith('/api/access/credentials/') and path.endswith('/revoke'): self._access_body() return self.reply(dataset.access.revoke(path.split('/')[-2])) + if (getattr(dataset, 'project_files_mode', False) and + not path.startswith('/api/workspace/project-files/')): + return self.reply({'error': 'This domain is unavailable until its canonical project publication is implemented.', + 'code': 'unavailable'}, 409) if path.startswith('/api/access/execution-grants/') and path.endswith('/revoke'): projects.closed(self._access_body(), ()) return self.reply(dataset.access.revoke_execution(path.split('/')[-2])) @@ -721,7 +644,31 @@ def _get(self): if path == '/api/agent/operations': return self.reply(agent_api.discovery()) if path == '/api/agent/capabilities': - return self.reply(agent_api.capabilities(dataset.db.dataset_id)) + capabilities = (dataset.capabilities() if getattr(dataset, 'project_files_mode', False) + else agent_api.capabilities(dataset.db.dataset_id)) + if not getattr(dataset, 'project_files_mode', False): + for operation in capabilities['operations']: + if operation['id'].startswith('project_files.'): + operation['availability'] = 'unavailable' + if operation['id'].startswith('project_transfer.'): + operation['availability'] = ('available' if transfer_controller is not None + else 'unavailable') + return self.reply(capabilities) + if path == '/api/workspace/context' and getattr(dataset, 'project_files_mode', False): + # Bootstrap identifies the installation that owns this + # credential, without constructing a legacy domain owner. + return self.reply({'version': 1, 'mode': 'real', 'dataset_id': dataset.db.dataset_id, + 'project_files': True}) + if path == '/api/workspace/project-files' or path.startswith('/api/workspace/project-files/'): + if not getattr(dataset, 'project_files_mode', False): + raise workbench.WorkbenchError('Start explicit project-file mode to use this capability.', 'unavailable', 409) + from project_workspace import http_arguments + name, arguments = http_arguments('GET', self.path) + return self.reply(dataset.dispatch(name, arguments, + selected_version=self._contract_selection[1] if self._contract_selection else None)) + if getattr(dataset, 'project_files_mode', False) and path.startswith('/api/'): + raise workbench.WorkbenchError('This domain is unavailable until its canonical project publication is implemented.', + 'unavailable', 409) if (path == '/api/workspace/projects' or path.startswith('/api/workspace/projects/') or path == '/api/workspace/project-snapshots' or path.startswith('/api/workspace/project-snapshots/')): self.project_access() @@ -937,6 +884,12 @@ def _get(self): assets = {'/native-detection-import.js': ('native_detection_import.js', 'text/javascript'), '/saved-searches.js': ('saved-searches.js', 'text/javascript'), '/sequence-batch.js': ('sequence-batch.js', 'text/javascript'), '/pumas-typed.js': ('pumas-typed.js', 'text/javascript'), '/pumas-gateways.js': ('pumas-gateways.js', 'text/javascript'), '/meshes.js': ('meshes.js', 'text/javascript'), '/sequences.js': ('sequences.js', 'text/javascript'), '/text-classification-proposals.js': ('text-classification-proposals.js', 'text/javascript'), '/caption-proposals.js': ('caption-proposals.js', 'text/javascript'), '/curation.js': ('curation.js', 'text/javascript'), '/caption-import.js': ('caption_import.js', 'text/javascript'), '/bulk-import.js': ('bulk_import.js', 'text/javascript'), '/saved-selections.js': ('saved-selections.js', 'text/javascript'), '/workbench': ('workbench.html', 'text/html'), '/workbench.js': ('workbench.js', 'text/javascript'), '/workbench.css': ('workbench.css', 'text/css'), '/': ('index.html', 'text/html'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/rights-note.js': ('rights-note.js', 'text/javascript'), '/preferences.js': ('preferences.js', 'text/javascript'), '/instruction-responses.js': ('instruction-responses.js', 'text/javascript'), '/native-text-import.js': ('native_text_import.js', 'text/javascript')} # Opt-in workspace shell keeps fixture and real service boundaries distinct. assets.update({ + '/project-files': ('project-files.html', 'text/html'), + '/project-files.js': ('project-files.js', 'text/javascript'), + '/project-files.css': ('project-files.css', 'text/css'), + '/project-transfer': ('project-transfer.html', 'text/html'), + '/project-transfer.js': ('project-transfer.js', 'text/javascript'), + '/project-transfer.css': ('project-transfer.css', 'text/css'), '/auth.js': ('auth.js', 'text/javascript'), '/auth.css': ('auth.css', 'text/css'), '/workspace-project-activity.js': ('workspace-project-activity.js', 'text/javascript'), @@ -971,6 +924,8 @@ def _get(self): '/workspaces-data.js': ('workspaces-data.js', 'text/javascript'), '/workspaces-data.css': ('workspaces-data.css', 'text/css'), }) + if getattr(dataset, 'project_files_mode', False) and path in ('/', '/workspaces', '/workbench'): + path = '/project-files' if path in assets: name, kind = assets[path] return self.reply((ROOT / 'static' / name).read_bytes(), content_type=kind + '; charset=utf-8') @@ -984,7 +939,9 @@ def _get(self): except access.AccessError as error: return self.reply({'error': str(error), 'code': error.code}, error.status) except workbench.WorkbenchError as error: - return self.reply({'error': str(error), 'code': error.code}, error.status) + return self.project_error(error) + except ProjectStoreError as error: + return self.project_error(error) except (ValueError, OSError) as error: return self.reply({'error': str(error)}, 400) @@ -1007,6 +964,8 @@ def _post(self): limit = spatial_assets.MAX_REQUEST if path.startswith('/api/workspace/'): limit = 256 * 1024 + if path == '/api/workspace/project-files' or path.startswith('/api/workspace/project-files/'): + limit = agent_api.REQUEST_LIMIT if not 0 < length <= limit: raise ValueError('Request is too large or empty.') raw_body = self.rfile.read(length) @@ -1016,10 +975,22 @@ def _post(self): # Strict JSON at the selected boundary, preserving ordinary HTTP semantics. body = agent_api.decode(raw_body) agent_api.validate_selected(self._contract_selection, self.command, self.path, body) + elif path == '/api/workspace/project-files' or path.startswith('/api/workspace/project-files/'): + body = agent_api.decode(raw_body) if not isinstance(body, dict): raise ValueError('Expected an object.') self._observe_dispatch(body) path = urlsplit(self.path).path + if path == '/api/workspace/project-files' or path.startswith('/api/workspace/project-files/'): + if not getattr(dataset, 'project_files_mode', False): + raise workbench.WorkbenchError('Start explicit project-file mode to use this capability.', 'unavailable', 409) + from project_workspace import http_arguments + name, body = http_arguments('POST', self.path, body) + return self.reply(dataset.dispatch(name, body, + selected_version=self._contract_selection[1] if self._contract_selection else None)) + if getattr(dataset, 'project_files_mode', False): + raise workbench.WorkbenchError('This domain is unavailable until its canonical project publication is implemented.', + 'unavailable', 409) if path.startswith('/api/workspace/'): parts = path.removeprefix('/api/workspace/').split('/') if parts == ['scientific-comparison']: @@ -1352,7 +1323,9 @@ def graph_guard(graph): except access.AccessError as error: return self.reply({'error': str(error), 'code': error.code}, error.status) except workbench.WorkbenchError as error: - return self.reply({'error': str(error), 'code': error.code}, error.status) + return self.project_error(error) + except ProjectStoreError as error: + return self.project_error(error) except Conflict as error: return self.reply({'error': str(error)}, 409) except FileNotFoundError: @@ -1370,6 +1343,14 @@ def main(): parser.add_argument('--port', type=int, default=8091) parser.add_argument('--project-root', action='append', default=[], metavar='ALIAS=PATH', help='Allow read-only project inspection beneath a local directory; repeat for multiple roots.') + parser.add_argument('--project-files-root', action='append', default=[], metavar='ALIAS=PATH', + help='Use inert external text-project mode beneath explicit local roots; other domain writes are unavailable.') + parser.add_argument('--project-runtime-source', action='append', default=[], metavar='ALIAS=CONFIG_FILE', + help='Allow explicit offline CPU runtime capture from an operator-owned local configuration; never capture on Open.') + parser.add_argument('--project-transfer-control', type=Path, + help='Enable explicit stopped extraction from this existing legacy controller into a separate local authority host.') + parser.add_argument('--project-transfer-root', action='append', default=[], metavar='ALIAS=PATH', + help='Configure backup and destination roots for explicit legacy extraction; requires --project-transfer-control.') args = parser.parse_args() roots = {} for binding in args.project_root: @@ -1377,12 +1358,49 @@ def main(): if not separator or not location or alias in roots: parser.error('Each project root needs a unique ALIAS=PATH.') roots[alias] = location + transfer_controller = control_workspace = None try: - dataset = Dataset(args.data, project_roots=roots) + if args.project_files_root: + if roots or args.project_transfer_control or args.project_transfer_root: + parser.error('Choose project-file mode or legacy snapshot inspection roots, not both.') + file_roots = {} + for binding in args.project_files_root: + alias, separator, location = binding.partition('=') + if not separator or not location or alias in file_roots: + parser.error('Each project-file root needs a unique ALIAS=PATH.') + file_roots[alias] = location + from project_workspace import ProjectWorkspace + runtime_sources = {} + from recipe_process import read_regular, decode + for binding in args.project_runtime_source: + alias, separator, location = binding.partition('=') + if not separator or not location or alias in runtime_sources: + parser.error('Each runtime source needs a unique ALIAS=CONFIG_FILE.') + runtime_sources[alias] = decode(read_regular(Path(location).expanduser(), 65536)) + dataset = ProjectWorkspace(args.data, file_roots, runtime_sources=runtime_sources) + else: + if args.project_runtime_source: + parser.error('Project runtime sources require external project-file mode.') + dataset = Dataset(args.data, project_roots=roots) + if args.project_transfer_control: + transfer_roots = {} + for binding in args.project_transfer_root: + alias, separator, location = binding.partition('=') + if not separator or not location or alias in transfer_roots: + parser.error('Each extraction root needs a unique ALIAS=PATH.') + transfer_roots[alias] = location + from project_workspace import ProjectWorkspace + from project_transfer_controller import ProjectTransferController + control_workspace = ProjectWorkspace(args.project_transfer_control, transfer_roots) + transfer_controller = ProjectTransferController(dataset, control_workspace) + control_workspace.transfer_controller = transfer_controller + elif args.project_transfer_root: + parser.error('Extraction roots require --project-transfer-control.') except (StorageError, workbench.WorkbenchError) as error: parser.exit(2, str(error) + "\n") observer = configured_interface(args, 'http') - server = ThreadingHTTPServer((args.host, args.port), make_handler(dataset, observer)) + server = ThreadingHTTPServer((args.host, args.port), make_handler(dataset, observer, + transfer_controller=transfer_controller)) print('Tuldok: http://' + args.host + ':' + str(server.server_port), flush=True) try: server.serve_forever() @@ -1390,10 +1408,19 @@ def main(): pass finally: server.server_close() + if transfer_controller is not None: + transfer_controller.close() + if control_workspace is not None: + control_workspace.close() dataset.close() if observer is not None: observe_close(observer) if __name__ == '__main__': + # Trusted controller capabilities use exact app-owned class identities. + # Executing this file must not create a second imported app module with + # distinct Dataset/guard classes during the explicit extraction workflow. + import sys + sys.modules['app'] = sys.modules[__name__] main() diff --git a/audio_assets.py b/audio_assets.py index 09a325e5..8f165cda 100644 --- a/audio_assets.py +++ b/audio_assets.py @@ -3,6 +3,7 @@ import base64 import hashlib import io +import re import struct import uuid import wave @@ -92,6 +93,19 @@ def validate_annotation(value, record): return {'segments': result} +def validate_project_metadata(value): + """The declared PCM frame contract, without requiring source availability.""" + fields={'schema_version','encoding','sample_rate_hz','channels','frame_count','duration_seconds','source_bytes','pcm_sha256','protected_groups'} + if type(value) is not dict or set(value)!=fields: + invalid('unexpected retained audio metadata fields.') + if type(value['schema_version']) is not int or value['schema_version']!=1 or value['encoding']!='pcm_s16le':invalid('unsupported retained audio encoding.') + rate,channels,frames=value['sample_rate_hz'],value['channels'],value['frame_count'] + if type(rate) is not int or not 8000<=rate<=96000 or type(channels) is not int or channels not in (1,2) or type(frames) is not int or not 1<=frames<=rate*MAX_DURATION_SECONDS:invalid('invalid retained PCM frame contract.') + if type(value['duration_seconds']) not in (int,float) or value['duration_seconds']!=frames/rate or type(value['source_bytes']) is not int or not 44<=value['source_bytes']<=MAX_WAV_BYTES:invalid('invalid retained PCM duration/byte count.') + if type(value['pcm_sha256']) is not str or not re.fullmatch('[a-f0-9]{64}',value['pcm_sha256']):invalid('invalid retained PCM digest.') + if strings(value['protected_groups'],'Audio groups')!=value['protected_groups'] or len(value['protected_groups'])!=2:invalid('invalid retained audio groups.') + + class AudioAssets: def __init__(self, workbench): self.access = workbench.access @@ -145,14 +159,19 @@ def metadata(self, record_id, provenance): meta = provenance.get('audio') if not isinstance(meta, dict) or meta.get('schema_version') != 1: invalid('stored source metadata is unavailable.', 'unavailable', 409) - return dict(meta), (self.path / record_id / 'source.wav').is_file() + available = getattr(self.workbench.dataset, 'project_availability', None) + return dict(meta), available(record_id) if available else (self.path / record_id / 'source.wav').is_file() @authorized('inspect', 'audio_assets.asset', target=lambda record: record.get('id') if isinstance(record, dict) else None) def asset(self, record): path = self.path / record['id'] / 'source.wav' try: - with path.open('rb') as source: - raw = source.read(MAX_WAV_BYTES + 1) + resolver = getattr(self.workbench.dataset, 'project_asset', None) + if resolver is not None and record['id'] in self.workbench.dataset.project_sources: + raw = resolver(record['id'], 'audio') + else: + with path.open('rb') as source: + raw = source.read(MAX_WAV_BYTES + 1) except OSError: invalid('original WAV is unavailable. Restore it from the catalog backup.', 'unavailable', 404) if hashlib.sha256(raw).hexdigest() != record['content_hash']: diff --git a/candidate_packages.py b/candidate_packages.py index af7cb275..2c9413c5 100644 --- a/candidate_packages.py +++ b/candidate_packages.py @@ -1196,17 +1196,18 @@ def reference(name): return manifest, blobs -def _canonical_capture_evidence(dataset, composition_ref, policy): +def _canonical_capture_evidence(dataset, composition_ref, policy, *, read_blob=None): """Use an authorized verified composition, then its hash-bound owned blobs.""" from model_definitions import read_owned_blob value = dataset.pipelines.recipe_material(composition_ref, verify=True) material = value['material']; directory = dataset.pipelines.material_path - snapshot_raw = read_owned_blob(directory, composition_ref['sha256'], material['recipe_document'], 2*1024**2) + reader = (lambda item, maximum: read_owned_blob(directory, composition_ref['sha256'], item, maximum)) if read_blob is None else lambda item, maximum: read_blob(composition_ref,item,maximum) + snapshot_raw = reader(material['recipe_document'], 2*1024**2) snapshot = decode(snapshot_raw) paths = [item['blob'] for item in snapshot['capture_paths'] if item['path'] == 'input/' + policy['canonical']['provenance']['input']] require(len(paths) == 1 and paths[0] in material['captures'], 'Canonical captured provenance is unavailable.') - provenance_raw = read_owned_blob(directory, composition_ref['sha256'], paths[0], 65536) + provenance_raw = reader(paths[0], 65536) return {'composition': encoded(material), 'execution_snapshot': snapshot_raw, 'canonical_provenance': provenance_raw} @@ -1232,6 +1233,18 @@ def authorize_transitive_inputs(runs, run, *, seen=None): def authorize_material_inputs(runs,material,*,seen=None): """Reauthorize an exact retained input material without inventing a Run.""" from workbench import WorkbenchError + # Project producers remain inert observations. Resolve their exact input + # proof through the fixed history owner, never through Runs._load recovery + # rows or a general historical fallback on the live Run owner. + from project_history import ProjectHistory + if isinstance(runs,ProjectHistory): + return runs.authorize_material_inputs(material) + from project_execution import ProjectExecution + execution=getattr(runs,'project_execution',None) + if isinstance(execution,ProjectExecution): + if execution._runs is not runs or not isinstance(execution.projection.history,ProjectHistory): + raise WorkbenchError('Current project Run input owner differs.','unavailable',409) + return execution.projection.history.authorize_material_inputs(material) seen=set() if seen is None else seen for item in material['inputs'].values(): if item.get('kind') != 'run_artifact': @@ -1376,7 +1389,7 @@ def _prefix_project_references(destination, prefix, prepared): path.chmod(0o400) -def _prepare_operation(experiments, study_id, body, kind): +def _prepare_operation(experiments, study_id, body, kind, *, publication=None): """Owner composition, atomic metadata, and existing exact study preparation.""" from metadata import CommitOutcomeUnknown from model_definitions import checked_json @@ -1411,7 +1424,10 @@ def _prepare_operation(experiments, study_id, body, kind): try: destination = Path('/proc/self/fd') / str(root_fd) / body['directory'] require(not destination.exists(), 'Preparation directory exists without this exact operation receipt.', 'unavailable') - snapshot = RuntimeSnapshots(dataset.db, access, dataset.path).get(body['runtime_snapshot_sha256']) + snapshot = (RuntimeSnapshots(dataset.db, access, dataset.path).get(body['runtime_snapshot_sha256']) + if publication is None else publication.runtime_snapshot) + require(snapshot['catalog_id']==dataset.db.dataset_id and snapshot['manifest_sha256']==body['runtime_snapshot_sha256'], + 'Current runtime differs from candidate preparation.', 'unavailable') if kind == 'package': run = dataset.runs._load(candidate['run_id']) scientific = dataset.runs._extension(run) @@ -1439,12 +1455,14 @@ def _prepare_operation(experiments, study_id, body, kind): published = {'material': model['material'], **{key: dataset.models.read_blob(model['ref'], model['material'][key]) for key in ('specification', 'preprocessing_policy', 'output_policy')}} if scientific: policy = decode(artifacts['execution-context.json'])['static']['policy'] - captured = (_canonical_capture_evidence(dataset, source_trial['material'], policy) + captured = (_canonical_capture_evidence(dataset, source_trial['material'], policy, + read_blob=None if publication is None else publication.read_recipe_blob) if policy.get('format') == 'extension_field_policy_v2' else None) manifest, blobs = scalar_a_closure(candidate, artifacts, sources, runtime_contract(snapshot), published, canonical_capture=captured) else: manifest, blobs = classifier_closure(candidate, artifacts, images, sources, runtime_contract(snapshot), published) + if publication is not None:publication.before_write() prepared = prepare_project(destination, manifest, blobs, snapshot_sha256=snapshot['manifest_sha256'] if scientific and policy.get('format') == 'extension_field_policy_v2' else None) else: @@ -1453,6 +1471,7 @@ def _prepare_operation(experiments, study_id, body, kind): require(manifest['candidate'] == candidate['ref'], 'Package belongs to another candidate.') require(decode(files[manifest['runtime']['contract']['name']])['snapshot'] == snapshot, 'Verifier runtime differs from package.', 'unsupported') + if publication is not None:publication.before_write() prepared = prepare_verifier_project(destination, {'kind': 'run_artifact', 'run_id': body['package_run_id'], 'artifact_name': 'package.zip', 'expected_sha256': body['package_sha256']}, snapshot['manifest_sha256']) publication_owned = False @@ -1482,7 +1501,8 @@ def _prepare_operation(experiments, study_id, body, kind): def publish(trial): nonlocal publication_owned, published_result publication_owned = True - experiments.db.on_rollback(lambda: shutil.rmtree(destination, ignore_errors=True)) + if publication is None: + experiments.db.on_rollback(lambda: shutil.rmtree(destination, ignore_errors=True)) access.require('edit', operation, study_id, owner_only=True) prior = experiments._receipt(context.actor_id, operation, body['request_key'], identity) if prior: @@ -1508,7 +1528,7 @@ def publish(trial): except CommitOutcomeUnknown: raise except BaseException: - if not publication_owned: + if not publication_owned and publication is None: shutil.rmtree(destination, ignore_errors=True) raise @@ -1524,37 +1544,70 @@ def prepare_candidate_verifier(experiments, study_id, body): return _prepare_operation(experiments, study_id, body, 'verify') +def decode_preparation_receipt(operation_id, value, *, catalog_id, validate_trial): + """Retain preparation facts without replaying requests or opening destinations.""" + from model_definitions import checked_json,reference + from pipeline import decode_graph + require(operation_id in ('studies.prepare_package','studies.prepare_verifier'),'Unsupported preparation receipt.','unsupported') + value=checked_json(value) + verification=operation_id=='studies.prepare_verifier' + fields={'state','candidate_id','trial','graph','model','runtime_snapshot_sha256'} + if verification:fields|={'package_run_id','package_sha256'} + closed(value,fields) + require(value['state']=='prepared','Preparation receipt must retain its actual prepared state.') + require(type(value['candidate_id']) is str and re.fullmatch('[a-f0-9]{32}',value['candidate_id']) is not None,'Invalid prepared candidate identity.') + trial=validate_trial(value['trial']);graph=decode_graph(value['graph']);model=reference(value['model'],'model',catalog_id) + require(trial==value['trial'],'Prepared Trial decoder changed retained facts.') + require(type(value['runtime_snapshot_sha256']) is str and HASH.fullmatch(value['runtime_snapshot_sha256']) is not None,'Invalid prepared runtime manifest.') + if verification: + require(type(value['package_run_id']) is str and re.fullmatch('[a-f0-9]{32}',value['package_run_id']) is not None and + type(value['package_sha256']) is str and HASH.fullmatch(value['package_sha256']) is not None,'Invalid prepared package reference.') + return value + + def candidate_package_state(experiments, candidate_id): - """Read current authorized package/verification receipts without executing code.""" - experiments.access.require('inspect', 'studies.package_state', candidate_id) - candidate = experiments.candidate(candidate_id) - result = {'candidate': candidate, 'state': 'selected', 'packages': [], 'verifications': []} - rows = experiments.db.execute("SELECT operation_id,document FROM model_operation_receipts WHERE operation_id IN (%s,%s) AND (document::jsonb)->>'candidate_id'=%s ORDER BY actor_id,request_key LIMIT 101", - ('studies.prepare_package', 'studies.prepare_verifier', candidate_id)).fetchall() - require(len(rows) <= 100, 'Candidate package receipt limit exceeded.', 'unsupported') + experiments.access.require('inspect','studies.package_state',candidate_id) + candidate=experiments.candidate(candidate_id) + rows=experiments.db.execute("SELECT operation_id,document FROM model_operation_receipts WHERE operation_id IN (%s,%s) AND (document::jsonb)->>'candidate_id'=%s ORDER BY actor_id,request_key LIMIT 101", + ('studies.prepare_package','studies.prepare_verifier',candidate_id)).fetchall() from model_definitions import retained_json + operations=[{'operation_id':row['operation_id'],'result':retained_json(row['document'])} for row in rows] + def runs(trial_id): + reservations=experiments.db.execute('SELECT run_id FROM model_attempt_reservations WHERE trial_id=%s ORDER BY run_id LIMIT 101',(trial_id,)).fetchall() + require(len(reservations)<=100,'Candidate package reservation limit exceeded.','unsupported') + return [experiments.dataset.runs.get(row['run_id']) for row in reservations] + return retained_package_state(candidate,operations,runs_for_trial=runs, + read_artifact=lambda run_id,name:experiments.dataset.runs.artifact(run_id,name)[0]) + + +def retained_package_state(candidate, prepared_operations, *, runs_for_trial, read_artifact): + """Pure existing reduction over already-authorized exact retained facts.""" + result = {'candidate': candidate, 'state': 'selected', 'packages': [], 'verifications': []} + rows=prepared_operations + require(type(rows) is list and len(rows)<=100,'Candidate package receipt limit exceeded.','unsupported') for row in rows: - prepared = retained_json(row['document']) - if prepared.get('candidate_id') != candidate_id: + closed(row,('operation_id','result')) + require(row['operation_id'] in ('studies.prepare_package','studies.prepare_verifier'), + 'Unsupported retained preparation operation.','unsupported') + prepared = row['result'] + if prepared.get('candidate_id') != candidate['ref']['id']: continue - reservations = experiments.db.execute('SELECT run_id FROM model_attempt_reservations WHERE trial_id=%s ORDER BY run_id LIMIT 101', - (prepared['trial']['id'],)).fetchall() - require(len(reservations) <= 100, 'Candidate package reservation limit exceeded.', 'unsupported') - for reservation in reservations: - run = experiments.dataset.runs.get(reservation['run_id']) + runs=runs_for_trial(prepared['trial']['id']) + require(type(runs) is list and len(runs)<=100,'Candidate package reservation limit exceeded.','unsupported') + for run in runs: if run['state'] != 'completed': continue collection = 'packages' if row['operation_id'] == 'studies.prepare_package' else 'verifications' require(len(result[collection]) < 100, 'Candidate package state result limit exceeded.', 'unsupported') if row['operation_id'] == 'studies.prepare_package': - raw, _ = experiments.dataset.runs.artifact(run['id'], 'package.zip') + raw = read_artifact(run['id'],'package.zip') item = next(item for item in run['artifacts'] if item['name'] == 'package.zip') manifest, files = inspect_package(raw, item['sha256']) require(manifest['candidate'] == candidate['ref'], 'Published package candidate differs.') result['packages'].append({'run_id': run['id'], 'sha256': item['sha256'], 'bytes': item['bytes'], 'artifact_name': 'package.zip', 'url': f"/api/workspace/runs/{run['id']}/artifacts/package.zip"}) else: - raw, _ = experiments.dataset.runs.artifact(run['id'], 'verification.json') + raw = read_artifact(run['id'],'verification.json') receipt = decode(raw) require(receipt.get('verified') is True and receipt.get('candidate') == candidate['ref'] and receipt.get('package_sha256') == prepared['package_sha256'], 'Published fresh verification differs.') diff --git a/caption_proposals.py b/caption_proposals.py index d5e3be5b..d6871c3b 100644 --- a/caption_proposals.py +++ b/caption_proposals.py @@ -114,6 +114,31 @@ def decode(data, source, request_id=None): raise WorkbenchError('Model must return one complete, valid caption JSON; partial or invalid output was not applied.') from None +def validate_retained_result(job): + """Pure byte/result checks shared by live completion and inert history.""" + image = job.get('input_image_base64') + if image is not None: + raw_image = base64.b64decode(image, validate=True) + captured = job['input_image'] + if (len(raw_image) > MAX_IMAGE or len(raw_image) != captured['bytes'] or + hashlib.sha256(raw_image).hexdigest() != captured['sha256']): + raise WorkbenchError('Retained prepared image identity differs.') + with Image.open(io.BytesIO(raw_image)) as raster: + if raster.format != 'JPEG' or list(raster.size) != captured['size']: + raise WorkbenchError('Retained prepared image geometry differs.') + raster.load() + encoded = job.get('raw_response_base64') + if encoded is None: + return + raw = base64.b64decode(encoded, validate=True) + if len(raw) > MAX_OUTPUT or hashlib.sha256(raw).hexdigest() != job['response_sha256']: + raise WorkbenchError('Retained caption response identity differs.') + if job['status'] in ('completed', 'applied', 'rejected'): + annotation, reported = decode(raw, job['source'], job['id'] if pumas.typed(job['config']) else None) + if annotation != job['annotation'] or reported != job.get('reported_model'): + raise WorkbenchError('Retained caption differs from the original response.') + + class CaptionProposals: def __init__(self, workbench): self.workbench = workbench @@ -141,6 +166,10 @@ def _save(self, job): job['updated_at'] = timestamp() self.db.execute('INSERT INTO caption_proposals (id, data) VALUES (%s,%s) ON CONFLICT(id) DO UPDATE SET data=excluded.data', (job['id'], encode_retained(job))) + execution = getattr(self.workbench.dataset, 'project_execution', None) + if execution is not None: + execution.mark_caption(job['id']) + def _get(self, job_id): if not isinstance(job_id, str) or not IDENTIFIER.fullmatch(job_id): raise WorkbenchError('Invalid caption request ID.') @@ -202,6 +231,18 @@ def _source(self, body, *, capture_image=False): @authorized('execute', 'caption.start', owner_only=True) def start(self, body): + with self.lock: + if self.db.in_transaction: + raise WorkbenchError('Caption dispatch requires its own committed transaction.', 'conflict', 409) + with self.db.transaction(): + job, launch = self._prepare_start(body) + if launch: + self._launch(job) + return job + + def _prepare_start(self, body): + if not self.db.in_transaction: + raise RuntimeError('Producer preparation requires an owned transaction.') fields = {'request_id', 'source_id', 'revision', 'source_revision', 'server_url', 'model', 'instruction', 'seed'} if not isinstance(body, dict) or not fields <= body.keys() or body.keys() - fields - {'protocol', 'profile'}: raise WorkbenchError('Supply only caption request identity, exact image revisions, model, guidance and seed.') @@ -218,61 +259,59 @@ def start(self, body): elif type(config['seed']) is not int or not 0 <= config['seed'] <= 2**32 - 1: raise WorkbenchError('Seed must be a nonnegative 32-bit integer.') intent_hash = hashlib.sha256(encode(body).encode()).hexdigest() - with self.lock: - if self.db.in_transaction: - raise WorkbenchError('Caption dispatch requires its own committed transaction.', 'conflict', 409) + previous = self.db.execute('SELECT data FROM caption_proposals WHERE id=%s', (request_id,)).fetchone() + if previous: + job = json.loads(previous[0]) + if job['intent_sha256'] != intent_hash: + raise WorkbenchError('Caption request ID already belongs to a different request.', 'conflict', 409) + return job, False + if not self.runtime_owner: + raise WorkbenchError('Caption workers belong to another open application. Use that application or reopen after it closes.', 'unavailable', 409) + if self.closed or (self.worker is not None and self.worker.is_alive()): + raise WorkbenchError('A caption request is active or closing. Cancel or wait before starting another.', 'conflict', 409) + source, image_bytes = self._source(body, capture_image=True) + output = io.BytesIO() + # Decode the exact buffer whose hash established source identity. + with Image.open(io.BytesIO(image_bytes)) as image: + image = image.convert('RGB') + image.thumbnail((1600, 1600), Image.Resampling.LANCZOS) + size = list(image.size) + image.save(output, 'JPEG', quality=95) + data = output.getvalue() + if len(data) > MAX_IMAGE: + raise WorkbenchError('Prepared model image exceeds 2 MiB; no inference started.') + job = {'schema_version': 1, 'id': request_id, 'revision': 0, 'status': 'preparing', 'error': '', + 'created_at': timestamp(), 'intent_sha256': intent_hash, 'source': source, 'config': config, + 'system_prompt': SYSTEM_PROMPT, 'prompt_version': PROMPT_VERSION, + 'prompt_sha256': hashlib.sha256(SYSTEM_PROMPT.encode()).hexdigest(), + 'input_image_base64': base64.b64encode(data).decode('ascii'), + 'input_image': {'mime': 'image/jpeg', 'size': size, 'bytes': len(data), 'sha256': hashlib.sha256(data).hexdigest(), + 'preparation': 'oriented canonical RGB; thumbnail max 1600; JPEG quality 95'}, + 'annotation': None, 'raw_response_base64': None, 'response_sha256': None, + 'verification': 'Caption shape only. Vision compatibility and factual correctness are not established by the catalog; human review is required.', + 'application': None} + job['canonical_request_sha256'] = hashlib.sha256(encode(payload(job)).encode()).hexdigest() + if pumas.typed(config): + job['provider_outcome'] = 'not_admitted' + job['request_digest'] = hashlib.sha256(encode({'config': job['config'], 'source': job['source'], 'payload': payload(job)}).encode()).hexdigest() + job['admission_id'] = self.access.admit_job('caption', job['id'], job['request_digest'], + {'caption.finish': [job['id']]}) + self.access.audit('caption.start', job['id'], {'request_digest': job['request_digest']}) + self._save(job) + return job, True + + def _launch(self, job): + # Persistence has committed; this lock serializes startup and shutdown. + self.stop.clear() + self.active_id = job['id'] + self.worker = threading.Thread(target=self._run, args=(job['id'],), daemon=True) + try: + self.worker.start() + except RuntimeError: + self.worker, self.active_id = None, None with self.db.transaction(): - previous = self.db.execute('SELECT data FROM caption_proposals WHERE id=%s', (request_id,)).fetchone() - if previous: - job = json.loads(previous[0]) - if job['intent_sha256'] != intent_hash: - raise WorkbenchError('Caption request ID already belongs to a different request.', 'conflict', 409) - return job - if not self.runtime_owner: - raise WorkbenchError('Caption workers belong to another open application. Use that application or reopen after it closes.', 'unavailable', 409) - if self.closed or (self.worker is not None and self.worker.is_alive()): - raise WorkbenchError('A caption request is active or closing. Cancel or wait before starting another.', 'conflict', 409) - source, image_bytes = self._source(body, capture_image=True) - output = io.BytesIO() - # Decode the exact buffer whose hash established source identity. - with Image.open(io.BytesIO(image_bytes)) as image: - image = image.convert('RGB') - image.thumbnail((1600, 1600), Image.Resampling.LANCZOS) - size = list(image.size) - image.save(output, 'JPEG', quality=95) - data = output.getvalue() - if len(data) > MAX_IMAGE: - raise WorkbenchError('Prepared model image exceeds 2 MiB; no inference started.') - job = {'schema_version': 1, 'id': request_id, 'revision': 0, 'status': 'preparing', 'error': '', - 'created_at': timestamp(), 'intent_sha256': intent_hash, 'source': source, 'config': config, - 'system_prompt': SYSTEM_PROMPT, 'prompt_version': PROMPT_VERSION, - 'prompt_sha256': hashlib.sha256(SYSTEM_PROMPT.encode()).hexdigest(), - 'input_image_base64': base64.b64encode(data).decode('ascii'), - 'input_image': {'mime': 'image/jpeg', 'size': size, 'bytes': len(data), 'sha256': hashlib.sha256(data).hexdigest(), - 'preparation': 'oriented canonical RGB; thumbnail max 1600; JPEG quality 95'}, - 'annotation': None, 'raw_response_base64': None, 'response_sha256': None, - 'verification': 'Caption shape only. Vision compatibility and factual correctness are not established by the catalog; human review is required.', - 'application': None} - job['canonical_request_sha256'] = hashlib.sha256(encode(payload(job)).encode()).hexdigest() - if pumas.typed(config): - job['provider_outcome'] = 'not_admitted' - job['request_digest'] = hashlib.sha256(encode({'config': job['config'], 'source': job['source'], 'payload': payload(job)}).encode()).hexdigest() - job['admission_id'] = self.access.admit_job('caption', job['id'], job['request_digest'], - {'caption.finish': [job['id']]}) - self.access.audit('caption.start', job['id'], {'request_digest': job['request_digest']}) + job.update(status='failed', error='Could not start the caption worker; no inference ran. Start a new request explicitly.') self._save(job) - # Persistence has committed; this lock serializes startup and shutdown. - self.stop.clear() - self.active_id = job['id'] - self.worker = threading.Thread(target=self._run, args=(job['id'],), daemon=True) - try: - self.worker.start() - except RuntimeError: - self.worker, self.active_id = None, None - with self.db.transaction(): - job.update(status='failed', error='Could not start the caption worker; no inference ran. Start a new request explicitly.') - self._save(job) - return job def _run(self, job_id): with self.lock: @@ -305,12 +344,12 @@ def _run_admitted(self, job): if self.stop.is_set(): raise WorkbenchError('Caption request cancelled.', 'cancelled') job.update(status='generating', catalog_qualification=models['qualification']) - if pumas.typed(job['config']): + if pumas.typed(job['config']) or getattr(self.workbench.dataset,'project_execution',None) is not None: # Persist uncertainty before possible effects; restart never replays it. job['provider_outcome'] = 'unknown' self._save(job) data = complete(job, self.stop) - if pumas.typed(job['config']): + if pumas.typed(job['config']) or getattr(self.workbench.dataset,'project_execution',None) is not None: job['provider_outcome'] = 'result_received' job['response_sha256'] = hashlib.sha256(data).hexdigest() job['raw_response_base64'] = base64.b64encode(data).decode('ascii') @@ -320,6 +359,7 @@ def _run_admitted(self, job): raise WorkbenchError('Caption request cancelled.', 'cancelled') self._source(dict(source_id=job['source']['id'], revision=job['source']['revision'], source_revision=job['source']['source_revision'])) job.update(status='completed', annotation=annotation, reported_model=reported) + validate_retained_result(job) self._save(job) self.access.audit('caption.finish', job_id, {'status': job['status'], 'response_sha256': job.get('response_sha256')}) except Exception as error: @@ -337,14 +377,30 @@ def cancel(self, body): if not isinstance(body, dict) or set(body) != {'job_id'}: raise WorkbenchError('Supply only the caption request ID.') with self.lock, self.db.transaction(): - job = self._get(body['job_id']) - if job['id'] != self.active_id or job['status'] not in ACTIVE: - return {'cancelled': False, 'job': job} - self.stop.set() - job.update(status='stopping', error='Cancellation requested; late output cannot be applied.') - self._save(job) - self.access.audit('caption.cancel', job['id']) - return {'cancelled': True, 'job': job} + result, pending = self._prepare_cancel(body['job_id']) + if pending is not None: + self._signal_cancel(pending) + return result + + def _prepare_cancel(self, job_id): + """Persist intent only; project dispatch signals after canonical ACK.""" + if not self.db.in_transaction: + raise RuntimeError('Producer cancellation preparation requires an owned transaction.') + job = self._get(job_id) + if job['id'] != self.active_id or job['status'] not in ACTIVE: + return {'cancelled': False, 'job': job}, None + pending = (self.worker, job_id) + job.update(status='stopping', error='Cancellation requested; late output cannot be applied.') + self._save(job) + self.access.audit('caption.cancel', job['id']) + return {'cancelled': True, 'job': job}, pending + + def _signal_cancel(self, pending): + # A retained worker object is local custody, never a saved PID or ID. + if self.worker is not pending[0]: + return False + self.stop.set() + return True @authorized('edit', 'caption.decide') def decide(self, job_id, body): diff --git a/dataset_releases.py b/dataset_releases.py index d2ea5cb9..42451169 100644 --- a/dataset_releases.py +++ b/dataset_releases.py @@ -3,6 +3,7 @@ import audio_export from contextlib import contextmanager import hashlib +import io import json import os import re @@ -161,6 +162,64 @@ def __init__(self, workbench): self.path = workbench.dataset.path / 'releases' self.path.mkdir(exist_ok=True) + @staticmethod + def project_manifest(raw): + """Read retained release bytes inertly; never recreate a frozen archive.""" + from canonical_release import decode + if type(raw) is not bytes or not 0 < len(raw) <= MAX_SELECTED_TEXT_BYTES: + raise WorkbenchError('Frozen release exceeds the retained archive bound.') + try: + with zipfile.ZipFile(io.BytesIO(raw)) as archive: + entries = archive.infolist() + names = archive.namelist() + if len(names) != len(set(names)) or len(names) > 20000 or sum(i.file_size for i in entries) > MAX_SELECTED_TEXT_BYTES: + raise ValueError('Invalid frozen inventory.') + for item in entries: + parts = item.filename.split('/') + if (not parts or any(p in ('', '.', '..') for p in parts) or '\\' in item.filename + or item.flag_bits & 1 or stat.S_IFMT(item.external_attr >> 16) not in (0, stat.S_IFREG)): + raise ValueError('Unsafe frozen inventory.') + if archive.getinfo('manifest.json').file_size > 8 * 1024 * 1024: + raise ValueError('Frozen manifest is excessive.') + manifest = decode(archive.read('manifest.json')) + Releases.validate_project_manifest(manifest) + # Observe every CRC without extracting paths or executing code. + for item in entries: + archive.read(item) + return manifest + except (ValueError, KeyError, zipfile.BadZipFile, RuntimeError, UnicodeError) as error: + raise WorkbenchError('Retained frozen release is malformed.') from error + + @staticmethod + def validate_project_manifest(manifest): + """Validate the owned metadata variant even when its payload is missing.""" + if type(manifest) is not dict or type(manifest.get('schema_version')) is not int or manifest['schema_version'] != 1: + raise WorkbenchError('Invalid frozen release manifest version.') + formats = ('canonical_v1', CAPTION_FORMAT, INSTRUCTION_FORMAT, PREFERENCE_FORMAT, + classification_export.FORMAT, text_class_export.FORMAT, audio_class_export.FORMAT, corpus_export.FORMAT) + format_name = manifest.get('format', 'canonical_v1') + if format_name not in formats: + raise WorkbenchError('Unsupported frozen release format.', 'unsupported', 409) + if type(manifest.get('seed')) is not int or not 0 <= manifest['seed'] <= 2**32 - 1 or type(manifest.get('split_report')) is not dict: + raise WorkbenchError('Invalid frozen release seed or split report.') + common={'schema_version','seed','split_report'} + variants={ + 'canonical_v1':common|{'coordinate_contract','limitations','records','vocabulary'}, + CAPTION_FORMAT:common|{'format','consumer','split_mapping','records','protected_components','warnings','limitations'}, + classification_export.FORMAT:common|{'format','consumer','split_mapping','class_vocabulary','class_coverage','class_to_idx','protected_components','records','warnings','limitations'}, + text_class_export.FORMAT:common|{'format','consumer','text_contract','class_vocabulary','class_to_idx','protected_components','records','limitations'}, + audio_class_export.FORMAT:common|{'format','consumer','audio_contract','class_vocabulary','class_to_idx','protected_components','records','limitations'}, + corpus_export.FORMAT:common|{'format','consumer','corpus_counts','files','text_contract','warnings','records','protected_components','limits'}, + INSTRUCTION_FORMAT:common|{'format','consumer','prompts','responses','protected_components','text_contract','limits'}, + PREFERENCE_FORMAT:common|{'format','consumer','prompts','responses','judgments','excluded','competing_reviewed_judgments','protected_components','text_contract','limits'}, + } + if set(manifest)!=variants[format_name]: + raise WorkbenchError('Unexpected frozen release manifest fields.') + for key in ('records','responses','prompts','judgments','competing_reviewed_judgments','excluded'): + if key in manifest and (type(manifest[key]) is not list or len(manifest[key]) > 5000 or any(type(r) is not dict for r in manifest[key])): + raise WorkbenchError('Invalid frozen release evidence list.') + return manifest + @authorized('inspect', 'dataset_releases.locate') def locate(self, release_id): if not isinstance(release_id, str) or not RELEASE_ID.fullmatch(release_id): diff --git a/docs/README.md b/docs/README.md index f832b0a2..679739f8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,7 @@ Start with the [root README](../README.md) for installation and the Data → Pip - [Agent operations and MCP](contracts/agent-operations.md) - [Optional MCP observations and privacy](contracts/mcp-observer.md) - [Project manifests and read-only inspection](contracts/projects.md) +- [External text project files and current milestone limits](contracts/project-files.md) - [Project command line](contracts/project-cli.md) ## Pipelines and execution diff --git a/docs/contracts/project-cli.md b/docs/contracts/project-cli.md index d788385b..4cb3d3f4 100644 --- a/docs/contracts/project-cli.md +++ b/docs/contracts/project-cli.md @@ -1,5 +1,10 @@ # Project execution CLI +The separate `file-*` commands operate on the explicit external text-project +mode. See [project files](project-files.md) for its canonical authority, owner +selection, checkpoint binding and current milestone limits. Registration `open` +below keeps its existing meaning. + `project_cli.py` uses the existing authenticated agent operations. Saved graphs, execution grants, Run custody, progress, cancellation and artifacts keep their existing owners and remain visible in `/workspaces` → Current local catalog → diff --git a/docs/contracts/project-files.md b/docs/contracts/project-files.md new file mode 100644 index 00000000..d78bfce8 --- /dev/null +++ b/docs/contracts/project-files.md @@ -0,0 +1,263 @@ +# External project files + +The [external project plan](../plans/external-project-save-load/plan.md) owns current +milestone acceptance. This contract covers the external-directory format and +its finite owner operations. Execution/recovery, independent Save As and +extraction have separate acceptance evidence; an unavailable capability must +refuse before effects. An in-progress draft is not final feature acceptance. +The [authority decision](../decisions/external-project-authority.md) explains the +canonical boundary. + +Start an explicit project-file service with a separate installation control +directory and one or more existing trusted root directories: + +```sh +python app.py --data /private/tuldok-control \ + --project-files-root research=/home/me/research +``` + +Use the service's normal local owner bootstrap, then visit `/project-files`. +New creates a previously absent relative directory under a configured alias. +Open chooses an existing project directory. Root aliases and credentials stay +local. Historical snapshots may retain inert root identities or runtime path +strings as evidence; they never establish a current filesystem binding. Tokens, +token hashes and live credential records are excluded. This mode cannot be +combined with legacy `--project-root`. + +## Canonical format + +`.tuldok/project.json` has format `tuldok_project_v1`. It is the sole current +checkpoint: project UUID, display name, membership roots, sorted record inventory, +request receipts and a SHA256 checkpoint. Its digest covers canonical bytes of +the document with `checkpoint` omitted. The store supports only its declared +format and family; malformed and future versions fail before projection writes. + +Each inventory entry identifies a rooted immutable file +`.tuldok/records///.json`, its format, namespace, +byte count and SHA256. Original `workbench_text_v1` preserves original source, current +Workbench record, every annotation revision, non-secret audit facts and operation +receipts. New operation receipts use `project_operation_v2`, whose identity +includes the current project UUID. Old v1 receipts remain exact historical +facts; a fork cannot reactivate their request authority. Canonical JSON is +UTF-8, sorted keys, two-space indentation, LF ending, +finite bounded values, and no duplicate keys. The namespace equals the logical +project UUID. Historical domain catalog IDs, record identities and hash inputs +remain exact within those envelopes. No generic recursive ID rewriting is permitted. + +The finite additional families cover Workbench data/source/annotation/response/ +preference histories and tombstones; saved selections/searches; frozen releases +and exposures; model/graph/recipe revisions and immutable materials; project and +runtime snapshots; Study/Trial/Run/candidate/lineage facts; and four producer +history families. Owner decoders validate their complete reference closure. +Unknown families, future formats and unclassified authority-bearing fields fail +before reconstruction. Older text is not silently upgraded on Open. + +New stages and fsyncs a complete project in a sibling directory, then publishes +it with atomic no-replace rename. An edit publishes and fsyncs new immutable +records before atomic replacement of the checkpoint. Unreferenced staged records +are not current authority. A failure with uncertain checkpoint publication fences +further use until explicit recovery. No cleanup deletes published history. + +Save verifies/fsyncs the full selected closure and returns a durability barrier. +A no-op Save leaves canonical bytes unchanged. Close drains synchronous project +requests, performs the barrier, releases the projection and file lock, and rotates +the binding. Active or unknown local Run, producer, runtime-copy and package +writers block Close, replacement and fork. A drained thread alone does not prove +that an external provider has ceased. Open/New replacement +validates an incoming inert projection before releasing healthy current custody. + +The generated `.gitignore` excludes `/artifacts/`, `/.tuldok-local/`, `/.venv/`, +`/__pycache__/`, `/PGDATA/` and `/output/`. New does not initialize Git. Authored +records and the checkpoint are suitable for Git; database binaries, model weights, +caches and large generated data stay outside tracked content. Intrinsic +`project_artifact_v1` records contain SHA256 and byte count; domain edges classify +role, media type, unique/regenerable preservation and required operations. +Present bytes live at `artifacts/sha256/` or an explicitly supplied local +resolution. Git transports canonical text and manifests; an independent payload +transfer supplies untracked bytes. Missing payloads remain visible. Consumers +verify exact captured bytes before use; they never regenerate historical evidence. + +## Consumers and outcomes + +The shared operation registry owns `project_files.*` contracts and exact contract +hashes. Browser, SDK, CLI and MCP negotiate those contracts. Lifecycle selection +requires current owner authority. Scoped agents may inspect data and submit draft +annotations; human review still requires the owner. Every project action is bound +to an opaque workspace generation and its expected canonical checkpoint. + +Use `ProjectFilesOwnerClient` only for the narrow owner project-file operations. +`ProjectFilesClient` wraps a negotiated client. CLI domain `file-*` commands take +`--arguments` JSON files; lifecycle commands use explicit flags and select +`--owner-project-files` with a +private owner configuration. Generic AgentClient remains agent-only. Keep request +keys across lost replies; the same key with changed input conflicts. Canonical +receipts survive SQL loss and return the originally committed domain result. +Current permissions and any stronger owner/disclosure requirement are checked +before replay. SQL failure after canonical publication returns a durable receipt +and fences the projection until explicit recovery. + +M2 domain operations return `{binding, project_id, checkpoint, value}`. Ordinary +list pages use offset/limit (default 40, maximum 40); heavy data-history pages +default to one and permit at most four. The original M1 text paging below stays +compatible. Historical Study metadata separates membership/reservation pages +from complete charge totals. Run summaries omit frozen source content. Exact +Run documents use digest-bound bounded byte segments, with current explicit +disclosure required before protected document bytes or evidence are read. +Imported disclosures remain historical observations and grant no current access. + +Explicit source registration captures the original manifest and establishes a +current installation-local rooted binding. Open restores no such binding. A +fresh explicit registration may renew a moved, unchanged manifest. Reference +inspection is bounded and reports observed SHA256 separately from a declared +expected digest. An unpinned reference is labeled unverified; a declared mismatch +is refused. Scientific import requires the selected exact observed bytes and the +existing scientific owner interprets them. Project scripts provide integrations; +opening or registering a manifest does not execute them. + +Owner-only artifact resolution selects an existing file relative to a configured +root. It verifies and durably copies the exact declared bytes. The receipt is a +past resolution observation; `artifact_status` reports current availability. +No network fetch, runtime installation, dependency activation or regeneration +is performed. For supported built-in graphs, explicit recipe preparation performs +a guarded current native probe before retaining the immutable material. Reading +retained native materials or historical evidence does not launch that probe. + +## Explicit execution and recovery + +Generic project scripts own domain integrations. Current external-script Run and +Study approval/admission use current local policy and grants. Admission and GO +intent are canonical before worker acknowledgement or GO. Measured terminal +evidence, spent budget and lineage remain project facts. Imported history supplies +no approval, admission epoch, cancellation right or resumable process. Producer +requests and candidate preparation follow the same intent-before-effects rule. + +For supported native graphs, the current owner explicitly calls +`project_files.native_preflight` with the saved graph ID and revision. A valid +result binds its validation token to that exact graph and measured runtime. +`native_submit` takes the graph ID, graph revision and token; admission rechecks +current inputs and authority before launching a native Run. `native_retry` +requires an actual current local Run and creates a new attempt. These actions +require current owner execute permission, the workspace binding, expected +checkpoint and a request key. `native_reconcile` instead selects an original +current local phase with the binding and expected checkpoint, requires current +owner cancel permission, and never sends GO. Its phase ID is available through +the current native-phase page of execution health v2; imported phases are history, +not current custody. + +Native execution uses the current operator's `TULDOK_TRAINING_PYTHON`, or the +service interpreter when unset. Its installed packages remain an operator trust +assumption; a runtime observation does not prove a complete dependency closure or +model quality. Open and ordinary inspection do not probe, install, train, resume +or activate a runtime. Retained validation, admission and phase facts do not grant +execution authority after a cold Open. + +An optional current operator binding selects runtime sources: + +```sh +python app.py --data /private/tuldok-control \ + --project-files-root research=/home/me/research \ + --project-runtime-source cpu=/private/cpu-runtime.json +``` + +The configuration uses the existing runtime-snapshot configuration schema. +`runtime_sources` lists aliases. Explicit Inspect measures that selected source; +Capture requires the exact inspected inventory and a stable request key, records +an intent, and copies bytes without launching them. Status distinguishes absent, +intent recorded, captured and abandoned. A cold Open has no configured alias or +cache merely because a historical descriptor contains a path. + +Recovery preserves the last acknowledged binding/checkpoint. Generic Run/producer +reconciliation requires exact original local custody and proof of drain. Runtime +retry and package reconciliation additionally require the exact original physical +writer journal and canonical intent; imported intent alone is insufficient. +Explicit abandonment records the owner reason with a separate key and retains +partial bytes and scientific facts. A completed package is reconciled rather than +silently rewritten. Recovery never sends GO or executes project code. + +The status endpoint is available before any project is open. It reports current +binding, local root aliases, project identity/checkpoint when selected, and +available actions. A stale binding or checkpoint is a conflict. Corrupt canonical +bytes cannot be blessed by Save. Missing unsupported families are never silently +omitted or auto-generated. + +Query returns summaries sorted by ID, with `limit` (1–100, default 40), exclusive +`after_id`, and `next_after_id`. Get returns the current full record and newest +history first, with `history_limit` (1–4, default 1), exclusive `before_revision`, +and `next_history_before_revision`. The same checkpoint binds all pages. Mutation +results include their committed revision and its first history page; replay never +mixes that historical result with newer revisions. The UI exposes explicit controls +to load more records and older history. + +Status may report `recovery_required`, its last acknowledged project identity, +and the selected root/directory to reopen. Explicit Open of the selected +directory is guarded by the current binding and last checkpoint. Ordinary +recovery validates canonical bytes, builds a fresh inert SQL projection and +rotates the binding. If original local custody remains, the named execution/file +recovery action must settle it first. That same-selection recovery preserves the +binding so its reply remains correlated with the caller. A failed recovery keeps the prior +custody and the recovery action available. A creation that committed but could not +be selected reports `selected:false` with the destination identity and request +receipt. Exact New retry reconciles that receipt without overwriting a different +existing destination. + +Errors distinguish invalid/unsupported format, stale selection, +writer contention, unavailable projection and unknown publication. A definite +canonical commit followed by projection failure returns its durable receipt. +Inspect/reconcile that receipt instead of minting another request key. Recovery +reopens the selected directory and reconstructs the projection without scripts. + +When changing Git revisions, close the project first. Open revalidates the complete +checkpoint; edits to canonical immutable files, unresolved conflict text and +inconsistent inventory fail. A changed checkout fences an already-open projection; +explicit reload reconstructs it without scripts. Git conflict resolution is manual. + +## Working files and independent Save As + +Adopt explicitly captures the exact UTF-8 bytes of a selected script or note, +including its history and measured hash. Merely editing a working file does not +replace an immutable scientific capture. Open never executes, repairs or restores +working files. Save As refuses an adopted file that is missing or changed until +its intended current bytes or an eligible note deletion are explicitly adopted. +A missing script remains protected: note-deletion adoption cannot remove its +captured script or make a missing script eligible for Save As. + +`project_files.file_adopt_deletion` is +`POST /api/workspace/project-files/files/adopt-deletion`. It requires current edit +permission and the usual `binding`, `expected_checkpoint` and `request_key`, plus +`path`, `expected_revision` and `expected_sha256`. The path must identify an +already adopted note that is safely absent. The revision and digest must match +its current positive captured revision; an existing file, changed canonical +revision/digest or unsafe path is refused. This action records absence; it never +deletes a filesystem entry. Inspection and Open never adopt a deletion. + +The first adopted deletion changes that note's document to `project_file_v2`. +All earlier positive revisions retain their exact original fields and text. A +closed deletion revision adds `deleted:true` and contains `revision`, +`sha256:null`, `bytes:0`, `text:null`, `adopted_at` and `actor_id`. This is distinct +from an empty positive file, whose text is `""` and whose SHA256 is the empty-byte +digest. `file_get` can select either revision; paged `file_history` returns +metadata, including the deletion marker, without embedding full texts. Current +inspection reports `deleted` only while that note remains safely absent, with +null expected/observed digests and zero expected bytes. + +Save As retains the complete note history and deletion marker and leaves its +current note path absent in the new destination. If a note is later recreated, +inspection reports its changed current bytes. Explicit `file_adopt` with the +tombstone's expected revision and the recreated file's measured SHA256 appends a +positive revision; it preserves the deletion and earlier text in history. Open +does not recreate the note or adopt its restoration. + +Save As publishes into a new absent directory, creates a new logical project and +editable identities, retains original hashed envelopes and records origin maps. +It copies required unique payloads and materializes captured working files only +into that new destination. Historical catalog IDs and material hashes remain +exact; unresolved bound drafts become explicit re-selection intents. Known +regenerable runtime binaries may remain missing with an explicit inventory; +missing unique evidence blocks a complete fork. Source bytes are unchanged. +Publication followed by selection failure reports `committed:true, selected:false`; +retry the exact request to select the already-published destination. + +The [extraction runbook](../runbooks/external-project-extraction.md) covers explicit +legacy selection, stopped backup, independent control credentials and the five +transfer operations. A whole-catalog backup is private recovery custody, not this +portable project format. diff --git a/docs/decisions/external-project-authority.md b/docs/decisions/external-project-authority.md new file mode 100644 index 00000000..b7b53ebd --- /dev/null +++ b/docs/decisions/external-project-authority.md @@ -0,0 +1,44 @@ +# External project authority + +The external project directory owns scientific records. PostgreSQL is a disposable +projection in the explicit project-file mode. The legacy catalog mode retains its +existing authority until an explicit future extraction; registration snapshots +are not Save/Save As. + +The implementation follows the [reviewed plan](../plans/external-project-save-load/plan.md), +initially committed as `2c9c9b098c9f1c4c2557c58818574ebdbc1e7d41`, based on +Tuldok `133d53c0e2985a6559ba9cebd7d4a9f530051f06`. Applicable standards were +inspected at Coding-Standards `5bce3110fc64d61ff8b4f79468184bd6295855d5`; +[the source report](../plans/external-project-save-load/reports/baseline-and-standards.md) +pins links and decisions. The implementation ledger records subsequent evidence. + +`ProjectStore` owns rooted filesystem access, immutable record publication and +the atomic checkpoint. `ProjectText` reuses Workbench's text policy inside an +outer SQL transaction, publishes the complete validated canonical revision before +SQL completion, and returns only after both boundaries complete. Failure after +canonical publication preserves the committed files and fences the projection +until reconstruction. SQL rollback cannot roll back canonical history. + +`ProjectWorkspace` owns selection, opaque request binding, local authorization, +projection lifetime and admission of supported operations. Each open uses a new +physical projection ID, while the canonical project UUID stays stable. A clone +cannot alias the originating installation's SQL schema. Access credentials and +current grants belong only to the installation control catalog. + +M1 deliberately instantiates no Dataset, Run, Job, generation or dependency +installer owner in project mode. Its only scientific family is authored text +with annotation history. All unfinished domain routes are fenced before dispatch +and unavailable in capabilities. This restriction is temporary under M2/M3 of +the plan, not acceptance of the full requested save/load capability. + +The supported publication platform is a local Linux/POSIX filesystem honoring +file and directory fsync, anchored no-follow access, advisory exclusion, and +atomic no-replace rename. The representative qualification used overlayfs and +PostgreSQL 17. It does not establish power-loss survival on storage that ignores +fsync, or support for network filesystems or Windows. + +Open validates canonical bytes and rebuilds inert text projections. It never +imports project Python, executes scripts, installs dependencies, downloads model +weights or regenerates data. Domain integrations remain project scripts. Later +families must preserve historical IDs and hash inputs, separate historical proof +from current permission, and declare unique/generated artifact dependencies. diff --git a/docs/plans/external-project-save-load/execution-ledger.md b/docs/plans/external-project-save-load/execution-ledger.md new file mode 100644 index 00000000..9c305b9a --- /dev/null +++ b/docs/plans/external-project-save-load/execution-ledger.md @@ -0,0 +1,334 @@ +# Planning execution ledger + +## 2026-10-22 — Source verification and implementation proposal + +- Scope: documentation only under `docs/plans/external-project-save-load/`. No product implementation, application/catalog execution, local lab inspection, user-data migration, push, PR or merge. +- Environment ready; original checkout clean at Tuldok `133d53c0e2985a6559ba9cebd7d4a9f530051f06`. Remote main read and fetch confirmed that exact baseline. Corrected a stale local tracking ref before authoring; isolated planning branch/worktree now has the verified base. +- Read current Coding-Standards `5bce3110fc64d61ff8b4f79468184bd6295855d5`, applicable Core/Router/workflows/topics/profiles, and current plan template. No applicable AGENTS or checkout skills were present. No Standards Engine application MCP is available; direct guidance is not claimed as automated certification. +- Verified registration/root identity, schema10 ownership, material/catalog hashing, Run proof/recovery dependencies, cross-Study lineage and whole-catalog backup limits. [Baseline report](reports/baseline-and-standards.md) pins sources and applied decisions. +- Wrote one canonical implementation proposal with five production milestones and A1–A9/T1–T9 prospective claims/procedures. It preserves completed D1–D4 ownership, generic script integrations, inert Open, canonical text/SQL projection split and independently scoped history/fork identities. +- Independent complete-plan review and documentation checks are recorded in [review](reports/independent-review.md) and [verification](reports/verification.md). Product acceptance stays pending; review/record additions do not imply runtime validation. +- The independent reviewer requested three revisions: Close-gated/generation-bound project replacement; explicit adoption of current editable scripts/notes without rehashing immutable evidence; and concrete dispositions for Preferences, candidate preparation, runtime snapshots and extension closure. The author resolved them together, added their acceptance cases and finite write sets, and requested targeted re-review of the semantic changes. The original complete-review subject and final subject identities are retained in the review report. The structural check also required plain architecture-probe prefixes instead of bold prefixes; only markup changed for that repair. +- Targeted re-review cleared those findings. A remaining nonblocking bootstrap detail was made explicit as authenticated `no_project_open` controller state, and the reviewer confirmed its closure against final plan SHA256 `d6d3832a494a44aebae15bed20748a6d5b90dfa98919d315a179abae02d60b4d`. Final independent review: clear, no unresolved findings. Documentation structure and links passed; exact scope and commands are in the verification record. No product acceptance claim changed status. + +## 2026-10-22 — Authorized M1 implementation and focused qualification + +- The user explicitly authorized implementation and GPT-6.1 Sol High workers, + including a dedicated feature branch/draft PR. The lead rechecked remote main + at `133d53c0e2985a6559ba9cebd7d4a9f530051f06` and standards main at + `5bce3110fc64d61ff8b4f79468184bd6295855d5`; both remain the reviewed baselines. + Implementation worktree `/workspace/Tuldok-project-save-load-impl`, branch + `feat/external-project-save-load-20261022`, starts from retained plan commit + `2c9c9b098c9f1c4c2557c58818574ebdbc1e7d41`. Original checkout and planning + branch remain untouched. No retired lead, unrelated qualification branch, + README PR or local surrogate lab was accessed. +- Disjoint workers owned store, text domain, public adapters/UI, and real + environment/HTTP/browser tests; the lead owned workspace composition, + metadata/app integration and documentation. A separate reviewer inspected + actual code and standards. A follow-up workspace assignment transferred its + exclusive write ownership to the store worker to repair review findings. +- M1 implements an opt-in external text mode with canonical checkpoint/record + publication, raw source and complete annotation history, fresh physical SQL + projections, New/Open/Save/Close, scoped draft editing and owner review. Legacy + domain routes and capabilities are fenced before effects. No non-text family, + execution, Save As or extraction is claimed implemented. +- Qualified real PostgreSQL 17.11, Python 3.12.14, Node 24.19.0 and Chromium + 151.0.7922.173 on Linux overlayfs using disposable synthetic fixtures. The cold + role/database is distinct and cannot CONNECT to the source database. Local + lock/fsync/rename observations do not claim destructive power-loss testing. +- Store checks covered cold copy/move, strict malformed/future formats, + no-follow/hardlink/special-file rejection, separate-process writer exclusion, + exact retry, staged New/no-clobber publication, real process kills around the + checkpoint boundary, fsync failure/unknown outcome and inert script sentinels. + The existing 27-test selection and added ignore-file test passed on the + qualified filesystem; no unchanged full-suite repeat was required. +- Text adapter's final 18-test real PostgreSQL run passed, including genuine + deferred SQL COMMIT rejection after canonical publication, projection rebuild, + original revision replay, exact committed receipt, ordinary agent draft/read + success, owner-only review refusal and current revocation serialization. + The affected legacy metadata transaction selection (15 tests) and three + Workbench regressions passed. These establish their named boundaries rather + than final feature acceptance. +- Independent review requested explicit recoverable unknown outcomes, bounded + query/history delivery, original-revision replay history, created-but-unselected + New receipts, rejection of obsolete lifecycle replays, truthful legacy + capabilities and real-server SDK bootstrap. The coordinated fixes include + explicit current-project recovery and fsync barriers, summary/history pages, + exact typed transport receipts and host-only bootstrap context. Nine real-PG + workspace checks passed, then two targeted persistent-fsync checks passed + after the recovery barrier correction. Final shared review and changed-subject + HTTP/browser checks are the remaining M1 gate at this checkpoint. +- Earlier real HTTP checks passed the four original methods across focused + corrections. The stable browser subject proved keyboard authoring, Git clone, + a fresh process/control catalog on the cold database, identical text/IDs/history, + unchanged no-op Save bytes and no script execution. Initial failed campaigns + exposed missing exact contract headers, incorrect static asset URLs, one + misplaced guard and test-only quoting/timing errors; each was corrected before + a scoped rerun. Those results are retained as dated evidence, not substituted + for the subsequently changed pagination/recovery subject. +- Read-only M2 preparation enumerated all 43 schema tables and managed payload + owners. It found routine owner boundaries requiring shared retained-proof + validators and canonical artifact custody rather than a generic SQL exporter. + In particular, current Study Run readers require live grants, and current + artifact rollback callbacks may delete published files. No M2 implementation, + user migration or later-milestone acceptance is claimed by that inventory. +- Final targeted source re-review cleared all M1 findings. Changed HTTP selected + four methods: three passed, and the real-adapter method initially failed its + 20-second MCP initialization deadline. Diagnosis measured actual CPU-bound + startup and identified repeated registry validation in bootstrap and serve. + Removing only the redundant serve check was independently cleared; the + startup-order/failure test passed. Only that failed adapter method reran and + passed, with actual MCP initialization 10.955 seconds under the same deadline. + The revised browser subject passed historical-note pagination on a real cold + Git clone. Final client selection (16), affected Agent API selection (17), and + Node controller/syntax/diff checks passed. [M1 evidence](reports/m1-implementation.md) + and its concise QA receipt distinguish failed, repaired, reused and unrun scope. +- The lead accepted M1's complete external text vertical and admitted M2 at the + owner boundaries identified by inspection. Finite data/material/history codecs, + intrinsic artifact declarations and one shared checkpoint transaction are + agreed. Additional source filenames retain those concrete concerns; extracting + image policy and retained evidence seams avoids a second interpreter. No + later milestone is claimed complete and no product branch is merge-ready. + +## 2026-10-22 — M2 integration candidate and remaining gates + +- Draft implementation [PR #119](https://github.com/MrScripty/Tuldok/pull/119) + was confirmed after the user's explicit retry approval. The lead first + checked that no PR existed, then retried the denied creation once. Its head + remains M1 `af276565fae9c08c1084d37d8937ba5d97599f17`; captured-CPU, + inert-extension and workspace-UI CI succeeded, while dataset lifecycle was + still running at the observation. No duplicate CI run or merge was started. +- M2 fixed owners now compose data and histories, immutable material/runtime + declarations, Study/Trial/Run/lineage facts, producer history, exact artifacts, + explicit current source bindings, and 84 finite public operations. M1 routes + delegate to the complete data owner. PostgreSQL remains disposable; Open + constructs no Dataset, Jobs or live Runs owner and restores no credentials, + grants, admissions, or current external-root bindings. +- Focused real PostgreSQL evidence includes Data 16 cases after acquisition + classification, material 32 cases plus targeted receipt/audit/probe guards, + history 17 targeted cases, producer 12 new cases and five affected owner + regressions, source lifecycle/import seven plus one bounded-read case, + complete projection three, rooted artifact supply one, and M1 workspace + cutover 11. Counts describe distinct scoped campaigns; their fixture limits + and targeted corrections are retained in scratch logs, not represented as + one full-feature acceptance run. Full M2 HTTP/browser acceptance is pending. +- Independent source review collected three justified pre-enable findings: + built-in preparation could execute a probe; raw historical Run metadata + exposed protected frozen inputs; historical audit/operation/acquisition + variants admitted unclassified fields. Owners added a pre-probe gate, a + shared safe public projection plus current-disclosure-gated exact document + segments, and finite semantic classification. Targeted re-review is pending. + Exact producer provenance containing historically hash-bound inline request + values is preserved; no recursive stripping or invented original wire bytes + is used to make it appear portable. +- Product source was frozen for real owner and interface gates at fingerprint + `27a3117411fd21f016a4305fdafde893deabb9165c623814deec628fc40a469a` + (226 product files, baseline `af276565`), recorded at + `/workspace/scratch/tuldok-save-load-qa/m2-product-freeze.json`. A prior broad + historical campaign ran while host source was changing and failed current + fingerprint checks; it is not acceptance evidence and was not repeated. + The frozen seven-case selection passed six; one test incorrectly assumed + a large historical document fit a single bounded segment. Only that test's + segment-assembly fixture is being corrected and rerun, with source unchanged. +- Targeted final repairs and review cleared all justified findings. Final product + tested manifest file SHA256 is + `7a8969d58594b4dc6f9412c5a31c77b2a13f59b807ad013084bfc351cdfde8f4`, + with 226 source files. Required-real final gates passed: fresh external graph / + actual nonexecuting material preparation / Study+Trial / cold reconstruction + over HTTP (one selected/run, no skip), and focused cold-history Chromium at + 390/1440. Earlier actual browser functional+cold gates passed while retaining + their responsive failure; the measured CSS repair has its own passing gate. + Three actual public HTTP cases including SDK/CLI/MCP and current authority + passed, as did the corrected composed-history case and new guards. Detailed + scoped results, failures, subject hashes and review are retained in + [M2 implementation evidence](reports/m2-implementation.md). M2 was accepted + only after these gates; this does not accept execution, Save As or extraction. + A staged whitespace check subsequently removed one redundant EOF newline in + `project_data.py`, with Python AST parity verified. Both tested and normalized + final source manifests are retained; the final file SHA256 is + `ef2dc94d76d73fc11a45bcb06cf32dc5399d84370ef2a94dfbf6db800cfa9937`. + Runtime evidence remains bound to its tested subject; no identical campaign + was repeated for this formatting-only delta. +- CI now provisions the required isolated cold role/database and registers the + actual non-skipped MCP and Chromium gates. Independent workflow review is + clear. It has not yet run on the M2 commit; the PR synchronize event, rather + than the branch push filter alone, triggers it. Existing M1 full-suite CI + remains running without a failed step. Its last successful main baseline + needed about 53 minutes for the same full-suite step, so elapsed time alone + is not treated as a hang and no duplicate or cancellation was requested. +- M3 execution/custody integration, M4 complete fork/relocation/Git workflow, + and M5 extraction remain unfinished. Pure fork storage/data constructors + have focused evidence but are not a completed public lifecycle. The local + surrogate lab remains uninspected and unmigrated. There is no environment + or missing-user-decision blocker. + +## 2026-10-22 — Unaccepted integration recovery checkpoint + +- A temporary executor disconnect was verified through failed process creation + and a pending patch read. Actual subsequent shell and repository status reads + succeeded; existing work was intact. Workers checked uncertain original test + processes before retrying. The native candidate recovery case had completed + successfully, so no duplicate was launched. +- The lead is preserving the current disjoint M3–M5 product, tests and evidence + in a local WIP commit. This is recoverable integration work, not milestone + acceptance or a request to merge. The remote draft remains at accepted M2 + `c22e9d02c87938514a7a1c9b3b0532158008ca0d` until a later intentional push. +- [M3](reports/m3-implementation.md), [M4](reports/m4-implementation.md) and + [M5](reports/m5-implementation.md) evidence distinguish actual passes, failed + cases, controlled fixtures and unrun gates. [M2 CI diagnosis](reports/m2-ci-repair.md) + retains the terminal failure and targeted browser-fixture repair; no CI rerun + or duplicate full-suite campaign was started. +- The original main/standards revisions remain unchanged. No user data or lab + was inspected or migrated, no Coding-Standards file changed, and nothing was + merged or deleted. Current phase/next action remain solely in the plan. + +## Retained resources + +### Local recovery qualification checkpoint + +After the executor reconnected, actual shell and repository reads confirmed the +worktree and WIP commit `ee43d849640d4d15977285a4e691549c3a052a2c` survived. +Main was rechecked remotely as `133d53c0e2985a6559ba9cebd7d4a9f530051f06` and +Coding-Standards as `5bce3110fc64d61ff8b4f79468184bd6295855d5`; both are unchanged. +The feature remote remains at M2 `c22e9d02c87938514a7a1c9b3b0532158008ca0d`. + +The following local checkpoint preserves the subsequently qualified external +runtime recovery, immutable disclosure snapshots, exact native-candidate wrapper +reconciliation, transitive extraction closure and real public/browser fixtures. +Detailed performed/failed/unrun distinctions remain in M3–M5 reports. Independent +review cleared those specific repairs and workflow isolation. No final frozen-head +review or CI acceptance is inferred, and no duplicate full-suite campaign ran. + +Review also identified unfinished built-in material probes and native Runs as a +real full-scope gap. Their early fences remain. The lead admitted a finite native +completion slice under the existing authorization, with disjoint process, +execution/custody, preparation and public adapter owners. The native foundation +is excluded from this checkpoint until its own tests/review; there is no claim +that a new flag makes the external-script subset satisfy the full plan. No lab +was inspected, no user migration ran and no publication/merge was performed at +this checkpoint. + +Planning worktree `/workspace/Tuldok-project-save-load-plan` and private branch `plan/external-project-save-load-20261022` are **retained-protected** for the user's review. The user owns integration/disposition; no synchronization or cleanup occurs without follow-up. The local final commit is the retained branch head (reported in the final response and resolvable with `git rev-parse plan/external-project-save-load-20261022`); its full SHA is not embedded into its own commit contents. Commit disposition is `retained`, reachable from this named local branch. The original checkout is outside this task's cleanup scope. `/workspace/Coding-Standards-readonly` remains an unchanged reference checkout, not an application worktree or deliverable. + + +## 2026-10-22 — Complete native source and final integration qualification + +The integration lead retained `b252fb4a2a545a6968ee1eae981fc45a19ad43f0` before +completing native subprocess/preparation/execution, note tombstones and original +source extraction. Complete immutable-subject review found three medium issues; +all have exact-source reviewed repairs and focused deciding evidence. Actual +CPU science, independent fork/cold reconstruction and real public/browser +controls are recorded in [native completion](reports/m3-native-completion.md) +and [M4](reports/m4-implementation.md). Earlier ERROR/FAIL results remain distinct +from corrected no-fit tails against the same measured Runs. + +The retained second Study Run completed after its test's arbitrary 180-second +wait. Read-only proof verified both Runs and 79 artifacts; original-host final +SQL counts were not reached. The finite fixture deadline now derives from the +existing 990-second native budget plus 120 seconds of bounded catalog +publication/observation. No repeated fit or full-suite campaign was run locally. +CI preflight corrected explicit interpreter and QA-root portability and proved +nonzero/no-skip selection without executing another campaign. + +Main and Coding-Standards were rechecked unchanged at +`133d53c0e2985a6559ba9cebd7d4a9f530051f06` and +`5bce3110fc64d61ff8b4f79468184bd6295855d5`. The reviewed plan commit +`2c9c9b098c9f1c4c2557c58818574ebdbc1e7d41` and protected worktree remain intact. +M2 CI's actual failed browser step and repaired fixture remain recorded; no final +CI success is inferred. Local lab/user-data migration, Standards edits, unrelated +branches, merge and branch cleanup remain outside this checkpoint's actions. + +## 2026-10-23 — Complete candidate CI and bounded fixture repair + +Published candidate `5704965d1a441946aacf3548e0b9f57dc4312d35` received one +CI campaign. Lifecycle `39874125909` passed native domain 3/3, native public +1/1 and note-deletion public 1/1 without skips, then failed full discovery +(2,332 tests, six failures/two errors). Later browser checks were skipped. +The other three workflows passed. Main and Standards remain at their recorded +revisions; no merge or branch deletion occurred. + +Independent triage identified stale test fixtures/expectations. Disjoint owners +repair only affected tests and their HTTP helper, with a read-only reviewer; +failed attempts, exact subjects and targeted outcomes are retained in +[CI diagnosis](reports/ci-5704965-repair.md). Production code and policies remain +unchanged. No identical broad local campaign, repeated fit or same-head rerun +is used to hide the failed result. Complete acceptance remains pending. + +The artifact ZIP's first local retrieval was denied by proxy HTTP 403 and +stopped without retry; its manifest remains uninspected. A later attempt to +update only PR 119's CI-status paragraph was separately rejected by automatic +approval review applying the historical planning-only restriction. That write +stopped and new approval was requested; it is not retried through another route. +Read-only diagnosis and local fixture repair continued independently. + +All eight failed cases subsequently acquired targeted passing evidence. The +HTTP-only corrected fixture passed after two retained fixture failures, with +the timed-out attempt's actual Open outcome explicitly unobserved. Independent +review cleared the exact eight-file repair subject and preserved all authority, +legacy and crash assertions. One changed candidate is prepared for CI; no +passing local full suite or final browser acceptance is claimed. + +## 2026-10-23 — Changed-candidate CI isolates the remaining Study wait + +Reviewed repair commit `591eb24de3ae9ccd219d0fc12bda6c34663a13d5` was pushed +once. Its lifecycle `39884356869` ran 2,332 tests in 3,288.197 seconds with one +failure, zero errors and 107 optional/intentional skips. All eight earlier +failures cleared. Native 3/3, native public 1/1 and note-deletion public 1/1 +passed without skips; the other three workflows passed. Later browsers were +skipped and remain unaccepted. [Study wait diagnosis](reports/ci-591eb24-study-wait.md) +preserves the exact checkout/tree and last observed Run, whose final outcome +and later accounting were not observed at the failing assertion. + +The remaining cold-Study case used a 30-second fixture wait below its existing +97-second admission. Independent source review cleared a case-only finite +217-second bound, including terminal publication/observation allowance. Only +that failed real-PG case is selected locally; shared timeouts, production, +historical charges and all final assertions stay intact. No broad local suite, +native fit or same-head CI rerun is requested. + +The earlier narrow PR-status correction succeeded at 01:39 after the parent +supplied newer explicit current-implementation authority. After the next +terminal CI failure, a new attempt to correct that paragraph was again rejected +by automatic approval review applying the historical planning-only restriction. +That write stopped; approval for one bounded retry was requested. Local repair +continues independently, with no alternate publication route or bypass. + +The exact cold-Study case then passed in 121.259 seconds: both generic Runs +completed and all original settlement, spend, lineage, fresh authority and cold +historical denial assertions passed. All 122 source/fixture hashes stayed +unchanged. Independent released-source/evidence review is clear. The former CI +Run's final outcome remains unobserved; this scoped pass is not final CI or +browser acceptance. The next candidate changes one fixture and its plan/evidence +records, with no product or workflow change. + + +## 2026-10-23 — Full discovery and project browsers pass; handler fixtures repaired + +Candidate `3ac3351a741cf5e6ef4712e3577f3fc0d3a3f3e5` lifecycle +`39893561852` passed full discovery: 2,332 cases, zero failures/errors, +107 optional/intentional skips. Native domain 3/3, native public 1/1 and +note-deletion public 1/1 passed without skips, as did all six project browsers +and observation browser 72. The three other workflows passed. Step 152 then +failed before classification HTTP readiness because the instrumented fixture +handler omitted the production `transfer_controller` keyword. Later steps +153–180 were skipped; complete acceptance remains pending. + +The sole other current handler replacement, the later Pumas fixture, had the +same incompatibility in source; CI had not reached it. Both wrappers now mirror +and forward the explicit keyword without changing instrumentation or assertions. +Independent source review cleared both files before one complete actual Chromium +run of each. Both passed once, with no application runtime exceptions and all +912 recorded source/test hashes stable. These synthetic-HTTP checks required no +native gateway/model fit. [Handler fixture evidence](reports/ci-3ac3351-handler-fixtures.md) +retains exact checkout, counts, failure, hashes and skipped gates. No product, +workflow, timeout or test-selection change was made; no same-head CI rerun or +broad local campaign was used. + +The previously denied narrow PR-status paragraph correction was explicitly +approved by the user at 04:33 UTC. After reading current PR/CI state, the lead +retried that known-unexecuted write once at 04:35; it succeeded, and a separate +read verified its body with all other text unchanged. The PR remained draft and +unmerged. This approval did not waive acceptance gates. The original denied +artifact retrieval remains stopped; newer uploaded manifests remain uninspected. +Main and Coding-Standards were rechecked unchanged. No user lab or unrelated +branch was accessed or changed. diff --git a/docs/plans/external-project-save-load/issues.md b/docs/plans/external-project-save-load/issues.md new file mode 100644 index 00000000..65480f89 --- /dev/null +++ b/docs/plans/external-project-save-load/issues.md @@ -0,0 +1,45 @@ +# Findings and dispositions + +The [plan](plan.md) owns current blockers/status. This file records bounded findings; none authorizes user-data access or migration. + +| ID | Finding / consequence | Owner / disposition | Blocking condition / evidence needed | +| --- | --- | --- | --- | +| I1 | Current Project registration has no authoritative Study/Run/data ownership. | Project membership/extraction owner; explicit selected roots and transitive closure in M2/M5. | Blocks a particular extraction until ambiguous membership is resolved; does not block new-project M1. | +| I2 | Catalog ID participates in material hashes and same-catalog lineage. | Model/Pipeline/lineage owners; stable project logical identity, separate physical DB, new fork active IDs with inert origin history. | M2/M4 must prove original hashes unchanged and independent fork material newly prepared. No blind rewrite/copy shortcut. | +| I3 | Existing Run validation depends on historical reservations/approval/consumption associations; restoring live tables would revive authority. | Runs/Access + historical reader; retain typed evidence facts, exclude credential/control capability. | M2 no-credential/no-grant tests and intact historical closure required. Inseparably secret-bearing proof blocks that transfer pending explicit decision. | +| I4 | Startup constructors reconcile mutable jobs/Runs. | App composition/Runs; inert open and separate local custody owner in M1/M3. | Imported history must never reach old PID/GO/retry/recovery paths. | +| I5 | Root alias + device/inode is physical binding; moves/checkouts are not new scientific identities. | Project/metadata owner; explicit rebind and stale projection handling, M4. | Real moved-root and same-UUID clone isolation proof. | +| I6 | Missing unique bytes cannot be recreated by a manifest or by a new producer measurement. | Artifact/transfer owner; preserve, copy and verify unique data; mark missing/incomplete. | Blocks requested complete-copy/extraction; metadata inspection and explicitly incomplete transfer remain useful. | +| I7 | Active/unknown descendants can continue changing files after supervisor exit. | Existing Run/job lifecycle owners; close admission and prove cessation/fencing in M3. | Blocks fork/extract/Close while unknown, never guessed safe from stale lock age. | +| I8 | POSIX local publication cannot imply distributed/network/Windows durability. | Persistence owner; disposable capability observation before M1 expansion. | An unsupported target refuses mutation. Other writer platforms remain deferred until a concrete requirement and real evidence. | +| I9 | Existing backup includes Access/runtime authority and only managed files. | Backup retains disaster-recovery ownership; portable extraction is separate. | No blocker to planning; future user cutover requires separately preserved external roots. | +| I10 | Legacy catalog mode remains database-authoritative until explicitly extracted. | App integration owner; separate advertised modes with no dual writes. | Must not advertise rebuildable project guarantees for old mixed catalogs. Rollback preserves post-cutover writes for reconciliation. | +| R1 | Independent review: Open/New/reload replacement did not explicitly apply Close/drain or bind old requests to their original Dataset. | Resolved in the proposal: process-wide workspace controller, opaque binding/generation on every request, Close-gated atomic replacement and delayed/active-job T6/T8 cases. | Targeted reviewer confirmation recorded in review report. | +| R2 | Independent review: externally edited current scripts/notes lacked an explicit checkpoint path. | Resolved in the proposal: reviewed `adopt_working_files`, inert adoption context, prior captured revision preservation and immutable-file rehash prohibition, with positive/negative T6 cases. | Targeted reviewer confirmation recorded in review report. | +| R3 | Independent review: preferences, candidate preparation, runtime snapshots and extension closure were missing concrete owner/write-set dispositions. | Resolved in the proposal: visible-family table, finite milestone path groups, direct writer integration/fencing and richer T2/M3 fixtures. | Targeted reviewer confirmation recorded in review report. | +| R4 | Targeted review: initial New/Open needs authentication without an existing project binding. | Resolved in the proposal: explicit authenticated `no_project_open` state and generation, local root-selection authority, atomic transition and preselection unavailability for project-specific operations. | Reviewer confirmed closure against final material hash; no unresolved review finding remains. | +| M1-R1 | Unknown canonical publication could wedge every public recovery action; SQL failure dropped committed identity. | Resolved: explicit guarded current-project recovery, reread/fsync before selection and closed durable receipts through every consumer. | Real filesystem faults, actual PostgreSQL COMMIT failure, real HTTP recovery and independent source re-review passed. | +| M1-R2 | Unbounded query/history could commit valid work then make it unreadable; original-revision retry mixed newer history. | Resolved: bounded summary/keyset pages and newest-first history pages correlated to the returned revision; UTF-8 request sizing. | Maximum Unicode contracts, real historical-note UI pagination and retry tests passed. | +| M1-R3 | Published New without selection and obsolete lifecycle replay had ambiguous outcomes. | Resolved: created-unselected receipt, exact durable creation reconciliation and current-state checks on cached lifecycle results. | Real-PG controller/fsync cases and independent re-review passed. | +| M1-R4 | Legacy capability advertisement and project SDK bootstrap disagreed with actual mode. | Resolved: unavailable legacy project-file operations and safe host identity context without domain construction. | Real owner/agent SDK, owner CLI, official MCP to actual HTTP passed. | +| M1-R5 | Production MCP repeated bootstrap's full registry validation and exceeded the initial acceptance deadline. | Resolved: removed only redundant serve validation; main admission and per-call validation retained. | Independent consequence review, startup-order test, measured initialization 10.955s under unchanged 20s and actual adapter acceptance passed. | +| M5-R1 | Extraction read the Run-input kind from the nested source instead of the input item, omitting automatic upstream closure. | Resolved in source: queue exact same-origin producer Runs; foreign origin is an explicit blocker. | Two actual PostgreSQL cases passed: three-Run transitive closure with original bytes and foreign-origin refusal; no scientific execution. | +| M3-R1 | A disclosure receipt bound only the latest Run payload set and could become invalid after legitimate output growth. | Additive immutable canonical Run-observation text record and v2 observation fact; old v1 semantics retained. | Focused real PostgreSQL growth/replacement, malformed snapshot and cold no-grant checks passed; targeted independent source review is clear. | +| M3-R2 | Native candidate science could commit before the public wrapper receipt; recovery offered abandonment despite complete retained preparation. | Recover only the missing acknowledgement from exact native Study operation proof under original writer custody. | Actual PostgreSQL missing-wrapper recovery passed in 107.490s without another native writer; targeted independent source review is clear. | +| M3-R3 | Built-in material probes and new native Runs remain unavailable in project mode. | Resolved in source: finite native admission/preparation, subprocess custody, retained proof and explicit recovery. | Actual CPU Run/fork/cold and Study proof, public recovery and source review are recorded in native completion; full original cases and final SQL assertions remain for exact-head CI. | +| M3-R4 | Native preflight consumers accepted contradictory success or coerced numeric hashes, falsely enabling Submit. | Resolved: exact types and success/failure relationships, with prior proof cleared before a new preflight. | Focused malformed-output checks passed and targeted source re-review is clear; no execution bypass was demonstrated. | +| M3-R5 | Native validation receipt assumed classification release fields, rejecting legitimate whole-asset integrity results after validation. | Existing release-validation owner exposes finite summary/vocabulary DTO decoders; original archive/science validators stay authoritative. | Actual mesh freeze/validate/cold/no-child and actual classification-export gates passed; targeted independent source review is clear. | +| M3-R6 | Native execution writes the existing scientific `execution_test_exposure` observation, but the portable Run allowlist omits it. | Run owner preserves the field and delegates its finite observation validation to the Pipeline owner. | Pure tamper control passed and targeted source review is clear; actual scientific/canonical Run and retained Study science have scoped evidence; final full-case CI remains. | +| M3-R7 | Generic current/original reconciliation assumes external Run custody fields and can raise an untyped error for native Runs. | Finite early native-custody handling precedes reconstruction. Private v3 admission custody covers the pre-phase window; no imported authority or invented terminal science. | Four no-ML controls and two actual admission SQL-ACK-loss cases passed; targeted source review is clear. Actual Workspace/HTTP and SDK/CLI/MCP/Chromium recovery controls passed on recorded immutable subjects. | +| M3-R8 | Native measured output capture checked leaf files but did not anchor the output directory against symlink substitution. | Reads through qualified stage/output directory descriptors and rechecks bindings before retaining measured provenance. | Focused path/refusal controls passed and targeted source review is clear; actual native science and copy/cold paths passed within the recorded composed proof; final full-case CI remains. | +| M3-R10 | Clearing known-not-launched proof before foundation validation made a known config/hash refusal permanently unknown. | Private callback clears only that volatile marker immediately before actual Popen, after all checks/setup; later failures stay unknown. | Four new boundary controls passed and exact-source independent rereview is clear. No baseline campaign was repeated. | +| M3-R11 | Retained native phase jobs are individually well formed but their model-aware scientific context is not fully correlated with the associated Run. | Exact model-aware jobs now match the existing scientific owner's Run-derived job/context, keeping observational paths separate and original bytes unchanged. | One pure case with 16 internally rehashed forgeries passed; targeted exact-source rereview is clear. Running real gates retain their original subject. | +| M3-R12 | An exception after entering spawn but before a Handle returns is exposed as ordinary failure while the Session remains dispatchable. | Uncertain current spawn outcomes now fence dispatch with typed uncertainty; known-before-spawn refusal remains separate. | One integrated HTTP/PG control passed and targeted exact-source rereview is clear. No actual ML claim is inferred from this fault control. | +| M4-R2 | Intentional external note deletion cannot be adopted, despite the selected plan's retained-tombstone promise. | Add explicit CAS-bound note deletion adoption, preserve prior text and honor the tombstone in inspection, restore and Save As. | Full frozen review found this selected capability missing; backend 10-case PostgreSQL, actual SDK/CLI/MCP and Chromium/cold-fork gates passed; targeted full public/backend rereview is clear. | +| M4-R3 | Native Save As validates fork draft intents as if they were complete graphs, raising `schema_version` KeyError. | Classify finite fork intents before complete-graph execution hashing while requiring the original complete graph's observation closure. | The actual native Run trained/verified before this error; scoped no-fit regression, exact-source rereview and actual native independent Save As/cold role passed. The separately corrected metadata-only tail passed against the same Run; the initial whole-case error stays retained. | +| M4-R1 | Native unique payload bytes can share a SHA with regenerable runtime files, allowing the weaker disposition to win. | Fixed finite native phase payload ownership promotes every native evidence edge to unique. | New empty-byte collision/declaration control passed and targeted independent rereview is clear. Actual native Run Save As/cold qualification passed within the recorded composed proof; final original-case CI remains. | +| M3-R9 | The private native phase bound of 16 is smaller than the existing concurrent Run owner can legitimately retain. | Resolved: finite capacity derives from three native phases per supported concurrent attempt plus one foreground probe; admission refuses before preparation at capacity. | Three focused controls passed in 0.781 seconds; targeted review is clear. No parallel training campaign was required. | +| M5-R2 | Legacy native Run extraction omits original staged adapter source bytes when no model/recipe closure happens to retain them. | Collector reads original staged captures against retained definition hashes or reports incomplete source; current host code is not a substitute. | Five distinct actual PostgreSQL cases passed after two fixture repairs; targeted independent source review is clear. One earlier lost-output runner remains unverified with no surviving matching process. | +| P-R1 | Selecting a project that encloses the control directory places private original-host operational state inside its Git tree. | Shared anchored directory-FD ancestry guard before New/Open/Save As and extraction publication; aliases parenting disjoint siblings remain valid. | Pure guard, actual PostgreSQL transfer and Workspace aggregate checks passed; targeted source review is clear. No automatic credential-file leak was found. | + +Independent review findings and exact dispositions are retained in [the planning review](reports/independent-review.md) and [implementation review](reports/implementation-review.md). Future discoveries expand the bounded owner/consumer family only when they reveal a new authority, reachable failure or persistence promise; no unrelated D1 replanning or evidence-volume target is created. diff --git a/docs/plans/external-project-save-load/plan.md b/docs/plans/external-project-save-load/plan.md new file mode 100644 index 00000000..1ce23e84 --- /dev/null +++ b/docs/plans/external-project-save-load/plan.md @@ -0,0 +1,454 @@ +# External project save and load + +**Plan status:** `Verifying` + +**Current phase:** Final integration verification — native execution, independent forks and extraction. + +**Next slice:** Publish the reviewed two-wrapper browser fixture repair for one changed-candidate CI campaign. Candidate `3ac3351a741cf5e6ef4712e3577f3fc0d3a3f3e5` passed full discovery (2,332 cases, 107 optional/intentional skips), all required native/public gates without skips, six project browsers and the earlier observation browser, then failed an old fixture handler signature before classification HTTP readiness. The identical later Pumas wrapper was found before its gate ran. Both repaired complete browser procedures passed once against disposable PostgreSQL/Chromium; independent source review is clear. [Handler fixture evidence](reports/ci-3ac3351-handler-fixtures.md) retains the exact failure, scoped passes and later skipped gates; [Study wait diagnosis](reports/ci-591eb24-study-wait.md), [earlier repairs](reports/ci-5704965-repair.md) and [implementation review](reports/implementation-review.md) preserve prior outcomes. Production and workflows are unchanged from `5704965`. Acceptance remains pending until required exact-candidate checks succeed; terminal CI closes the reviewed condition without a status-only source commit. + +**Acceptance status:** `pending` + +**Execution ledger:** [execution-ledger.md](execution-ledger.md) + +**Issues:** [issues.md](issues.md) + +This is the canonical current implementation plan. The user authorized implementation on 2026-10-22; the integration lead admitted `start` for this exact plan after rechecking current main and preserved work. The earlier planning-only boundary is historical in the [ledger](execution-ledger.md) and [planning verification](reports/verification.md). Product acceptance below remains pending until the named evidence passes. The local surrogate lab remains outside all implementation, inspection and migration authority. + +## Objective + +A person can own a Tuldok project as an ordinary external directory, version its canonical plain-text records in Git, close Tuldok, and reopen it using a fresh runtime database. The directory retains authored work, annotations, scientific decisions, observations, exact historical material and lineage. PostgreSQL is a reconstructible runtime projection. Project-owned scripts supply domain integrations; Tuldok neither knows Rheon semantics nor runs scripts, installs dependencies or regenerates data on Open. + +Save means a durable, coherent project checkpoint. A successful edit must also survive a process crash before the person clicks Save. Save As creates an independent project, retaining the provenance of inherited history without copying executable authority or falsifying old identities. Git alone transports text and manifests; large payloads require the explicitly listed external artifacts. Missing payloads remain visible and never become invented evidence. + +## Objective Acceptance + +All rows are required for final product acceptance. The implementation owner records results against an identified material candidate and the precise fixture/environment. T1–T9 below own the procedures. + +| ID | Observable criterion | Kind | Environment | Mode | Status | Evidence | +| --- | --- | --- | --- | --- | --- | --- | +| A1 | New → annotate/edit → Save → Close → fresh process/Open from Git clone with no old DB reconstructs identical authored values, identities and history; subsequent Save has no semantic changes. | `user-workflow` | `required-real` | `automated` | `pending` | T1: local PostgreSQL, supported filesystem, real HTTP/browser | +| A2 | Every supported durable family roundtrips without silent omission, including unique annotations, observations, source bytes and cross-Study lineage; historical proof remains inspectable with live authority absent. | `integration` | `required-real` | `automated` | `pending` | T2: real PostgreSQL/file adapters | +| A3 | Moved directory and fresh checkout reopen through new local bindings; independent Save As has a new project identity and no write, ID or authority collision with its source. | `system` | `required-real` | `automated` | `pending` | T3: separate processes/DB schemas/roots | +| A4 | Missing or altered external artifacts have precise availability/integrity states; unique bytes are preserved when copying a complete project, and regeneration creates distinct observations. | `contract` | `required-real` | `automated` | `pending` | T4: filesystem and artifact consumers | +| A5 | Kill/interruption, disk/write failure and lost replies at each publication boundary lose no acknowledged authored work and publish no partial checkpoint; retry reconciles exact outcomes. | `system` | `required-real` | `automated` | `pending` | T5: subprocess/fault boundaries + actual durable reopen | +| A6 | Concurrent writer, active/unknown Run and Git checkout/merge/stale DB scenarios cannot overwrite canonical work, revive prior activity or mix revisions. | `system` | `required-real` | `automated` | `pending` | T6: cooperating separate processes + Git | +| A7 | Malformed/future records, unsafe paths and credential/grant/resume imports are rejected before effects; opening malicious project source causes no execution, installation, download or regeneration. | `contract` | `required-real` | `automated` | `pending` | T7: exact diagnostics + effect observations | +| A8 | CLI, HTTP/API, SDK, MCP and UI use the same project operations, revision/outcome meanings and capability negotiation, including failed/unknown outcomes and keyboard-accessible recovery. | `user-workflow` | `required-real` | `automated` | `pending` | T8: real clients/server/browser | +| A9 | Read-only extraction report and explicit extraction of a disposable mixed catalog preserve dependency closure and original data, with proven fresh-project reopen and reversible cutover. | `system` | `required-real` | `either` | `pending` | T9: representative fixture, never the user's lab | + +## Scope + +### In Scope + +- Local, user-selected external directories on the currently supported Linux/POSIX local-filesystem deployment, using local PostgreSQL. The file format is portable text; writer support on other filesystems is a separate verified promise. +- One writable project per application workspace/session; separate projects use isolated runtime projections. Multiple browser tabs may share that owner with revision checks. Existing global/mixed catalogs remain a separately identified legacy mode until explicitly extracted. +- Project-owned editable data and history, saved selections/searches, Pipeline recipes, model definitions/materials, Studies/Trials/decisions, Run evidence and retained analysis, releases, project script/extension declarations, exact input and artifact manifests. Preserve all relevant closure, including cross-Study dependencies. +- New/Open/Save/Save As/Close, explicit relocation/rebinding, deterministic cold reconstruction, safe stopped extraction, consistent transport/UI behavior and the narrow lifecycle changes needed to keep project facts out of database-only authority. +- Git diff/commit/clone/checkout/manual merge workflows. Tuldok validates the resolved project and stale projection; it does not implement a Git client. + +### Out Of Scope + +- Any inspection, migration or execution of the user's local surrogate lab. Its local state is ahead of GitHub; GitHub is a code baseline only, never its current project truth. +- Unrelated product changes and Coding-Standards edits. A dedicated feature branch and draft implementation PR are authorized; incomplete milestones must not be merged as the completed feature. Any eventual merge requires relevant independent review, exact-head CI and acceptance gates. +- Repeating completed model-development D1–D4 architecture work, redesigning scientific metrics/training, adding a scheduler, domain-specific importer, universal object registry or recipe engine. +- Live export/fork during active or uncertain execution; collaborative/distributed writers, network-filesystem locking guarantees, semantic Git auto-merge, automatic conflict repair, cloud synchronization, automatic artifact fetch/install/regenerate, automatic garbage collection, automatic format upgrade/downgrade. +- Binary weights/data in Git, database dumps as the project format, and a promise that Git history alone backs up unique binary evidence. External artifact backup remains required. + +## Constraints And Assumptions + +The verified [source and standards baseline](reports/baseline-and-standards.md) fixes the code examined. Re-read affected owners if implementation starts on later main; do not assume this plan certifies a future code state. No applicable `AGENTS.md` exists in the inspected workspace ancestry or tracked Tuldok tree. + +User requirements are binding. Format/layout and sequencing below are engineering defaults selected to satisfy them; they can change through the re-plan rules when an observation shows a simpler conforming design. The person supplies a destination and a meaningful project selection, not hash rewrites, SQL mappings or recovery procedures. + +| Assumption / uncertainty | Decision affected and owner | Smallest adequate observation / stopping condition | +| --- | --- | --- | +| Local filesystem supports anchored no-follow access, single-writer advisory exclusion, same-filesystem atomic rename and fsync of files/directories. | Project persistence owner: publication guarantee | Before M1 expands, use a disposable target directory to exercise lock contention, replacement and reopen in another process. Document mount/platform and supported limits. Power-loss durability requires storage honoring fsync; no network/Windows promise inferred. | +| Existing owner methods can expose durable values and commit boundaries without duplicating domain policy. | Domain owners + project persistence owner | Inspect and wire one real annotation mutation in M1; prove canonical commit precedes acknowledgement and SQL rebuild. Stop at a useful text vertical; replan if a second domain interpreter becomes necessary. | +| An extraction root cannot be inferred from today's Project registration. | Extraction owner, M5 | Read a disposable mixed-catalog fixture and enumerate referenced records, reservations, materials, producer inputs and lineage before any destination write. Ambiguous membership needs explicit user selection later; it does not block planning or new projects. | +| Large/unique payloads may not all be locally available. | Artifact owner | Report required byte identities and availability; do not substitute generated bytes. No complete-copy success until required unique bytes have been copied/verified or a deliberately metadata-only result is selected. | + +## Binding Decisions + +| Decision | Owner | Evidence / reason | Supersedes | +| --- | --- | --- | --- | +| External canonical records own project facts; PostgreSQL and runtime folders are disposable projections and operational state only. | Project persistence owner; each domain retains semantic ownership | User requirement; current DB-backed owners need a coordinated project-mode cutover. | DB-only authority for project mode, after accepted cutover | +| Project scope is an explicit root set plus complete referenced closure; registration snapshots are inputs, not containers inferred to own all matching data. | Project membership + existing domain owners | Current schema has no Project → Study/Run ownership. | None | +| One portable project UUID is stable across moves/clones/checkouts; physical projection identity is separate. Save As allocates a new project UUID. | Project identity owner | Catalog identity currently participates in hashes; preserve identity domains explicitly. | None | +| Historical hashed documents retain exact origin namespace/bytes. New active values in forks are constructed through domain owners with new references/hashes, never recursively rewritten JSON. | Model/Pipeline/Studies/Runs owners | `model_material_v1`, `recipe_material_v1` and lineage bind catalog IDs. | None | +| Imported history is inert evidence. Credentials, live grants, admission/lease authority, process/PID continuation, request replay authority and resumable execution never cross the boundary. | Access + Runs; portable history decoder enforces separation | Historic authorization inputs are facts; current permission is separately granted. | None | +| An edit succeeds only after its canonical record checkpoint is durable; Save is an explicit verified barrier, not a DB-to-file export that leaves an unsaved authoritative DB. | Persistence + mutation owners | Avoid second authority and crash loss. | None | +| Open validates and builds indexes/SQL only; it does not instantiate recovery-capable worker owners over imported history. | App composition + Runs | Current `Dataset` constructs recovery-capable Jobs/Runs; that constructor cannot be used unchanged. | None | +| Generic scripts and declarations remain project-owned; explicit execution remains under existing grants and Run custody. | Project authors + Pipeline/Runs | User boundary; registration already inspects source inertly. | None | +| Stop/fence and prove cessation before Save As, extraction, relocation of an open writer, or Close. Active execution publishes durable facts through its existing custodian. | Runs/coordinator + session owner | Unknown effects cannot be made safe by dropping a lock or copying a folder. | None | +| Current status/next slice live here; ledger/reports retain dated evidence. | Implementation integration owner | Current Planning + requested audit lessons. | None | + +## Architecture And Authority + +Use one internal project persistence boundary, provisionally `project_store.py`, and one offline extraction procedure, provisionally `project_transfer.py`. Do not make a public plugin system or generic domain registry to serialize every SQL table. `projects.py` retains immutable external registration/inspection; it may delegate safe rooted file operations after their contracts are reconciled. Portable project identity and registration snapshot identity are distinct even if both have UUID syntax. + +| Concern | Canonical owner / durable source | Runtime projection and boundary | +| --- | --- | --- | +| Project identity, membership roots, current checkpoint inventory | Project store; `.tuldok/project.json` | Session binds selected directory + checkpoint digest. No current absolute host bindings in canonical metadata. Historical non-secret path spellings may remain inside exact inert evidence, never as current resolution authority. | +| Targets, responses/comparative judgments and rationale, provenance/rights, review/exposure/deletion histories, saved intent | Workbench/Preferences/Releases/selections/searches; versioned domain records | Existing validation/query behavior retained; SQL tables reconstructed. An export must not silently reset reviews, exposure or deletions. | +| Editable recipes and model configuration; immutable materials/source closure | Pipeline/model definitions; owned records + captured source/artifact references | Existing owner validators preserve material semantics and hashes. No executable material silently synthesized on Open. | +| Study membership, budgets already spent, reservations as historical facts, decisions, lineage | Experiments/lineage domain records | Imported Studies start closed for execution. Historical charges remain; new budget/admission is explicit. Read-only historical joins cannot require live grants. | +| Run attempt/results, input/output custody and observations | Runs canonical facts + artifact manifests | Local supervisor/leases/process state live only under runtime ownership. Cold history reader cannot call recovery/admission. | +| Artifact bytes and integrity | Immutable asset / release / Run owners, identified by digest and size | Local resolution map/cache does not change identity. Unique bytes require preservation, even when untracked. | +| Current permissions, credentials and operational control | Access/operator configuration outside project | New local authority on each installation/project binding. Not derivable from imported audit facts. | +| Domain meaning/regeneration scripts | Project authors and explicit task/adapter contracts | Tuldok sees generic source, inputs, runtime declarations and outputs; no Rheon special case. | + +Keep domain decisions with existing owners. The store coordinates a coherent canonical checkpoint and its projection, not training policy, annotation semantics, scientific validity or credentials. SQL/schema adapters are internal coordinated contracts; independently persisted project/domain records and public operation versions have explicit support/rejection policies. + +### Visible family and mutation-owner dispositions + +The current schema and file owners already establish this minimum population; M2/M3 may refine codecs but cannot drop these families. Every advertised project-mode mutation reaches canonical commitment, and an unfinished family returns unavailable before a write. Current families are the required target scope; temporary milestone restrictions cannot silently become final exclusions. + +| Family / actual owner paths | Portable disposition | Mutation disposition and milestone | +| --- | --- | --- | +| `samples`, Workbench records/targets/rights/history/deletions; `workbench.py`, `metadata.py` | Current values, immutable prior revisions, provenance and tombstones retained. | Text vertical M1; remaining supported modalities and source/history codecs M2. | +| Responses, response histories, preferences and comparative judgment histories; `workbench.py`, `preferences.py` | Exact prompt/answer revision bindings, outcomes/rationale/review and captured source revisions retained, not merely final labels. | Both direct response and Preferences `_persist` mutations canonical in M2; disabled before integration. | +| Saved selections/searches and fixed/dynamic intent; `saved_selections.py`, `saved_searches.py` | Preserve selected revisions/criteria and stale meaning without minting reviews. | Canonical writes M2. | +| Sequence/mesh/pointcloud/volume bundles, scientific fields and audio; `sequence_assets.py`, `meshes.py`, `pointclouds.py`, `volume_assets.py`, `scientific_fields.py`, `audio_assets.py`, `immutable_assets.py` | Record/annotation/provenance plus exact original bundle payloads, including SQL BYTEA, become explicit artifact dependencies. | Owned asset publication/record mutation coordinated M2; no serializer substitutes for subtype validation. | +| Releases and exposure evidence; `dataset_releases.py`, `pipeline.py`, `runs.py` | Frozen data/target/input identities, split/exposure facts and unique release payloads. | Static record/release publication M2; execution-produced exposure/publication M3. | +| Generation entries/jobs and proposal history; `synthetic.py`, `grounded_candidates.py`, `caption_proposals.py`, `text_classification_proposals.py` | Retain source/result/review/observation history; live credential/admission/worker control excluded. | Inert read M2; effectful producers/continuations canonical and fenced in M3. Unsupported until that integration. | +| Graph/recipe/model drafts/materials; `pipeline.py`, `model_definitions.py`, `extension_bindings.py`, `extension_canonical.py`, `extension_evidence.py`, `model_evidence.py` | Exact versioned values, capture paths/source bytes, extension declarations/selection/schema identities and transitive input closure; interpret via current owners. | M2 immutable/historical reading and non-executing preparation through canonical boundary; current executable capture and external effects remain fenced until M3. Extension readers receive origin-scoped inert context; no new extension interpreter. | +| Policy/Study/Trial/membership/reservation/candidate/lineage/operation facts; `experiments.py`, `experiment_lineage.py`, `candidate_packages.py` | Budget charges, selection/intent, lineage and candidate preparation/package/verification facts retained; live admission/resumable request control excluded. | Metadata changes M2; candidate `_prepare_operation` external file publication, registration and composed Trial/material/receipt operations integrated in M3. Its existing SQL rollback callbacks must not delete canonically committed files; use staged intent/receipt reconciliation at the same project boundary. No advertised bypass through candidate helpers. | +| Run/input/output/publication/retained analysis; `runs.py`, `run_publication.py`, `retained_analysis.py`, `field_evidence.py` | Exact non-secret proof closure and observed artifacts remain inert and currently permission-checked; no imported GO/retry/PID authority. | Historical access M2; live lifecycle/custody/artifact transitions M3. | +| Runtime snapshots; `runtime_snapshots.py`, `recipe_process.py` | Captured manifest/digest/provider identity is retained independently of host path. Executable binaries/weights stay untracked declared dependencies with explicit availability, never installed/launched on Open. Historical absolute path strings may remain inert proof; current resolver uses a new local mapping. | Inert declaration/reference reading M2; explicit snapshot capture/publication and its audit/manifest facts integrated M3 or returns unavailable before side effects. No project-mode call to old direct capture writer until integrated. | +| Registration snapshots; `projects.py` | Keep exact snapshot manifests/origins; current device/inode/root aliases are local bindings. | Project-mode registration through canonical boundary M2; rebinding M4; legacy snapshot APIs keep their existing meaning. | +| Access tables and audit; `access.py` | Export allowlisted historical attribution/approval/consumption facts only. Credentials, tokens/token hashes, sessions, live grants, admissions and authority-bearing receipts never enter portable execution tables. | Current authority stays installation-local. Canonical non-secret audit/operation facts commit with domain changes in M1–M3; local credential audit cannot become project truth. | + +The family table is a minimum concrete owner map, not a new runtime registry. Existing model/extension/candidate numerical meaning and package verification are preserved. M2's acceptance fixture includes text response/preference history, candidate preparation facts and runtime/extension closure; M3 exercises the direct writers' publication/no-bypass paths. Further source inspection expands this population only when it reveals a new owner or reachable persistence promise. + +### Canonical layout and versions + +Proposed v1 layout, to be made executable in M1; names below are concrete defaults rather than implemented commands or schemas: + +```text +my-project/ + .tuldok/ + project.json # sole current checkpoint / complete inventory + records///.json + artifacts/.json # artifact declarations, not payloads + history////.json + scripts/ # ordinary project-owned, inert on Open + notes/ # ordinary authored scientific notes + artifacts/ # untracked payloads, including unique evidence + .tuldok-local/ # ignored locks, resolutions, staging, runtime + .gitignore +``` + +`project.json` uses a closed `tuldok_project_v1` format discriminator, project UUID, display name, optional fork-origin reference, explicit membership roots and sorted inventory entries. Each entry gives family, record ID, domain format, project/origin namespace, path, bytes and SHA256, with artifact/script/notes references as applicable. It enumerates everything needed to rebuild project facts and the explicitly external payload requirements. It must not become an umbrella domain version or authority: it references Workbench, Pipeline, Runs and Studies' independently owned formats. + +Records are strict UTF-8 JSON with LF, deterministic key/order conventions and terminal newline. New project records use one documented serializer: sorted object keys, two-space indentation, finite JSON values, no duplicate keys, no Unicode normalization or unordered-array reordering unless the domain owns that equivalence. Authored strings keep their meaning. Immutable legacy evidence is a typed historical envelope containing the exact original document text and origin format/hash, so pretty printing never changes the source identity. A digest verifies exact bytes, not scientific validity. IDs, 64-bit counters and floating values must respect existing domain bounds; no lossy JavaScript conversion. + +Immutable revision files allow a multi-record checkpoint to be published by replacing just `project.json` after all referenced records exist and are durable. Readers only traverse the inventory; arbitrary files and unreferenced partial objects do not acquire authority. A record filename digest is over its file bytes; existing material semantic hashes remain their domain hashes. Do not mix those equalities. Existing revision files remain retained in v1; reachability-based pruning is deferred. Inventory and file layout are Git-compatible text, although manual conflicts in the checkpoint inventory require resolution. No parallel stable-path copy of the same canonical records is introduced for prettier diffs. Inventory entries distinguish immutable domain/capture/evidence files from current user-editable scripts and free-form notes. A script or note used as historical input is captured immutably by its owning material/observation; its later working-file edits never change that capture. Prior captured/acknowledged note revisions remain in immutable records; current editable working inputs are not authority for past results. + +`format` is the current project-format discriminator; per-family `format` preserves the domain contract; a projection schema/reader revision invalidates only the disposable projection; checkpoint digest is state identity; operation ID is retry identity. These values do not advance in lockstep. V1 supports only declared v1 project records plus explicitly selected historical source formats. Malformed content is `invalid`, a well-formed future format is `unsupported`, missing required content/capability is `unavailable`. Open never upgrades, drops unknown fields or downgrades. Future migration is explicit into a new destination, preserves originals and verifies its source/destination states. + +Tracked defaults: canonical JSON, artifact manifests, authored scripts/notes and small explicit source/config inputs. Ignored defaults are anchored `/artifacts/`, `/.tuldok-local/`, virtual environments, caches, generated output, model weights and database/PGDATA directories. Do not blanket-ignore annotations or notes by extension. New writes stage only declared paths; Tuldok does not initialize Git, stage files or commit for the user. `.gitignore` is a convenience, not proof: status/checkpoint inspection identifies accidentally tracked generated/binary/secret-bearing paths without deleting or unstaging them. + +### Stable identity and independent Save As + +An ordinary move or Git clone preserves the project UUID, domain identities and checkpoint digest. A physical DB projection has a local instance ID and namespace/schema independent of that UUID; two locations with the same UUID do not share a mutable DB. Local binding keys include physical root identity and runtime instance; device/inode is a race/ownership check, never portable identity. Rebinding is explicit and leaves old registration snapshots intact. + +For new projects, the existing logical `dataset_id`/catalog namespace can be the stable project UUID, avoiding a needless new semantic identity layer. `metadata.py` must separate this logical value from physical schema/runtime locking so clones have isolated physical stores. This is not permission to assign a mixed catalog's UUID to each extracted project. + +Save As always makes an independent fork in v1. It copies validated canonical closure into a new non-overlapping destination with a new project UUID and fork-origin checkpoint. Existing historical material/Run/Trial identities remain scoped to their original namespace in an inert archive, including full ancestry and baselines. The archive is inspectable through new historical readers with explicit origin context; it is never inserted into active execution tables merely to satisfy existing `_load` joins. Copies of editable graphs/model definitions/selections receive destination IDs/revisions and explicit origin mappings. Re-preparation creates new material using destination identities through existing owners, with new approval if execution is requested. Existing historical material SHA256s stay unchanged. Legacy/forked historical Trials can be referenced as origins of new Trials without claiming same-catalog executable membership; the lineage contract needs an explicit historical-origin representation rather than weakening its current same-catalog validator. + +Save As copies required unique binary artifacts to the new untracked payload area and verifies them before complete-copy success. Declared regenerable/large shared artifacts may remain external references with visible availability; copy policy and completeness are in the result. No writable hard links to source, no source destination overlap, and no overwrite/merge into existing projects. A metadata-only fork is explicit and cannot claim fully available evidence. A same-identity backup is a filesystem/Git backup procedure, not an ambiguous second Save As mode. + +### Reconstruction and inert historical evidence + +Open first reads a stable checkpoint and validates the entire referenced structural closure, then builds a fresh projection in isolated local storage and switches the session only after successful construction. The projection marker contains project UUID, exact checkpoint digest, projection schema/reader revision and local root/runtime binding. Partial projections never become ready. Missing artifact payloads are allowed for metadata/history inspection with explicit per-artifact unavailable status; missing canonical records fail Open. Ordinary Open may create derived SQL/index files, but causes no project-script import, subprocess launch, dependency installation, network resolution, data generation or automatic model/material preparation. + +Project mode also has one process-wide workspace/session controller, not one active project per tab. Every request carries the opaque local binding handle (project UUID + local binding instance + session generation); the server pins and authorizes that exact binding before dispatch. A global mutable `dataset` alias is insufficient. `make_handler(dataset, ...)` currently closes over one Dataset; project-mode routing must resolve a guarded binding through the controller while legacy mode keeps its fixed Dataset. Read responses carry that generation; queued mutations from the old binding are rejected before effects, never redirected to the new project. + +New/Open/reload may validate an incoming checkpoint in isolation, but replacement uses the same Close gate: preflight unsent form input, close old admission, drain pending requests/commits/jobs/descendants, reject active or unknown cessation, and only then atomically install the new binding and release the old resources. In-flight old responses cannot apply to the replacement UI, and a closing binding cannot accept later mutations. A failed incoming validation or Close gate leaves the previous project selected and its custody owned; a created but unselected New destination is reported by exact creation receipt rather than mistaken for a successful switch. T6/T8 must cover delayed old reads/mutations, concurrent tabs, active jobs and failed replacement. + +The current `Dataset` constructor calls job recovery and `Runs._recover`. Add an explicit inert composition path before calling these owners: history readers and query projections are separated from live runtime reconstruction. A stored Run terminal state is a historical fact. Stored active/unknown state is an observation of the old machine, not a request to attach to its PID or resume; unresolved local work remains controlled by its original runtime owner. An imported history record cannot enter `_recover`, consume an old request key or open a Study epoch. + +Retain immutable facts needed to interpret evidence: reservation-to-Trial/Run association, exact approved material and approval time/disposition, producer inputs, consumed-charge history, lineage/baseline edges, selection decisions, measurement definitions, source/runtime declarations and artifact digests. Retain opaque attribution identifiers only as historical labels; credentials/tokens/token hashes, private endpoints and active session IDs are excluded. Grants/consumptions/admissions are represented as inert typed historical facts with no authorization capability, never restored into `access_*` runtime tables. Run specs/custody files require field-level classification: exclude executable continuation control, but preserve exact signed/hashed evidence fragments and required non-secret proof in an inert envelope. If a source format inseparably binds a secret into evidence, extraction reports it and stops that record pending an explicit preservation/redaction decision; it cannot silently alter proof or put secrets into Git. + +Historical readers reuse owner validation of values/integrity while dropping the requirement for *live* reservation/grant rows. They still check complete historical proof closure and current local disclosure permission. Where proof is missing, show retained/unverified or unavailable, never a new verified/accepted status. This distinction is necessary because `runs.py` currently joins reservations, grants and consumptions to validate external Run history. Newly measured regeneration results get new artifact/Run/observation identities and explicit derived-from links. Even byte-identical output cannot silently replace the original measurement's provenance. + +### Durable mutation, Save and recovery + +Avoid distributed SQL/file commit as two equal authorities. In project mode, a mutation uses the existing domain owner to validate against the current checkpoint/projection, constructs its changed canonical records and stages required new payloads. Under the project writer guard, it rechecks expected checkpoint and domain revisions, durably publishes immutable records/payloads, then atomically publishes the new checkpoint containing the operation receipt. Only the canonical commit authorizes success. SQL applies the committed change afterwards or is rebuilt; failure after canonical commit is reported as committed/projection-unavailable with exact checkpoint/operation identity, never as permission to repeat the effect. The next command must reconcile/rebuild before mutation. + +A staging transaction may use SQL rollback for validation, but cannot acknowledge DB-only domain facts or dispatch execution before canonical commitment. Existing audit/receipt guarantees must be preserved in that boundary: portable non-secret audit facts commit with the project mutation; local credential audit references stay local. This is a coordinated project-mode change to existing mutation owners, not a generic decorator around arbitrary SQL writes. + +Save validates the latest committed closure, waits for pending project publications and fsyncs the checkpoint barrier. It returns project UUID, checkpoint digest, completeness/missing-artifact summary and receipt identity; no-op Save preserves canonical bytes. Forms not submitted remain UI input: navigation/Close warns about them, while submitted edits are already durable. This avoids a persistent DB-backed “unsaved project” with an implicit second authority. + +| Interruption window | Required reopen / retry behavior | +| --- | --- | +| Before record/payload durability | Old checkpoint remains authoritative. Preserve or remove only proven task-owned incomplete staging; no success acknowledged. | +| After immutable files, before checkpoint replacement | Old checkpoint remains authoritative; orphan files are inert and retained for diagnosis. Retry checks operation identity and input revisions. | +| During checkpoint replacement/fsync, or lost acknowledgement | Outcome is unknown until the actual checkpoint is reread. Its atomic old/new content plus embedded receipt decides commitment; no guessed rollback or duplicate effect. | +| After checkpoint durability, before/during SQL update | New checkpoint wins. Rebuild derived projection; acknowledged work survives. Never roll files back to match SQL. | +| During New/Save As/extraction destination publication | Stage on destination filesystem, verify full inventory/required bytes, fsync, publish a non-existing destination atomically, fsync its parent. Receipt resolves lost replies. Source remains unchanged. Unsupported rename/durability capability refuses publication. | + +Rebuilding or replacing a projection is permitted only after accounting for original local custody. Do not drop a projection/control binding while active or unknown workers still own it; close/fence and prove cessation first, or remain blocked. Losing SQL cannot grant new execution permission or erase unresolved local work. + +No automatic recovery deletes canonical history, overwrites a conflicting destination, or retries project code. Keep recovery bounded and operator-readable; no new general journal/event-sourcing engine is needed. The checkpoint's current operation receipt and retained operation records provide the retry window required by public clients; preserve request scope/body binding and reject changed-body key reuse. Retention and expiry rules belong to the new operation contract; old execution request keys never become imported retry authority. + +### Active runs, Close and multiple writers + +One local process owns a writable project binding and all effectful child work. Use a kernel-released local file lock for physical root ownership and existing catalog coordination within that runtime; a lockfile's age or PID text alone cannot authorize takeover. Each mutation additionally compares the expected canonical checkpoint and domain revision. A second writer gets `busy` with no effects; an optional metadata reader reads an immutable checkpoint. Copies/clones on separate local directories are independent workspaces sharing history; Git integration is explicit. + +All durable authored/Run lifecycle facts needed after database loss must reach canonical records, including admission intent, measured outcomes, publications and interruption observations. High-rate progress may remain disposable diagnostics if labeled so. Execution permission/lease remains local and is never reconstructed from those facts. M3 must place the Run dispatch barrier after the canonical admission fact and current local approval checks; canonical history alone cannot authorize GO. Worker completion must publish artifacts and final evidence durably before reporting terminal success. + +Save can checkpoint a project during an active Run only after draining currently committing facts; it clearly reports active custody and is not a portable live snapshot. Save As and extraction require closed admission and proven cessation of all relevant descendants/publication work, including generation/proposal jobs. Unknown cessation refuses them. V1 Close similarly returns `busy` until the user explicitly stops work and the owner drains it; Close never kills unknown PIDs, silently cancels, discards edits or detaches workers. On ordinary crash/restart, only the exact original local runtime may reconcile live custody under existing rules; inert Open on a fresh runtime does not resume anything. A missing old runtime cannot be interpreted as proof that a former process ceased; execution readiness remains fenced where local unresolved effects could still act on the same root. + +### Git changes, stale projections and conflicts + +Support Git checkout/merge while the project is closed. For deliberate edits to current scripts or free-form notes, offer an explicit `adopt_working_files` operation: inertly report changed declared editable paths and their old/new identities, require the expected project/checkpoint and explicit selected changes, validate bounded regular files and any owned declared structure, then capture changed bytes and publish a new canonical checkpoint. Retain prior acknowledged/captured versions and record the edit origin; any source/material preparation remains a separate action. Deletion of an editable note is a retained tombstone, not history removal. External edits are unacknowledged input until adoption; no promise recovers bytes the external editor itself destroyed before any capture/Git backup. Show this distinction without hiding the new bytes. + +Adoption must never update hashes for changed immutable records, captured sources, materials, Run outputs, artifacts or historical envelopes to make corruption pass. Such edits require restore from an authoritative source or an explicitly new domain record/measurement with new identity. Validate-only has no write effects; adoption is a separate explicit mutation, not auto-repair on Open/Save. If editable-file drift prevents a normal Open, an owner may select that project into an inert validation/adoption context without installing it as a ready writable workspace; publish a validated new checkpoint before normal Open. This recovery path has the same root/permission/current-session replacement gates and cannot relax immutable closure validation. + +Changes outside the application while open invalidate the checkpoint binding: before each mutation and authoritative query, compare the bound inventory checkpoint identity and verify the referenced records being consumed. If changed, return `stale_project`, fence mutations, retain the old projection for diagnosis and require explicit reload after resolving UI input. No automatic Save overwrites a checkout. Read paths label their captured checkpoint and cannot mix data from new files with old SQL. Immutable filename/byte mismatches are corruption; never repaired by trusting the DB. + +Unresolved Git entries or conflict-marker/malformed referenced JSON block writable Open; structural closure also detects duplicate IDs, broken references, stale hashes and conflicting project identities. A `.git` file in a worktree is not a directory assumption; use Git's declared plumbing for optional unmerged-entry detection, with no hooks or filters executed. Format validation remains mandatory without Git installed. Manual merge resolution includes updating the inventory's paths/hashes and checking semantic constraints such as acyclic lineage and release/material references. Provide a pure validation/report command rather than a semantic merge engine or automatic winner. A destructive old checkout need not retain later history; the current checkpoint defines current state, with later committed text/payloads recoverable from their own backups. Never garbage collect artifacts just because a branch no longer references them. + +An advisory lock does not fence a concurrent Git client or malicious filesystem writer. V1 requires closed-project checkout and rejects observed changes; it does not promise transactional live Git edits. Detect record changes across read/validation/publication using rooted handles/identity checks and final expected-checkpoint comparison. If a concurrent non-cooperating editor defeats the supported preconditions, preserve both staged material and conflicting checkpoint for explicit recovery; do not report a successful overwrite. Supporting hostile/uncooperative distributed writers would require a different coordination contract and is deferred. + +### Artifact resolution and narrowly scoped protection + +Each artifact declaration gives a stable identity, SHA256, byte size, role/media/domain format, owning record, provenance/producer inputs, required-for operations, and retention class: unique evidence, captured source/input, regenerable generated output, or disposable cache. Include the generic recipe/source/runtime/input identities describing regeneration, never an Open-time action. A local resolution maps digest to project-relative payload or operator-selected approved root; absolute paths, credentials and device/inode stay untracked. Reject changed bytes as integrity mismatch and never search arbitrary host paths or fetch a URL on Open. File-size/hash work is bounded/streamed and cancellable; index reading need not hash terabytes before metadata inspection, but actual consumption verifies required bytes through the artifact owner. + +Preserve original unique annotation/source/observation bytes through transfer and backup. Original bundle bytes currently in PostgreSQL BYTEA are externalized to untracked payloads with exact digest/size, not base64 embedded in Git metadata. Tiny human-authored annotations/decisions remain text; large numeric observations may be payloads whose unique evidence status requires backup/copy. “Generated” never implies disposable when the particular measured result is historically irreplaceable. + +Threat model: a locally selected project contains untrusted records/scripts; browser/agent input must not authorize arbitrary host writes; cooperative local writers can race. Reuse rooted no-follow handle traversal and regular-file checks, rejecting absolute/traversal components, symlink escapes, special files and unsafe creation/replacement. Check destination non-overlap by filesystem identity/components, not string prefixes. Reject hard-link alias overwrite for owned canonical writes and avoid writable hard-link copying. Bounded strict decoding and inventory size/depth/byte limits prevent accidental resource exhaustion; choose limits from admitted current record bounds and a representative fixture, rather than reusing the 64 KiB registration manifest limit for a whole project. Display paths/diagnostics must not leak DSNs, credentials or unrelated host locations. Project scripts remain inert text and previews escape markup. No new sandbox, cryptosystem or network service is justified by save/load itself. + +## Operation Semantics And Consumer Contract + +New operations have an explicit project-file namespace, provisionally `project_files.*`, separate from existing `projects.open` / `project_snapshots.open` registration. Exact public names/versions are frozen with M1's contract and all real consumers. Do not silently redefine legacy Open to mean filesystem Save/Load. + +| Action | Observable semantics / preconditions | Failure / side effects | +| --- | --- | --- | +| New | Local operator selects a new directory beneath an authorized creation parent; allocate UUID, empty valid checkpoint and ignore defaults; construct projection; apply the same old-session Close gate before UI replacement. | No existing directory overwrite. Failed staging leaves source/user paths intact; unknown outcome reconciles destination receipt. | +| Open | Select an existing authorized root/relative project file; stable decode, closure validation, artifact availability and isolated projection; apply old-session Close gate before replacing the process-wide workspace; obtain writer ownership only for writable mode. | No script/import/install/download/regeneration. Invalid/future/missing structural records leave current session untouched. | +| Save | Expected project/checkpoint + operation key; durable barrier, complete inventory and availability report; no-op stays byte-identical. | Stale/busy/IO/unknown outcomes remain distinct; never export SQL as a second truth. | +| Save As | Saved current checkpoint, idle/proven ceased source, new non-overlapping destination; create independent UUID/draft IDs with inherited inert history and explicit artifact completeness. Source unchanged. | Missing unique bytes block complete-copy result; explicit metadata-only result is marked incomplete. No grant/resume/control import. | +| Close | Flush pending commits, reject unsubmitted-form loss without choice, close admission and drain/release local owners. | Active/unknown work returns busy/incomplete until explicit stop and known cessation; no silent discard/detach. | +| Validate / reload / adopt working files / bind artifact | Inert report; explicit reload through the replacement gate; reviewed selected editable-file adoption into a new checkpoint; or local artifact mapping. Domain owners validate required content. | Reload rebuilds only derived state. Binding an artifact never changes historical identity or runs a producer. | + +`agent_api.py` remains canonical for public operations, schemas, versions, scopes and hashes. HTTP handlers dispatch to the single project owner. `tuldok_client.py`, `project_cli.py`, `mcp_stdio.py` and existing workspace UI adapt those operations, not their own parsers/serialization rules. New/Open/Save As host path selection is owner-only/local-operator configured; agents receive opaque allowed root/destination tokens, never an unconstrained absolute path primitive. Existing inspection/edit scopes are enforced at the actual boundary, plus current disclosure permission for history/artifacts. Importing a project confers no role. + +The controller has an explicit authenticated `no_project_open` state with an installation/session generation and no project UUID; initial New/Open bind to that state and local root-selection authority. Successful selection creates the project binding atomically. Project-specific operations are unavailable in the no-project state. Requests against an open project require the opaque project binding and generation in addition to their operation-specific revision/key. Requests lacking the new binding in project mode fail before dispatch; legacy fixed-catalog operation support is not silently widened. Responses identify project UUID, checkpoint, local binding/session generation, operation receipt and availability state. Unsupported capability, malformed content, stale expected revision, busy ownership, IO unavailability and unknown publication outcomes have documented typed codes mapped consistently to existing HTTP/CLI/SDK/MCP conventions. Successful HTTP transport cannot turn a failed domain result into success. Clients negotiate new per-operation contracts; incompatible mutations stop before dispatch. UI invalidates outstanding reads on project switch/close, retains exact unknown-outcome requests for explicit reconciliation and never automatically repeats mutation. New/Open/Save/Save As/Close controls, dirty form warning, missing-artifact view, conflict/reload and busy-run state require visible keyboard/focus behavior. No mandatory new desktop shell or OS picker is implied: use the existing web UI with configured local root choices. + +## Migration And Extraction + +This is an explicit future procedure, not startup migration and not authorization to touch the real lab. Preserve existing backup/restore unchanged for whole-catalog disaster recovery; it contains Access/runtime authority and only the managed catalog file tree, so it is not the portable project format. + +1. **Report only:** against an owner-selected, supported, quiesced catalog, inventory requested roots and complete transitive dependencies. Today registration has no authoritative ownership mapping. Report shared records, ambiguous membership, excluded unrelated projects, missing external-root payloads, cross-Study parents/baselines, source/artifact/rights constraints and unknown source formats. Use a disposable representative fixture first. +2. **Resolve closure:** user selects which workflows/Studies/records belong; include immutable referenced facts even from other Studies as read-only provenance. Do not infer ownership from names or copy every table. Retain provenance mapping `(origin catalog, family, ID, revision/hash) → destination record/history` and per-family counts/hashes plus exceptions. Unsupported/secret-entangled records block complete extraction rather than being skipped. +3. **Freeze source:** stop all writers, close admission, prove worker/descendant/publication cessation and retain a verified whole-catalog backup plus separately inventoried external roots. The existing backup excludes external roots; explicitly preserve their required bytes. Read-only discovery never fences or modifies a running user catalog by surprise. +4. **Create destination only:** new project UUID; extract editable records through domain-owned codecs, archive exact historical material with origins; move BYTEA contents to immutable payload files and copy unique ordinary files. Do not mutate/delete/re-sign the source catalog, rewrite old hashes, mint review facts or reactivate approvals. Extraction receipt ties source revision/inventory, mapping, chosen scope and destination checkpoint together. +5. **Verify cold:** reopen from text/manifests and declared payloads with no source DB, paths or runtime control. Compare authored annotations/decisions, deletion/history/exposure, Study/Trial/Run associations, costs, lineage and artifact identities against the independently read source fixture. A report of row counts alone is insufficient. +6. **Cut over explicitly:** user chooses new project after reviewing proof and outstanding missing artifacts. Keep original plus backup read-only until retention decision. Roll back by reopening the original under its compatible binary; retain all new project writes for explicit reconciliation. Never discard post-cutover work by restoring an older backup over it. No dual-write period. + +## Evidence And Oracle Plan + +Each T procedure supplies one or more A claims; use existing fixture/test infrastructure and domain assertions. No new all-purpose verifier is needed. For each negative start with an otherwise valid project/authority so the intended boundary is reached; assert its typed diagnostic and unchanged authoritative state. Exact source bytes/digests are an oracle only where byte identity is the contract; semantic values/lineage and no-effects observations require their independent domain assertions. + +| Procedure | Deciding observation / oracle and boundary | Narrowly selected evidence | +| --- | --- | --- | +| T1 fresh clone | Author a tiny text/annotation/decision fixture through real UI, capture expected authored values independently, Save/Close, Git commit/clone into another root, start a new process on an empty isolated PostgreSQL namespace without source DB access, Open and inspect/edit. Compare UUID/values/history and no-op canonical bytes. | Extend existing project browser/server fixtures plus new `test_project_store.py` and `browser_project_files.cjs`. Git contains text/manifests only; required unique payload fixture copied separately. | +| T2 historical preservation | Fixture with two Studies, parent and comparison baseline in different Studies, multiple attempts/reservations, source releases, producer Run-artifact inputs, candidate preparation/selection facts, text responses/comparative judgments with rationale/review/history, runtime/extension manifests and a unique annotation/observation. Cold readers show same facts and exact hashes without Access runtime tables; no proof claims from counts alone. | Existing lineage/model/Run fixtures + `test_project_history.py`; compare against independently captured source values and bytes. Exercise unique binary BYTEA and ordinary artifact preservation. | +| T3 move/fork | Close and physically move a directory; rebind under a new alias/inode and empty projection. Save As another root; edit fork and source separately, prepare new fork material explicitly and check namespace/hash divergence while inherited history remains byte-identical. | `test_project_transfer.py`, two real processes, distinct DB physical identities and destination/source non-overlap cases. No actual training needed to prove identity. | +| T4 artifacts | Remove declared payload, alter bytes under old name, remap to matching bytes, copy a unique payload and explicitly generate a new fixture observation. Compare availability/integrity and immutable origins. | Actual owner consumption, no silent regenerated substitute; missing weights block inference but not notes/history inspection. Rights/disclosure failures remain failures. | +| T5 crash/no loss | Kill a child at record write/fsync, checkpoint replace/fsync, post-checkpoint/pre-SQL, post-SQL/pre-response and destination publish boundaries; inject disk-full/permission/fsync failure and lost reply. Reopen from files using a fresh DB, reconcile exact receipt and assert old-or-new full closure and all acknowledged edits. | Deterministic boundary injection + real subprocess/file/PG reopen; new fault seams private to owner. No fixed repetition target; exercise each distinct durability obligation. Power-failure scope explicitly limited to proven filesystem guarantees. | +| T6 concurrency/Git | Two processes contend for one physical root; stale browser edit; active/unknown descendant blocks Close/fork/extract; closed checkout A→B rebuilds stale SQL; unresolved merge, duplicate IDs, broken lineage/hash and checkout while open refuse mutation. Open/New/reload with active or unknown jobs refuses replacement; delayed old-generation reads/mutations cannot hit the new project. Explicit adoption accepts an edited current script/note and preserves prior capture, while a modified immutable source/history/artifact is rejected without hash blessing. | Real lock/PG/Git paths plus deterministic client ordering. Verify no partial project switch, no overwritten work and no double execution. No fake advisory lock as final proof. | +| T7 inert/negative | Place scripts with visible execution/install/network sentinels; Open/validate/preview/save/load must leave them untouched and create no producer process. Attempt credential/grant/lease/PID/request replay import, traversal/symlink/sibling-prefix/special-file/destination alias, duplicate keys/nonfinite/oversized/broken/future records. | Exact rejection boundary, no `access_credentials`/grant/control resurrection, no process signal, no downloads/dependency changes, source/current session unchanged. Do not count an earlier authentication failure as future-format/path proof. | +| T8 public path | Run the same valid and stale/unsupported/busy/unknown operations through real HTTP, AgentClient, CLI, MCP stdio and UI; inspect status + body/exit/typed result. Switch projects with delayed old replies; keyboard New/Open/Save As/Close/recovery at desktop and narrow viewport. | Extend real client/HTTP/MCP tests and project UI/controller tests. Shared schema agreement is supporting evidence; actual filesystem result and cold consumer response decide behavior. | +| T9 extraction | Report and extract a disposable mixed catalog with two unrelated roots and a shared dependency. Expected selected/shared/excluded closure comes from authored fixture, not exporter enumeration. Repeat same receipt only under declared reconciliation contract; corrupt/ambiguous source blocks publication. | `test_project_transfer.py` + existing backup/metadata fixtures; compare original tree/DB unchanged, fresh destination restore and retained post-cutover writes. Real user migration remains separately authorized. | + +Before running a selected test campaign, preflight interpreter/dependencies, working tree/material subject, selected test names/discovery count (>0), owned PG schema/root/port/process resources, permissions, available disk and target filesystem. Stop an invalid selection before calling it a pass. Record command, selection/count, candidate identity, material environment, direct result and exceptions sufficient for the deciding claim. No evidence-byte target, receipt-count target, mandatory identical campaigns or automatic full-suite rerun. A repeated failure requires a new hypothesis or changed input; after a repair rerun affected claims and expand only where changed semantics/risk justify it. Retain an existing unrelated failure separately and show that the selected test reaches the changed path. Subject-preserving review/evidence bookkeeping does not invalidate prior review. + +## Simplicity And Ownership Review + +**Applicability:** `applicable` + +- Independent concepts and dimensions: the user chooses *what* belongs in a project and *where* it lives; domain owners decide values/scientific meaning; project store owns *how/when* coherent records become durable; Access owns *who may act now*; Runs owns execution/custody; projection adapter indexes facts. These change for different reasons. An inventory packages them without acquiring their semantic policy. +- State, identity, value, time, policy, and mechanism: project UUID is a logical scope; origin material identity is immutable history; root inode and physical DB ID are local mechanisms. Checkpoint digest binds values at a state; observed times/approval facts do not imply present permissions. Project format, domain format, projection revision and operation key each own a different promise. Current project-v1 writers/readers agree; unknown future formats reject. Existing registration v1/v2 and selected history readers coexist because actual stored inputs exist, not as an indefinite global compatibility matrix. A note edit invalidates its record/checkpoint and SQL view, not old model hashes; a new model prepares new owned material. +- Caller and composition-root knowledge: callers select root/project/action and expected revisions, receive durable identity/outcome/completeness, and handle stale/busy/unknown states. They need not serialize SQL, determine transitive closure or rewrite catalogs. App composition supplies the store, isolated projection and inert/history versus live runtime owners. This ordering must be centralized rather than repeated in UI/CLI/MCP. +- Representative change paths and forced owners: annotation correction touches Workbench canonical revision and new release/trial only if explicitly requested; model edit touches model/Pipeline material and new references; relocating a folder touches local binding, not historical identity; artifact cache move touches resolution only; adding an observation format touches its owner/reader, not project UUID or all formats; adding a transport action projects the same operation contract. Unexpected changes across unrelated authorities trigger replan. +- Stable Interfaces versus hidden knowledge: store exposes open/commit/save/fork/close plus immutable checkpoint inputs; domain codecs expose owned values/closure and validation. Internal SQL table names remain projection details. Historical origin readers expose inert proof, not a fake Access implementation. Rooted filesystem and artifact handles carry capabilities; public clients do not learn `.tuldok-local` or PG schema names. Material ordering/failure obligations are explicitly part of the interface, not hidden behind “save”. +- Independent evolution, testing, failure, and replacement: PostgreSQL projection can be dropped/rebuilt on matching checkpoint; historical readers can reject an unsupported format without changing notes or running jobs. Domain formats evolve explicitly without an unrelated umbrella increment. Missing artifacts disable their consuming operations while text/history remain inspectable. File commit can succeed when SQL update fails; result makes that fact explicit and gates later actions. A new DB engine is not promised or needed to establish reconstructibility. +- Necessary complexity and containment: multiple authoritative records need a durable publication boundary; legacy identity-bound evidence needs an inert namespace; subprocess effects need their existing lifecycle owner. Keep these in store/history adapters and existing Runs, with explicit closure, rather than adding a distributed transaction service, event bus, scheduler or generic plugin registry. +- Deletion and cumulative machinery result: deleting the store spreads durability/checkpoint/stale checks into every adapter; deleting domain codecs loses meaning or makes the store a second interpreter; deleting history separation either leaks live authority or destroys evidence; deleting artifact manifests hides unique/missing bytes; deleting physical writer ownership permits lost updates. Immutable revision files + one checkpoint remove the need for a second recovery journal/generation database. Extraction is a bounded explicit command, not a permanent synchronizer. No extra registry, version per field, semantic merge engine, always-on watcher or generic integrity framework is admitted. Existing whole-catalog backup remains for its distinct recovery promise, never a second project format. + +## Milestones + +M1 is the smallest coherent vertical implementation, including a real UI result. Later milestones expand durable family coverage, execution lifecycle, fork/Git behavior and extraction because each has a separate high-consequence proof boundary. No milestone dictates a Git commit count or branch per layer. Each allowed write set also includes this plan directory for current status/evidence; files outside the set require a scope update, and material decisions require replan. Proposed new filenames identify responsibility; a better owned placement may replace them with recorded rationale. + +The following named path groups are finite write sets, reused below for readability: **Client/UI** = `agent_api.py`, `tuldok_client.py`, `project_cli.py`, `mcp_stdio.py`, `static/workspaces.html`, `static/workspaces.css`, `static/workspace-projects.js`, `static/workspaces.js`, `static/workspace-service.js`, `static/workspace-real.js`, `static/runs-view.js`, `static/pipeline-editor.js`, `static/experiment-lineage.js`. **Project docs** = `docs/contracts/project-files.md`, `docs/decisions/external-project-authority.md`, `docs/contracts/projects.md`, `docs/contracts/project-workspace.md`, `docs/contracts/project-cli.md`, `docs/contracts/agent-operations.md`, `docs/contracts/workspace-storage.md`, `docs/README.md`. **Project evidence** = `tests/test_project_store.py`, `tests/test_project_files_http.py`, `tests/test_project_files_clients.py`, `tests/test_project_history.py`, `tests/test_project_runtime.py`, `tests/test_project_transfer.py`, `tests/test_project_git.py`, `tests/test_workspace_project_files.cjs`, `tests/browser_project_files.cjs`, `tests/browser_metadata_server.py`. These are allowed boundaries, not an instruction to edit or rerun every listed file. + +### M1 — A new external text project survives a cold reopen + +**Status:** `Accepted` for the stated text vertical. [Implementation evidence](reports/m1-implementation.md) records independent review and real PostgreSQL/HTTP/SDK/CLI/MCP/browser gates. This is not full-feature acceptance. + +**Goal / production outcome:** user creates a new project, authors text/annotations/notes through the normal workspace, saves/closes and opens it from a moved/cloned folder with a new DB. Submitted work is canonical before acknowledgement. + +**Allowed write set:** new `project_store.py`, `project_text.py`, `project_workspace.py`; `metadata.py`, `app.py`, `workbench.py`, `projects.py`; Client/UI, Project docs, and Project evidence as defined above; new `static/project-files.html`, `static/project-files.js`, `static/project-files.css`, `tests/test_project_text.py`, `tests/test_project_workspace.py`. M1 uses an explicit `--project-files-root ALIAS=PATH` inert startup mode and dedicated text workspace; every legacy domain route is unavailable in this mode until its canonical writer is integrated. Installation-local Access stays outside portable records. Only New/Open/Save/Close and the text family are advertised in M1. + +**Dependencies / tasks:** prove filesystem capability on disposable root; freeze format and operation contracts; separate physical DB binding from logical project identity; add inert project-mode app construction and generation-bound workspace controller with Close-gated replacement; bind one text/annotation/history codec to canonical mutation/publication; implement New/Open/Save/Close across adapters and UI. Declare exact supported families; disable unsupported project-mode writes before dispatch so no family becomes DB-only. Preserve legacy catalog/registration behavior in its own explicit mode. Make the format examples executable fixtures only with their codecs. + +**Gate / risk-based tests:** T1 + T5 on text mutations and T7 no-auto-execution/malformed/future input; T8 for the four available actions, actual browser keyboard/cold-open result and client errors. Check crash before/after checkpoint and stale projection. Run affected Workbench/metadata/registration tests that cover changed invariants; no full-suite default. + +**Rollback / replan:** incomplete capability remains unadvertised. Retain canonical files and disable project mode if a defect is found; rebuild projection from supported committed text, never restore DB over files. Replan if durable mutation cannot be coordinated without duplicating domain policy, or supported filesystem cannot prove publication. Partial M1 does not imply A2–A9 acceptance. + +### M2 — Preserve complete project history and unique artifacts + +**Status:** `Accepted` for inert history, artifacts and nonexecuting metadata; [source, review and required-real evidence](reports/m2-implementation.md). + +**Goal / production outcome:** cold project inspection retains the real data/model/Study/Run/lineage and scientific decision closure, including large unique artifacts, without active authority. + +**Allowed write set:** `project_store.py`, `project_text.py`, `project_workspace.py`; new `project_history.py`, `project_artifacts.py`, `project_session.py`, `project_projection.py`, `project_data.py`, `project_material.py`, `project_producers.py`, `image_sources.py`; `workbench.py`, `preferences.py`, `dataset_releases.py`, `saved_selections.py`, `saved_searches.py`, `pipeline.py`, `model_definitions.py`, `experiments.py`, `experiment_lineage.py`, `candidate_packages.py`, `runtime_snapshots.py`, `recipe_process.py`, `extension_bindings.py`, `extension_canonical.py`, `extension_evidence.py`, `model_evidence.py`, `field_evidence.py`, `one_fit_evidence.py`, `runs.py`, `run_publication.py`, `retained_analysis.py`, `retained_analysis_schema.py`, `immutable_assets.py`, `scientific_fields.py`, `audio_assets.py`, `sequence_assets.py`, `meshes.py`, `pointclouds.py`, `volume_assets.py`, `access.py`, `metadata.py`, `app.py`; Client/UI and Project docs; `docs/contracts/experiment-lineage.md`, `docs/contracts/model-development.md`, `docs/contracts/extensions.md`; Project evidence; `tests/test_workbench.py`, `tests/test_preferences.py`, `tests/test_dataset_releases.py`, `tests/test_saved_selections.py`, `tests/test_saved_searches.py`, `tests/test_model_definitions.py`, `tests/test_experiments.py`, `tests/test_experiment_lineage.py`, `tests/test_candidate_packages.py`, `tests/test_runtime_snapshots.py`, `tests/test_projects.py`, `tests/test_project_snapshot_capture.py`; new `tests/test_project_artifacts.py`, `tests/test_project_session.py`, `tests/test_project_projection.py`, `tests/test_project_data.py`, `tests/test_project_material.py`, `tests/test_project_producers.py`. Candidate/runtime writer changes here only fence unsupported project-mode effects; their live integration is M3. + +**Dependencies / tasks:** M1; bound family/closure inventory against all retained domain tables and managed artifacts, with a codec or explicit unsupported-write disposition for each. Preserve annotation/deletion/review/exposure, response/preference histories and rationale, immutable source/extension/runtime closure, candidate preparation/selection facts, Study accounting, reservation association and cross-Study producer/lineage edges, with the visible-family table providing the minimum scope. Introduce inert historical proof and current disclosure checks, not fake grants. Externalize BYTEA as payloads and implement digest/availability/resolution manifests. Supported metadata writes use the canonical boundary. Active Run creation stays unavailable in project mode until M3; no DB-only bypass. + +**Admitted owner composition:** M1's separate store/text owners remain. M2 adds a narrow shared `ProjectSession` for one outer SQL transaction, complete `(family,id)` checkpoint merge, canonical original-result receipt and post-publication projection fencing. It is internal composition of fixed owners, not a public registry or arbitrary domain RPC. `ProjectArtifacts` owns intrinsic `{format,sha256,bytes}` declarations and verified immutable payload custody; domain edges own namespace, media type, role, unique/regenerable policy and required-use meaning. Data, material and historical proof codecs retain their own closed versioned families. Lead-owned `ImageSources` extracts existing Dataset image policy without constructing Dataset; legacy Dataset delegates to that same owner. Directly affected `one_fit_evidence.py` and `run_publication.py` expose retained reader seams instead of copied interpreters. These filename/write-set refinements follow actual owner inspection, without changing final scope. + +The concrete composition also admits `project_dispatch.py` (fixed public-to-owner calls), `project_sources.py` (explicit current source registration and bounded reference inspection), and their focused tests. Explicit artifact resolution supplies exact bytes from a selected configured local-root file; it does not download, install or regenerate them. A resolution receipt records the past observation, while a separate status read reports current availability. Built-in material preparation remains unavailable before its native runtime probe until M3 owns that effect. Exact historical Run documents use bounded, hash-correlated segments behind current disclosure; public summaries omit protected frozen source fields. Early pure `project_fork.py` / `project_data_fork.py` ownership seams support later M4 construction but do not expose or accept Save As before lifecycle integration and its gates. + +M1 `workbench_text_v1` remains supported without automatic upgrade. New M2 data records use `workbench_record_v1`; operations that require a new format refuse before effects on an older record unless an explicit conversion has been admitted. Historical binding/schema/source bytes establish integrity separately from current host compatibility. Current protected-data disclosure is granted explicitly by local authority and is never reconstructed from imported exposure facts. + +**Gate / risk-based tests:** T2/T4/T7: unique annotation and observation bytes; cross-Study parents/baselines and producer Run inputs; no live grant/credential/control import; missing/corrupt artifacts distinguish unavailable from unverified. Test real cold SQL projections/history readers and representative owned modality codecs rather than training every modality. A family inventory is deciding evidence against silent omission, not a requirement to test every unrelated feature again. + +**Rollback / replan:** retain exact origin records and payloads; do not convert imported archive into active old tables as rollback. Unavailable unsupported families remain visible and complete transfer blocks. Replan for a source proof binding secrets, missing closure owner or any loss of scientific intent. Evidence formats with inseparable secret/control data need explicit disposition before being claimed supported. + +### M3 — Project execution preserves durable facts and local custody + +**Status:** `Verifying` — source complete and enabled after scoped actual execution, recovery, public controls and independent review; final exact-head CI remains. + +**Goal / production outcome:** explicit existing Run/Study execution can operate in an external project without durable facts trapped in SQL, and cannot be resurrected by importing it elsewhere. + +**Allowed write set:** `project_store.py`, `project_history.py`, `runs.py`, `run_coordinator.py`, `run_publication.py`, `experiments.py`, `candidate_packages.py`, `runtime_snapshots.py`, `extension_bindings.py`, `extension_evidence.py`, `model_evidence.py`, `access.py`, `pipeline.py`, `recipe_process.py`, `synthetic.py`, `grounded_candidates.py`, `caption_proposals.py`, `text_classification_proposals.py`, `app.py`, `metadata.py`; Client/UI, Project docs and Project evidence; `docs/contracts/workspace-runs.md`, `docs/contracts/run-coordinator-recovery.md`, `docs/contracts/run-preflight-publication.md`, `docs/contracts/actors.md`; `tests/test_external_runs.py`, `tests/test_runs.py`, `tests/test_experiment_lineage.py`, `tests/test_model_development_http.py`, `tests/test_candidate_packages.py`, `tests/test_runtime_snapshots.py`, `tests/test_synthetic.py`, `tests/test_grounded_candidates.py`, `tests/test_caption_proposals.py`, `tests/test_text_classification_proposals.py`, `tests/test_run_coordinator_recovery.py`, `tests/test_run_publication_recovery.py`. + +**Dependencies / tasks:** M2; enumerate every effectful project writer and ensure it shares project ownership/publication fencing. Canonical admission intent precedes GO while current approval/lease remains local. Publish terminal custody and measured artifacts through the canonical boundary; disposable progress cannot acquire scientific authority. Close/fork/extraction require explicit stop/drain and proof of all relevant descendant effects ending; unknown cases fence. Keep original-host recovery separate from inert imported history and preserve spent budget facts. + +The concrete M3 composition adds `project_execution.py`, `project_study_execution.py`, `project_producer_execution.py` and `project_locks.py`, with focused tests bearing those names. Its shared durability boundary also changes `project_session.py`, `project_projection.py`, `project_workspace.py`, `project_data.py`, `project_material.py`, `project_producers.py`, `experiment_lineage.py` and their existing focused tests. These are fixed domain adapters and an ordered lock facade, rather than a generic persistence registry. A per-projection outer-transaction hook captures named science before SQL commit; the selected canonical checkpoint fences SQL rollback or uncertain acknowledgement before any worker wake. Completed SQL scopes are removed before commit callbacks run. The existing owner retains current local credentials, grants, admissions and custody; imported operation facts never reconstruct them. Construction and health inspection remain inert. + +The finite runtime/custody composition also admits `project_custody.py` and `project_runtime.py`, plus their focused tests. The private original-host index is outside portable content and records exact physical-root/projection bindings and unresolved outcomes; a changed canonical UUID at the same physical root cannot hide it. Explicit runtime capture accepts only current operator-configured source aliases, commits an inert intent before copying bytes, and never probes or launches captured binaries on Open. + +Disclosure observations use an additive `project_disclosure_v2` fact bound to a +finite `run_observation` / `project_run_observation_v1` canonical text record. +That record preserves the exact Run document and payload inventory observed at +the decision. Its content hash is its identity, so later Run output or publication +cannot rewrite the earlier scientific observation. Existing v1 observations keep +their original validation; neither version reconstructs a current disclosure grant. + +The native completion slice admits fixed `project_native_process.py`, +`native_process_bootstrap.py`, `project_native_execution.py` and, where needed, +`project_native_preparation.py`, with their focused tests. The directly affected +`release_validation.py` may expose its own finite pure summary DTO decoder for +classification and whole-asset integrity results; original archive readers retain +scientific/numeric validation, and old summary bytes are not rewritten. Existing native +Pipeline/Runs/Study owners retain policy and scientific meaning. Every probe, +training worker and verifier has an explicit current-owner intent and canonical +ACK before GO through the existing custodian. A native immutable job template +keeps its original bytes; the owned bootstrap creates a separately measured +effective job with the actual helper parent. The helper child arms and rechecks +parent-death protection before exec, closing the helper-death race before +bootstrap starts. Missing full descendant drainage remains unknown. Explicit +preparation happens outside the short admission SQL transaction; a one-use +current in-process validation context supplies that transaction without running +another probe inside it. Imported records cannot create that context. + +The new finite native phase record retains exact small generated proof text +(template, effective job, parent observation, drainage receipt and bounded probe +response) canonically, correlated with its SHA256/byte-count artifact edges. +Source captures, training logs, weights and outputs retain explicit untracked +payload availability. Missing payloads must allow inert metadata Open while +current use and complete copying require exact verified unique bytes. This is a +new owner format, not an upgrade of old history or a general proof registry. + +Native execution retains the existing explicit operator-configured interpreter +policy (`TULDOK_TRAINING_PYTHON`, otherwise the current application interpreter). +Its executable, staged sources, bounded probe/version observations and actual +custody/output evidence are retained. Installed packages and native libraries +remain operator-managed dependencies; observed versions are not a complete +hashed binary closure, reproducibility proof or model-quality claim. This work +does not repeat D1–D4 qualification or require a new runtime-capture workflow. +A configured virtual environment must be qualified through the sealed launch +path with its original interpreter identity/prefix preserved. + +**Gate / risk-based tests:** T5/T6/T7 plus one explicit tiny generic script Run through existing custodian, fixture outputs and fresh historical read. Kill at canonical admission/pre-GO and terminal publication windows; no duplicate dispatch, no imported request/grant reactivation, no false cessation. Real process/PG and public busy/unknown states required, including Open/New/reload refusal during active generation/proposal work and delayed old-generation request rejection. Exercise candidate preparation and runtime capture writer fences/publication without installing or launching runtime binaries. No Rheon/GPU/model-quality campaign is needed for this custody claim. + +**Rollback / replan:** close new admission, retain canonical intent/artifacts and unresolved local custody; disable project execution while read-only history remains usable. No rollback to an older binary over new facts and no abandoned worker ownership. Replan if an effectful writer cannot be fenced or local runtime identity cannot establish safe continuation/cessation. + +### M4 — Independent Save As, relocation and Git revision changes + +**Status:** `Verifying` — independent fork, relocation/Git, explicit note deletion and cold client/browser controls passed on their named subjects; final integrated CI remains. + +**Goal / production outcome:** user forks a complete idle project into an independent directory, moves/rebinds it, or switches Git revisions without corruption, mistaken identity or lost work. + +**Allowed write set:** `project_store.py`, `project_history.py`; new `project_transfer.py`; `metadata.py`, `projects.py`, `app.py`, `pipeline.py`, `model_definitions.py`, `experiment_lineage.py`, `experiments.py`; Client/UI, Project docs and Project evidence; `docs/contracts/experiment-lineage.md`. + +The concrete M4 composition admits `project_fork.py`, `project_fork_composition.py`, `project_data_fork.py`, `project_material_fork.py` and `project_files.py`, their fixed owner integrations, and focused tests. These finite constructors retain original hashed envelopes and derive new editable values; they do not recursively rewrite IDs. `project_operation_v2` namespaces new receipts by project UUID while preserving exact v1 history. Working-file adoption records exact UTF-8 bytes and prior revisions; Save As refuses changed/unadopted files and materializes captured versions only into a new task-owned destination. + +Explicit adoption of an intentionally missing note adds a `project_file_v2` +tombstone while preserving exact earlier `project_file_v1` positive revisions. +This records a reviewed deletion observation; it does not delete files itself. +Open remains inert, Save As does not recreate the deleted current note, and an +explicit later adoption can retain a restored positive revision. Missing scripts +remain protected until separately adopted from actual bytes; no script deletion +or automatic source regeneration capability is inferred from note deletion. + +**Dependencies / tasks:** M2+M3 lifecycle; implement new destination publication, new project/editable IDs and explicit origins, inert inherited history and artifact copy completeness. Rebind physical root with new inode without changing canonical identity. Add strict stale binding/checkout/manual-merge validation, explicit editable-file adoption and Close-gated reload; separate physical runtime namespaces for same-UUID clones. No generic recursive ID rewriting or semantic merge machinery. + +**Gate / risk-based tests:** T3/T5/T6 and T8 Save As/reload/availability paths: independent source/fork edits and newly prepared material, source history hash preservation, moved root, stale browser response, real Git closed checkout and unresolved/structurally inconsistent merge, hard-link/overlap refusal and failed destination publication. Exercise edited script/note adoption versus immutable-capture/history corruption rejection; no automatic artifact generation. + +**Rollback / replan:** source remains byte-identical; retain failed task-owned staging and receipt when uncertain. Remove a failed destination only under proven task ownership and known non-publication; keep any published destination. A fork bug fences fork creation, not source Open. Replan if identity separation requires weakening current immutable contracts or exposing active origin authority. + +### M5 — Explicit mixed-catalog extraction and final acceptance + +**Status:** `Verifying` — extraction source is complete; collector/controller, producer-input/native-source closure and real SDK/CLI/MCP/Chromium component gates passed; full integrated acceptance remains pending. + +**Goal / production outcome:** an owner can preview and explicitly extract a selected legacy project into a proven portable directory while retaining the original; all A claims are closed for the admitted supported product scope. + +**Allowed write set:** `project_transfer.py`, `project_history.py`, `project_store.py`, `app.py`; Client/UI, Project docs and Project evidence; `backup_metadata.py` only if a demonstrated shared safe-copy helper change is necessary, never to redefine backup format; `tests/test_workspace_metadata.py`, `tests/test_metadata_migration.py`, `tests/test_metadata_transactions.py`; new `docs/runbooks/external-project-extraction.md`; `.github/workflows/tests.yml` only to register actual focused gates with nonzero discovery. + +The concrete M5 composition admits `project_extraction.py`, `project_transfer_controller.py` and their focused tests. The collector uses existing fixed domain owners over a pinned read-only source; the controller retains an opaque in-process stopped-source capability and requires separate current destination-host authority after the source closes. Neither JSON, a PID nor a `quiesced` flag establishes that capability. + +**Dependencies / tasks:** M1–M4; implement report-first closure/ambiguity and supported-source checks, stopped backup + external payload inventory preconditions, deterministic extraction receipt/mapping and cold verification. Public surfaces must share selection/completeness/outcome meaning. Complete documentation/capability advertisement only for actually accepted families and operations. Review current affected main/standards and exact material candidate once; reconcile justified findings before acceptance. + +**Gate / risk-based tests:** T9 plus any still-pending A1–A8 procedure on the final semantic candidate, reusing valid unchanged evidence. Representative mixed fixture has unrelated data, shared dependencies, two-Study lineage, unique notes/annotations/payloads and old Access authority. Prove selected closure and original unchanged; no local lab or external-domain service. Preflight real clients/store/browser, assert nonzero selected tests and record blocking unavailable environments. Scope review ensures no unsupported family was quietly dropped to get a pass. + +**Rollback / replan:** no source mutation or deletion; reopen source with compatible binary and separately retain/reconcile destination writes. User migration is a future individually authorized operation, not a gate on shipping the extraction procedure. Replan for ambiguous user scope, unsupported source version, missing unique source or a new irreversible disclosure risk. Do not turn incomplete extraction into successful empty project creation. + +## Blockers + +No missing user decision or unresolved material product review finding blocks the final candidate. M1 and M2 are accepted at their recorded scope; M3–M5 are implemented and undergoing final verification. Required exact-head CI remains the release/merge gate: `3ac3351` passed full discovery, native/public checks, all project browsers and the earlier observation browser, then failed a stale test-wrapper signature before classification HTTP readiness. Both affected wrappers now forward the explicit production keyword; their complete local browser cases passed once. Later CI steps remain skipped and the next changed-candidate campaign is required. The previous timed-out CI Run's final outcome remains unobserved. No product regression has been identified. User-data migration and local lab inspection remain forbidden. Disposable PostgreSQL 17 and Chromium remain available. Performed, failed and unrun claims remain distinct in implementation evidence. + +The initial draft-PR creation was denied by automatic approval review under the earlier planning-only restriction. The user explicitly approved the exact retry at 18:31 UTC; the lead first verified no existing PR, then retried once. [Draft PR #119](https://github.com/MrScripty/Tuldok/pull/119) was created and remains the dedicated integration PR, targeting unchanged main. M2 lifecycle run `39842528122` failed at the project-observation browser fixture; its precise arrival/application race repair is retained in the CI diagnosis. Three other M2 workflows passed. No bypass or merge occurred; no later CI success is implied by local component results. + +Actual per-operation blockers are: unknown source membership during later extraction; missing unique bytes for a requested complete transfer; inseparable secret-bearing legacy proof; unknown active descendant effects; or an unsupported filesystem's inability to provide the promised durable publication. Owners and resolution conditions are in [issues](issues.md). These block only dependent operations, not inert inspection or independent planning. Format naming, no-op Save, single local writer, closed-project Git changes, inert imported history and independent Save As are safe defaults, not a questionnaire for the user. + +## Re-Plan Triggers + +- Changes in user intent/authority, current code owners, actual supported consumers or source persistence formats invalidate a selected boundary. +- A canonical mutation still commits only in PostgreSQL, imported historical state can reach execution recovery, or any exporter silently omits an owned record family. +- A material identity/lineage change would require rewriting historical hashes or a whole-catalog UUID clone for independent extraction. +- Actual filesystem/worker evidence contradicts atomicity, fencing, cessation or no-data-loss claims; live Git/multi-host writes become a requirement. +- Missing closure or unique evidence cannot be represented honestly; secret-bearing proof cannot be preserved without disclosure. +- The design accumulates a second scheduler/registry/domain interpreter or surprising cross-owner changes. Re-run the full composition probe for the replacement. + +Repair failed tests within the same admitted meaning; do not restart an identical evidence campaign. Record material findings and replace current decisions here, preserving dated reasoning in the ledger. Future start/continue/verify must bind this exact repository-relative plan and applicable lifecycle operation under current authority. + +## Concurrent Work And Repository Isolation + +Planning has one serial writer/integrator and an independent read-only reviewer. The reviewer checks the complete material plan against actual pinned code and standards; findings are collected before revisions. A review report is non-authorizing. There are no outstanding mutable integration proposals, so the concurrent-plan-integration profile is not selected. + +The user requested isolation. Original `/workspace/Tuldok` and reviewed planning branch/worktree `plan/external-project-save-load-20261022` at `/workspace/Tuldok-project-save-load-plan` remain preserved. Active integration branch `feat/external-project-save-load-20261022` at `/workspace/Tuldok-project-save-load-impl` starts from local plan commit `2c9c9b098c9f1c4c2557c58818574ebdbc1e7d41`, whose parent is current main `133d53c0e2985a6559ba9cebd7d4a9f530051f06` (reconfirmed before branching). This lead owns integration, review and authorized dedicated draft-PR publication. Workers share this worktree with disjoint write sets; only this lead commits/publishes. No retired lead is resumed, no unrelated README/qualification branch is changed, and incomplete feature scaffolding is not merge-ready. Retain branches/worktrees until dependent retargeting and safe merge/reachability proof; standards checkout remains unchanged reference material. + +## Final Acceptance + +- Acceptance status: `pending` +- This is the committed candidate-admission state. Terminal acceptance is conditional on successful exact-head checks in [PR #119](https://github.com/MrScripty/Tuldok/pull/119): Dataset lifecycle tests, M1 workspace interface, Inert extension catalog and Candidate package captured CPU. The CI product manifest binds evidence to that implementation commit; the PR/check result supplies the terminal run URL, SHA and conclusion. A missing or failed required check leaves acceptance pending. This external result closes the condition without a status-only source change or an identical CI campaign solely to rewrite this field. +- Deferred follow-ups: distributed/live-Git collaboration, other writer platforms/filesystems, artifact auto-fetch/regeneration, semantic merges and automatic pruning; revisit only for a concrete requirement and owned evidence. +- Final status: `Verifying` + +[Independent plan review](reports/independent-review.md) and [planning verification](reports/verification.md) remain historical documentation evidence. Product implementation/results have their own dated ledger/report and independent code review; no planning check satisfies A1–A9. User migration is separately authorized per operation. diff --git a/docs/plans/external-project-save-load/reports/baseline-and-standards.md b/docs/plans/external-project-save-load/reports/baseline-and-standards.md new file mode 100644 index 00000000..0ecf2830 --- /dev/null +++ b/docs/plans/external-project-save-load/reports/baseline-and-standards.md @@ -0,0 +1,69 @@ +# Source baseline and applied standards + +Inspected 2026-10-22. This report records observations and their limits; [plan.md](../plan.md) alone owns current status and decisions. + +## Exact revisions and isolation + +| Source | Exact revision / observation | Use | +| --- | --- | --- | +| Tuldok GitHub `refs/heads/main` | `133d53c0e2985a6559ba9cebd7d4a9f530051f06`, confirmed with `git ls-remote origin refs/heads/main` and fetched `origin/main` | Application source baseline, not the user's local lab truth | +| Original checkout `/workspace/Tuldok` | `HEAD` on `work` at the same revision; clean before investigation | Preserved; no product or user-data edits | +| Planning checkout | `/workspace/Tuldok-project-save-load-plan`, branch `plan/external-project-save-load-20261022`, based on the same main | Only `docs/plans/external-project-save-load/` is an output write set | +| Coding-Standards current default branch | `5bce3110fc64d61ff8b4f79468184bd6295855d5`, cloned from `https://github.com/MrScripty/Coding-Standards.git` into `/workspace/Coding-Standards-readonly` | Current canonical guidance; older Tuldok reports pin `dcc56f26…` and are historical evidence only | +| Applicable instructions | No `/AGENTS.md`, `/workspace/AGENTS.md`, tracked Tuldok `AGENTS.md`, or checkout `.agents/skills/*/SKILL.md` found | No missing application skill or AGENTS rule was assumed | + +The environment started successfully. The planning worktree initially inherited a stale local `origin/main` (`2fc4a46…`); before any document was written, `git fetch origin main` refreshed it and a fast-forward placed the isolated branch on the exact main above. No target branch merge or remote mutation occurred. The original checkout and standards checkout remain separate from the planning output. The standards clone is reference-only. + +## Verified code findings + +All links in this table are immutable application source links at the recorded main. A source inspection proves a structural fact, not product runtime behavior. + +| Finding | Actual source and implication | +| --- | --- | +| Registration is immutable snapshot inspection | [projects.py](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/projects.py): `Projects._open` reads configured roots, stores manifest bytes/digest, root alias and `[st_dev, st_ino]`, and rejects a different snapshot/root for the same ID. It writes no project save format. Existing `close` closes root handles, not a project-document lifecycle. [Project contract](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/docs/contracts/projects.md) and [workspace contract](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/docs/contracts/project-workspace.md) describe registration and explicit imports. | +| Root identity needs relocation treatment | `Projects.__init__`, `_bound`, `_snapshot_identity`, `_read_snapshot_file` in the same source retain physical root identity and no-follow handle traversal. A moved directory or newly configured alias cannot simply replace immutable old snapshot bindings. Portable identity must be separate. | +| Project does not own Studies/Runs | [metadata.py](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/metadata.py): schema10; `workspace_projects` stores only ID/document. Model studies/trials/reservations/lineage and workspace Runs have their own tables, without a Project FK. [experiments.py](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/experiments.py) operates over the catalog. There is no authoritative project membership filter to reuse as extraction. | +| Logical catalog IDs participate in immutable identity | [model_definitions.py](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/model_definitions.py): `publish` builds `model_material_v1` containing `catalog_id`, then hashes it; `material` verifies namespace and digest. [pipeline.py](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/pipeline.py): `prepare_builtin_material`, `prepare_external_material`, `_recipe_material_record` bind model/data/runtime/trainer/material namespaces and retained source bytes. Copying an ID or recursively editing it cannot preserve those hashes. | +| Lineage crosses Studies within the catalog | [experiment_lineage.py](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/experiment_lineage.py) and [lineage contract](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/docs/contracts/experiment-lineage.md): immutable sidecars reference exact catalog/Trial/material identity; baseline and parent are distinct, same-catalog and not restricted to one Study. Extraction must walk both and producer-input closure; new historical-origin representation cannot weaken current active lineage validation. | +| Historical Run readers currently depend on runtime-adjacent rows | [runs.py](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/runs.py): `_study_attempt_metadata`, `_load`, `_public` join reservation/Trial and grant/consumption/admission facts; external material, producer inputs and publication source participate in proof. A portable exporter cannot just omit those tables and expect existing Run readers to work. Preserve inert facts and add explicit historical reading, not fake live authority rows. | +| Startup is not inert reconstruction as written | [app.py](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/app.py): `Dataset.__init__` constructs Access, Jobs, proposals, Pipeline, Runs and Studies. `Runs.__init__` claims runtime and calls `_recover`, which can reconcile and write active/unknown history. Imported history must not flow through this constructor unchanged. [run_coordinator.py](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/run_coordinator.py) remains the live custodian owner. | +| Backup is a different promise | [backup_metadata.py](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/backup_metadata.py): `read_catalog` reads all selected schema tables; `backup_catalog` requires quiescence, copies the managed path tree and stores SQL values/sequence states including Access tables; restore recreates exact catalog identity. External roots configured in `projects.py` are outside that tree. Its JSON transport is a whole runtime-bearing backup, not a portable project contract. [Storage contract](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/docs/contracts/workspace-storage.md) requires explicit stopped upgrade/backup and preservation. | +| Current client surfaces already have common operation authority | [agent_api.py](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/agent_api.py), [tuldok_client.py](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/tuldok_client.py), [project_cli.py](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/project_cli.py), [mcp_stdio.py](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/mcp_stdio.py), [workspace-projects.js](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/static/workspace-projects.js): new file-project actions should use negotiated operations and existing thin adapters, distinct from snapshot registration. | +| Switching needs new lifecycle composition | `app.py`'s `make_handler(dataset, ...)` closes over one Dataset and its long-lived owners. A process-wide project-mode controller must gate replacement through Close/drain and pin every request to its project binding/generation; changing a selected project ID in the UI alone cannot implement this safely. | +| Comparative judgments are independently authored data | [preferences.py](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/preferences.py): `_persist` writes judgment, captured parent/answers/history and audit directly. M2 includes this owner, response/history bindings and reviewed rationale in canonical publication and cold-preservation fixtures. | +| Candidate preparation has filesystem effects and receipts | [candidate_packages.py](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/candidate_packages.py): `_prepare_operation` prepares external files, registers project snapshots and composes model/Trial/operation receipts. M2 retains facts and fences unsupported calls; M3 coordinates its real publication and rollback callbacks with the canonical checkpoint. It cannot remain a DB-only bypass. | +| Runtime and extension closure have existing owners | [runtime_snapshots.py](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/runtime_snapshots.py): descriptor includes catalog identity and host root; explicit snapshot capture publishes manifests/binaries and audits. Preserve manifest identity as history, resolve binaries locally without automatic installation, and integrate/fence the writer. [extension_bindings.py](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/extension_bindings.py), [extension_canonical.py](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/extension_canonical.py), [extension_evidence.py](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/extension_evidence.py) and [model_evidence.py](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/model_evidence.py) retain/interpret contract, source, selected contribution, provider/runtime and input identities; historical reads must preserve this exact closure rather than inventing a persistence-owned interpreter. | +| Existing architecture is reusable | [Model-development decision](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/docs/decisions/model-development-architecture.md), [completed plan](https://github.com/MrScripty/Tuldok/blob/133d53c0e2985a6559ba9cebd7d4a9f530051f06/docs/plans/end-to-end-model-development/plan.md): Pipeline owns editable recipes, models own computation material, Experiments owns finite intent/budget/selection, Runs owns custody and Access owns current authority. This plan adds portability boundaries without repeating D1 design or claiming its historical numerical evidence as new validation. | + +Inspected relevant existing tests as source/placement evidence: `tests/test_projects.py`, `tests/test_project_snapshot_capture.py`, `tests/test_experiment_lineage.py`, and the tests/fixtures and browser paths named in the plan. No application test execution, training or catalog access occurred. The presence of `adapters/rheon` in the application source is not treated as a requirement to add domain knowledge to persistence; no local lab path was opened or enumerated. + +## Standards routing and concrete application + +The current guidance was read directly from the pinned checkout. No Standards Engine application MCP is exposed. This is manual application of canonical guidance, not an Engine routing certificate. Do not invoke authoring tools or modify the standards library to manufacture one. + +All standards links below pin `5bce3110fc64d61ff8b4f79468184bd6295855d5`. + +| Guidance | Applied design/evidence decision | +| --- | --- | +| [Core](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/CORE-STANDARDS.md), [Router](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/STANDARDS-ROUTER.md) | Select planning/documentation/verification/commit from actual activity, and future persistence/security/concurrency boundaries from the proposed behavior. No language/framework profile applies to this Markdown change. | +| [Planning](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/workflows/planning.md), [discovery](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/workflows/planning/discovery.md), [template](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/templates/PLAN-TEMPLATE.md) | User has supplied material intent/constraints and asks for implementation sequencing, not another broad discovery/D1 effort. Bounded source verification precedes this plan. Explain proposed direction, distinguish safe defaults from true operation blockers, retain one current phase/next slice and explicit start authority. No separate visual/discovery campaign is needed to recover already explicit intent. | +| [Implementation](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/workflows/implementation.md), [proportionality](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/workflows/development-proportionality.md) | Small first production vertical includes real UI; expand for distinct evidence/lifecycle risks, not layer/file/commit counts. No full-suite, evidence-volume or repeated-identical-campaign requirement. Missing user migration authority does not prevent finishing this plan. | +| [Architecture](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/topics/architecture.md), [code design](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/topics/code-design.md), [immutable closure](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/topics/architecture/replay.md) | Complete eight-probe composed review; one coherent checkpoint store with domain-owned values, exact transitive retained proof and disposable SQL. Archive identity/current permission are separate. No umbrella schema policy, generic scheduler/registry or mirrored authority. | +| [Contracts](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/topics/contracts.md), [evolution](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/topics/contracts/evolution.md), [schemas](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/topics/contracts/schemas.md), [protocols](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/topics/contracts/protocols.md) | Closed v1 format; explicit independently scoped versions; new file-project operations do not reinterpret existing registration. Byte identity is distinct from semantic equality. Reject future/malformed states without fallback and preserve typed outcomes through every client. | +| [Persistence](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/profiles/boundaries/persistence.md), [IPC](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/profiles/boundaries/ipc.md), [concurrency](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/topics/concurrency.md), [resilience](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/topics/resilience.md) | Define precondition, atomic checkpoint publication, operation reconciliation, stale/busy/unknown outcomes and cold rebuild. Canonical files win after SQL failure. Source-preserving explicit extraction and stop/drain before transfer; no runtime resume on imported history. | +| [Security](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/topics/security.md), [cross-platform](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/topics/cross-platform.md), [diagnostics](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/topics/diagnostics.md) | Bound selected-root filesystem authority and no-follow operations; distinguish root physical identity from portable logical identity. No secrets/runtime control in exports. Report exact safe operation outcomes/completeness without raw DSNs or host details. Existing explicit execution authority stays with its owners; no new sandbox/delegated execution design is claimed. | +| [Verification](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/workflows/verification.md), [oracles](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/workflows/verification/oracles.md), [GUI](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/workflows/verification/gui.md), [platforms](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/workflows/verification/platforms.md) | A1–A9 identify kind/environment/mode; T1–T9 observe real boundaries and independent authored values. Negative cases reach the intended boundary. Preflight/nonzero selection, stable reviewed material and sufficient deciding evidence replace repeated campaigns. No Linux result certifies other filesystems. | +| [Frontend](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/profiles/applications/frontend.md), [accessibility](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/topics/accessibility.md), [library](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/profiles/applications/library.md), [launcher](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/profiles/applications/launcher.md) | Prospective UI, SDK and CLI consumers project the same contract; per-session stale-result isolation, exact CLI outcomes and keyboard/focus recovery have explicit tests. No new frontend store or CLI domain logic. | +| [Documentation](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/workflows/documentation.md), [Commit](https://github.com/MrScripty/Coding-Standards/blob/5bce3110fc64d61ff8b4f79468184bd6295855d5/workflows/commit.md) | Repository-native plan/ledger/issues/evidence; future contract/ADR/runbook owners named. User-required private isolation, reviewed docs-only staged diff, local commit and retained-protected resource contract. No publication or standards edits. | + +Requires closures were followed through Core/Implementation/Verification/Contracts/Security as applicable. This task selects no release/package-distribution, new dependency, FFI/native binding, generated executable schema, or concurrent-plan-integration work. Future CI registration in M5 must route Tooling when its concrete test-selection change is made; it is not authorization to change CI during planning. Public schema projections remain subject to their actual generation facts rather than a speculative new generator. + +## Alternatives and deciding observations + +1. **Whole-catalog JSON backup as Save:** rejected. It includes runtime/Access state, preserves one catalog identity, misses external roots and does not define project membership. Its readable container does not meet the user's authority/fork requirements. +2. **Export DB snapshots only when Save is clicked:** rejected. Acknowledged edits would be DB-only authority until Save and could be lost after a crash/database loss. A second unsaved authoritative store contradicts reconstructibility. +3. **Stable mutable record filenames plus multi-file recovery journal:** credible, but introduces a second recovery protocol because an old checkpoint could point to newly overwritten files. Default immutable revision files plus one current inventory reduce that obligation; their cost is retained text revisions and explicit manual inventory merges. Pruning/semantic merge are deferred. +4. **Keep catalog UUID in every independent fork or recursively rewrite IDs:** rejected. Shared mutable identity or changed historical material hashes would misrepresent evidence. Independent new active namespace plus inert origin-scoped facts preserves both. +5. **New project super-object owning science, models and execution:** rejected. Existing Pipeline/Models/Experiments/Runs/Access ownership is already selected by completed D1–D4 work; persistence packages their values without taking semantic authority. + +No external service or numerical assumption must be tested to select these boundaries: the relevant code structure is inspectable. Actual local filesystem publication, projection cutover and Run barriers are future M1/M3 design observations with explicit stopping conditions. Their product acceptance remains pending. diff --git a/docs/plans/external-project-save-load/reports/ci-3ac3351-handler-fixtures.md b/docs/plans/external-project-save-load/reports/ci-3ac3351-handler-fixtures.md new file mode 100644 index 00000000..85266096 --- /dev/null +++ b/docs/plans/external-project-save-load/reports/ci-3ac3351-handler-fixtures.md @@ -0,0 +1,92 @@ +# Browser handler fixtures after the cold-Study repair + +The [plan](../plan.md) owns current status. This report records the third +complete-candidate campaign and the bounded fixture compatibility repair. + +## Exact failed subject + +Feature head `3ac3351a741cf5e6ef4712e3577f3fc0d3a3f3e5`, based on main +`133d53c0e2985a6559ba9cebd7d4a9f530051f06`, ran lifecycle +[39893561852](https://github.com/MrScripty/Tuldok/actions/runs/39893561852), +job `120218652037`. The decoded root checkout confirms PR merge +`da7ec49d55b4bf6d8186c02d81918133de718d28`; merge and feature share tree +`06193f266222f32423c24c1677a7458d9895360a`. Coding-Standards remains +`5bce3110fc64d61ff8b4f79468184bd6295855d5`. Both remote baselines were +rechecked unchanged before this diagnosis. + +Full discovery ran 2,332 cases in 3,014.033 seconds: **OK**, zero failures, +zero errors, 107 optional/intentional skips. This clears the earlier cold-Study +wait failure on the changed candidate; the previous CI Run's unobserved final +outcome remains unobserved. The three other workflows succeeded: M1 interface +`39893561721`, extension catalog `39893561904`, captured CPU `39893561748`. + +| Required real gate | Cases | Seconds | Result | +| --- | ---: | ---: | --- | +| Native Run/fork/Study/accounting | 3 | 839.297 | OK, no skips | +| Native SDK/CLI/MCP | 1 | 32.187 | OK, no skips | +| Note deletion/cold-fork transports | 1 | 14.092 | OK, no skips | + +All six project Chromium gates (steps 45–50: authoring/cold reconstruction, +independent fork, runtime, extraction, native recovery and note deletion) passed. +The earlier observation browser (72) also passed. These are scoped successful +results; lifecycle as a whole failed at step 152 and later checks 153–180 were +skipped. No terminal product acceptance is claimed. + +## Failure and bounded repair + +`node tests/browser_text_classification_proposals.cjs` stopped before HTTP +readiness: `app.main` passes `transfer_controller` to `make_handler`, whose +production signature has an explicit keyword-only argument. The fixture's +`instrumented_handler(dataset, observer=None)` wrapper still had the old +signature and raised `TypeError: instrumented_handler() got an unexpected +keyword argument 'transfer_controller'`. No proposal flow ran; this is not a +proposal assertion failure or observed proposal race. + +A read-only scan found exactly one other current `app.make_handler` replacement, +`tests/browser_pumas_typed_server.py`, with the same stale wrapper. Its configured +browser gate had not been reached; its incompatibility was found in source, +not observed as a second failed CI case. Frozen legacy HTTP factories are +separate and remain unchanged. + +Both wrappers now mirror the explicit keyword-only `transfer_controller=None` +argument and pass its value unchanged to the original handler. Admission/refusal +instrumentation, test endpoints, gateway configuration and all browser assertions +remain unchanged. No generic keyword swallowing, product change, workflow change, +timeout increase or test exclusion is introduced. + +Independent source review cleared both exact files before execution: + +| Fixture | SHA256 | +| --- | --- | +| `tests/browser_text_classification_proposals_server.py` | `ec24aee7f1b26ca31724489c05d6bde975213596e1613f5f5c21b2410dbf7ee8` | +| `tests/browser_pumas_typed_server.py` | `0c7244000a2d33d27f457bcc9b49ab17f71916a2345df7c548ceca366671f1e3` | + +The deciding local selection is one complete Chromium run of each affected +browser against existing disposable PostgreSQL and synthetic HTTP endpoints. +No native gateway/model fit, broad suite or same-head CI rerun is selected. +Both complete procedures passed exactly once with exit 0, and both retained the +browser assertion that `Runtime.exceptionThrown` is empty. All 912 tracked +source/test hashes were unchanged across execution, including both reviewed +wrapper hashes. Chromium's environment DBus/OOM-score diagnostics remain in raw +logs; they are not application exceptions. This scoped pass does not make the +failed CI campaign successful; the later repository gates still require the +changed-candidate campaign. + +## Retained evidence + +Under `/workspace/scratch/tuldok-save-load-qa/artifacts/`: + +| File | SHA256 | +| --- | --- | +| `lifecycle-39893561852-3ac3351.log` | `7fd2b8ba16a5d50bcb717ecc414ba7f9a99c41920e6ffec6e0c162e6db5aac5a` | +| `exact-candidate-ci-3ac3351-39893561852.json` | `04a4486b2f98c6f5aaa0142ed827d2d3bed3b503e7fc7785b492e11530860574` | +| `browser-handler-3ac3351-repair-receipt.json` | `f6bb8d42ea58dc5ec791cc2a507a109bdaff5298ec32fced3ba22cb82beebd35` | +| `browser-text-classification-proposals-3ac3351-handler-repair.log` | `3a9a92416eccf128698b72e962ca087438ec51e6374e433c21aea0dd73d55100` | +| `browser-pumas-typed-3ac3351-handler-repair.log` | `b7e6c36ab2cc47c8e01c48ee895545220d6b528009df53a8e9bfdee649d08e80` | + +Artifact `12479940595` uploaded successfully; no download was attempted and its +manifest remains uninspected. No previously denied retrieval route was retried. +Available checkout logs and independent Git tree reads identify the subject. +Earlier failures and unrun gates retain their original status. The PR stays +draft until a complete changed-candidate campaign and final review close the +plan's acceptance condition. diff --git a/docs/plans/external-project-save-load/reports/ci-5704965-repair.md b/docs/plans/external-project-save-load/reports/ci-5704965-repair.md new file mode 100644 index 00000000..be075316 --- /dev/null +++ b/docs/plans/external-project-save-load/reports/ci-5704965-repair.md @@ -0,0 +1,124 @@ +# Full-suite CI diagnosis and bounded repair + +The [plan](../plan.md) owns current status and acceptance. This report preserves +candidate-specific outcomes; a passing subgate does not accept the whole feature. + +## Failed subject and verified results + +Feature head `5704965d1a441946aacf3548e0b9f57dc4312d35` ran in PR 119 against +main `133d53c0e2985a6559ba9cebd7d4a9f530051f06`. The root checkout log confirms +GitHub test merge `28a49775c42564972db64cb5503c3200476a9f0a`; both commits have +Git tree `31d69890b0f019349a5106e53337d19c9f2fbfe6`. The separate pinned legacy +checkout is not confused with this product checkout. Current standards remain +`5bce3110fc64d61ff8b4f79468184bd6295855d5`. + +Lifecycle run `39874125909`, job `120155445651`, failed at full discovery: +2,332 tests in 2,179.457 seconds, 6 failures, 2 errors, 107 optional/intentional +skips. M1 interface `39874125884`, extension catalog `39874126119` and candidate +CPU `39874125893` succeeded. Decoded lifecycle logs separately establish: + +- Native Run/fork/Study/accounting: 3 cases, 525.522 seconds, OK, no skips. +- Native SDK/CLI/MCP: 1 case, 22.140 seconds, OK, no skips. +- Note deletion/independent cold fork transports: 1 case, 10.541 seconds, OK, + no skips. +- Later browser gates 45–50 and the previous observation-fixture repair at 72 + were skipped, not accepted on this candidate. + +The earlier apt/ffmpeg provisioning delay resolved without source changes, +cancellation or another run. No supported cause was inferred from elapsed time. + +Raw decoded log: +`/workspace/scratch/tuldok-save-load-qa/artifacts/lifecycle-39874125909-5704965.log`, +SHA256 `5dcef7b25ac80f9d8efb0f1c556f40796a6d748f8cdd213a84f7de19896435d4`. +Compact receipt: +`/workspace/scratch/tuldok-save-load-qa/artifacts/exact-candidate-ci-5704965-39874125909.json`, +SHA256 `025258dc14cf7a62c5c4a3f3d186c3653ec0d64d2abcfb8bdd145d13935a944f`. + +Artifact upload succeeded as `12471242268`. The connector returned a reusable +ZIP reference, but the first local retrieval was denied by the proxy with HTTP +403 before receiving bytes. That operation stopped with no retry or alternate +route. Artifact manifest bytes remain uninspected; checkout identity and counts +above are established by the available log and independent Git data instead. + +## Eight fixture failures and repair boundaries + +Independent source triage found no demonstrated product regression. Repairs are +confined to the seven affected test files and their shared HTTP fixture helper; +production checks and protocol policy stay intact. + +| Failed case | Cause and bounded repair | +| --- | --- | +| `test_project_dispatch.Dispatch.test_explicit_preparation_keeps_runtime_and_sequence_semantics_with_owners` | Opaque `pipelines.get` Mock cannot pass the finite saved-graph classifier. Author the intended valid external-recipe graph and keep the owner/runtime/sequence assertions. | +| `test_project_files_material_http.ProjectFilesMaterialHTTP.test_fresh_neutral_material_preparation_and_cold_refs_are_nonexecuting` | Replacing the entire `app.Dataset` class with a Mock breaks a real `isinstance` check. Keep the actual type and place the no-construction tripwire on `Dataset.__init__`. | +| `test_managed_model_workflow.ManagedModelWorkflowTests.test_trial_runtime_identity_and_budget_include_measured_managed_verification` | Expected budget omitted the separate bounded READY-to-GO owner wait. Include `recipe_process.GO_WAIT_SECONDS` in the independently derived expression; do not change admission policy or old historical charges. | +| `test_project_files_clients.Consumers.test_cli_named_lifecycle_and_json_text_commands_use_registry` | Latest negotiated get is v2. Retain explicit legacy-v1 fallback coverage. | +| `test_project_files_clients.Contracts.test_additive_negotiation_preserves_installed_legacy_and_mcp_contracts` | Latest get/annotate legitimately negotiate v2; original v1 contracts remain exact. | +| `test_project_files_fork_contracts.ForkTextContracts.test_old_pins_stay_exact_and_only_v2_accepts_closed_fork_origins` | Exact version inventory must include native execution-health v2 while freezing its v1 hash and original fork rejection checks. | +| `test_project_projection.ProjectProjectionTests.test_unknown_receipt_is_rejected_before_invoke_and_sql_or_checkpoint_changes` | The forged v2 envelope omitted project identity/namespaced ID, reaching structural invalidity before unknown-operation rejection. Use a structurally valid v2 envelope and preserve no-invoke/no-SQL/no-checkpoint effects. | +| `test_project_runtime.RuntimeCaptureTests.test_sigkill_after_native_publication_retains_exact_private_intent_without_final_ack` | Child `python -c` could not import the test helper. Supply its root/tests search path explicitly; retain actual SIGKILL -9, exact private intent and no final acknowledgement assertions. | + +Three disjoint test owners and an independent read-only reviewer handle these +repairs. Only failed cases and necessary distinct controls run locally. No full +suite or native fit is repeated locally, and no same-head CI rerun is requested. +A changed candidate receives one exact-head campaign after targeted checks and +review. Qualification is not inferred for skipped browser stages. + +## Targeted repair evidence + +The public contract owner ran six selected checks in 7.530 seconds, all passing +without skips. These include the three failed assertions and distinct legacy +fallback/pin controls; all 238 original pins remain exact. Log SHA256 +`3bcd9956ccdd3057c0706c4260122c38e36ac13885c0a38b89684695108528e5` +is retained at `public-contract-ci-repair.log` beneath the QA root. + +The projection/runtime owner ran both failed cases in 4.215 seconds, passing +without skips. A separate clean-child-path control passed in 2.054 seconds +without an inherited tests search path. The actual child still received SIGKILL +(-9), and private intent/no-final-ack assertions remain. Logs beneath +`artifacts/` are `ci-39874125909-projection-runtime-fixture-repair.log` +(SHA256 `e7936287176118153282f72b3c1bd191a3dd1b57047942447df3011a0d6bbee6`) +and `ci-39874125909-runtime-clean-child-path.log` +(`f34e6e7f913076dc37c45f34ecea961f3b41c016fe5d78485689e31c50b94824`). + +The domain owner's first selected three-case check passed Dispatch and managed +budget, but advanced the HTTP fixture to a second stale expectation: cold Open +now legitimately reconstructs authored Study/Trial metadata. The corrected +assertions require their exact IDs and counts while current epochs, Runs, +credentials, grants, reservations and execution authority remain absent. This +does not relax the no-execution or no-live-authority boundary. + +The next HTTP-only check timed out at the helper's 15-second client deadline +before observing those assertions. Its server outcome is unknown: cleanup +removed the disposable schemas, and available logs show neither a server error +nor a supported lock/concurrency cause. Both failures remain evidence. A finite +fixture-only deadline now allows the existing 60-second catalog-local wait plus +the existing 15-second transport/reply allowance; all other fixtures retain +their 15-second default. No product latency SLA is inferred or changed. + +The final HTTP-only check passed one selected case in 23.480 seconds, zero +failures/errors/skips. Disposable source/cold PostgreSQL preflight passed; 20 +actual domain operations, separate-role source CONNECT denial, exact metadata +and closure, canonical-byte preservation and all no-execution tripwires passed. +No production file changed. Dispatch and managed-budget passes are retained +from the initial three-case check, whose overall result remains FAIL because +the HTTP tail stopped there. No already-passing case was repeated. + +Domain logs beneath `artifacts/ci-domain-fixtures-39874125909/`: + +| Evidence | SHA256 | +| --- | --- | +| `initial-3.log` (two passes, HTTP authoring assertion FAIL) | `fad0cd54239ce9b81fcdd2d6b1cfc932a40a63696ea25cff4d181725b7a9e058` | +| `corrected-http-only.log` (client deadline ERROR, outcome unknown) | `93dc4679aeee91cfa4bc4e7b7889d08e5b86efa9066dfde06e8e821ec3daa0b7` | +| `final-http.log` (one complete PASS) | `d65121ced653b50b7ede1362b636fd75f0c22e9a98201aeb1fa1966235bb9140` | +| `final-http-receipt.json` | `fb92bc6684ceb092f3c52373a96165c485d23b915829daac3e7785f69c1f1452` | + +All eight original failed cases now have scoped passing evidence. Full-suite +and later browser acceptance still require the changed candidate's CI, rather +than inference from these local repairs. + +The independent reviewer cleared all eight test files at exact subject SHA256 +`9472f124392cf9e2c6cb36cadcd2ddf76ae33f2c279619e5222976b93b0b1688` +(SHA256 of compact sorted UTF-8 JSON mapping relative paths to file SHA256, +without a trailing newline). Review retained all old contract pins, legacy +fallback, no-effect/no-authority assertions and real SIGKILL; no product finding +remains. Documentation finalization is reviewed separately before commit. diff --git a/docs/plans/external-project-save-load/reports/ci-591eb24-study-wait.md b/docs/plans/external-project-save-load/reports/ci-591eb24-study-wait.md new file mode 100644 index 00000000..34c3e6f8 --- /dev/null +++ b/docs/plans/external-project-save-load/reports/ci-591eb24-study-wait.md @@ -0,0 +1,92 @@ +# Study drain deadline after the fixture repair candidate + +The [plan](../plan.md) owns current status. This report preserves the second +complete-candidate CI outcome and the narrow deciding repair. + +## Exact failed subject + +Feature head `591eb24de3ae9ccd219d0fc12bda6c34663a13d5`, based on main +`133d53c0e2985a6559ba9cebd7d4a9f530051f06`, ran lifecycle `39884356869`, +job `120188935444`. The decoded root checkout confirms merge +`455c08cef97e21afea0382281fff01b49a782c9f`; feature and merge share Git tree +`94deb443164152f279e95f1b314b2bd899ef38ec`. Coding-Standards remains +`5bce3110fc64d61ff8b4f79468184bd6295855d5`; remote source and standards were +rechecked unchanged during this campaign. + +Full discovery ran 2,332 cases in 3,288.197 seconds: one failure, zero errors, +107 optional/intentional skips. All eight prior failures are absent. M1 interface +`39884356864`, extension catalog `39884356930` and captured CPU `39884356868` +succeeded. Named real gates separately passed without skips: + +| Gate | Cases | Seconds | +| --- | ---: | ---: | +| Native Run/fork/Study/accounting | 3 | 913.214 | +| Native SDK/CLI/MCP | 1 | 44.171 | +| Note deletion/cold-fork transports | 1 | 19.799 | + +Project browsers 45–50 and observation browser 72 were skipped, not accepted. +Artifact `12475845576` uploaded successfully; no download was attempted, and +its manifest remains uninspected. No previously denied retrieval route was +retried. Available checkout logs and independent Git trees identify the subject. + +## Sole remaining failure and limits of observation + +`ProjectStudyCustodyTests.test_cold_study_spent_budget_lineage_and_approval_remain_inert_while_fresh_attempt_is_current` +stopped at `tests/test_project_study_execution.py:426`: the inherited +`ProjectRunCustodyTests.await_drained` defaulted to 30 seconds. The last observed +fresh Run `5d790f16af4f4e13b3df5bbff817c057` was `running/go_intent`, with no error, +11.051 seconds measured queue and 6.514 seconds launch verification. That snapshot +does **not** prove delivered GO, final completion, settlement or the later +accounting/lineage assertions. No load, concurrency or product regression cause +is inferred from elapsed time. + +The actual neutral recipe declares 10 seconds execution. Its existing Pipeline +admission reserves 10 READY + 2 setup + 60 owner GO wait + 0 post-GO verification ++ 15 cleanup seconds: 97 total. The fixture's 30-second observation deadline is +below that supported budget. + +The one-case repair derives `drain_timeout` from its actual immutable composition +envelope's `admitted_wall_seconds`, plus twice the existing 60-second catalog-local +timeout for terminal publication and observation: 217 seconds here. Both Runs in +this same cold-Study case use the finite bound explicitly. The shared 30-second +helper, every other case and all product limits remain unchanged. Completed state, +zero active workers, exact spent/settled envelope, lineage, fresh approval and +absence of historical runtime authority remain required. + +Independent read-only review cleared that contract before execution at test-file +SHA256 `183f8a3e8e15231e4ea3584b309be18318befe82cac158eb42a0f52e1a89a33c`. +Only the failed real PostgreSQL case is selected locally, using two generic +neutral script Runs, no native training or model fit. No identical full local +suite or same-head CI rerun is used to replace the failure. + +## Retained evidence + +Under `/workspace/scratch/tuldok-save-load-qa/artifacts/`: + +| File | SHA256 | +| --- | --- | +| `lifecycle-39884356869-591eb24.log` | `272280e4bb2d08e00f9291b4258fa164d7719b0cc302a4cf07b6d4c8b13f892b` | +| `exact-candidate-ci-591eb24-39884356869.json` | `628a3e99d39b8df08d3786a7bc09ec4021aaae892c3e73334e76bd42c19fe215` | + +## Targeted deciding result + +The one selected real PostgreSQL case passed in 121.259 seconds, zero failures, +errors or skips. Existing database preflight passed; all 122 recorded production +and fixture source hashes stayed unchanged. Both generic neutral Runs completed. +All original assertions passed: exact twice-spent envelope, settlement, lineage, +cold zero operational tables, historical replay denial, fresh grant/Run identity, +one current SQL Run and reservation, and absence of the historical SQL Run. +This new case result does not establish the prior CI Run's unknown final outcome. + +Evidence beneath `artifacts/ci-neutral-study-591eb24/`: + +| File | SHA256 | +| --- | --- | +| `actual.log` | `42049d43f6e2ee8e71de0015203c9185897ca387bec4671e396b4544b7860e2a` | +| `receipt.json` | `4a0e529fd0de47a2a6f887454998e9694792cbae4411bfcd04e328ed1109b60c` | +| `subject.json` | `de2273208541e6d0671339ad5059c648addbce4afc0c8609759797bb0b77366b` | + +Independent rereview inspected the actual receipt/log, verified the released +test hash against the pre-execution subject and cleared the repair. No product +finding remains. One changed candidate proceeds to CI; complete acceptance still +requires successful exact-candidate CI and the unrun browsers. diff --git a/docs/plans/external-project-save-load/reports/implementation-review.md b/docs/plans/external-project-save-load/reports/implementation-review.md new file mode 100644 index 00000000..77315126 --- /dev/null +++ b/docs/plans/external-project-save-load/reports/implementation-review.md @@ -0,0 +1,133 @@ +# Implementation review result + +The [plan](../plan.md) owns current acceptance and blockers. This report records +independent review scope and dispositions; it grants no execution or merge +authority. The separate [planning review](independent-review.md) remains intact. + +## Reviewed subjects + +The complete native integration review used an immutable source copy, manifest +SHA256 `d9f7fcc6e0a79483d8d48820acd78f4638b20f6077aff0fe81d62efec454da6e`. +All 824 product/test/static/schema files matched before and after review. Earlier +unchanged owner reviews remain valid only for their named subjects. The reviewer +examined actual producers, cold decoders, custody, public consumers and the +selected plan against Coding-Standards +`5bce3110fc64d61ff8b4f79468184bd6295855d5`. + +Three medium findings required source changes: + +| Finding | Resolution and deciding evidence | +| --- | --- | +| Individually valid native phase jobs could disagree with their Run's scientific context. | Compare exact jobs derived by the existing image/text/audio scientific owners, preserving observational paths. One pure case rejected 16 internally rehashed forgeries; exact-source rereview cleared `project_native_execution.py` SHA256 `28b51a914087ac29a17d7665960b5d19d36b6edbe18858514b2cf96fc3ee4fe9`. | +| An exception after entering spawn but before returning a Handle left dispatch available. | Typed `outcome_unknown` fences the Session/coordinator. An actual HTTP/PostgreSQL control covered callback-entry/OSError, no Handle, denied redispatch and unknown recovery; rereview cleared the same source. Receipt SHA256 `454dcee734d258b76e1213a1966324477daa028b4f180864b7f6c479af3453f6`. | +| Explicit note deletion promised by the plan was absent. | CAS-bound note-only tombstones retain all prior text; restoring uses ordinary explicit adoption. Ten backend PostgreSQL cases, real SDK/CLI/MCP and Chromium/cold-fork controls passed. Full bounded public/backend rereview cleared manifest SHA256 `dc276867f7e1095a6c8a6a2578450dd0eaf6c974775d2adc7ad7f85c53f3214f`. | + +Actual native Save As subsequently exposed a fork-intent decoder error. The +finite preparation adapter now distinguishes an explicit draft intent before +complete-graph execution hashing, while requiring the original complete graph's +observation closure. A no-fit PostgreSQL regression and the actual retained Run's +independent fork/cold path passed. Targeted rereview cleared +`project_native_preparation.py` SHA256 +`967a368da9faa476e366ec66f047c1c463d08edfe71a1a4ae9faa15476f68404`. + +Other resolved findings are indexed in [issues](../issues.md): immutable Run +observations, original-host recovery correlation, source/control separation, +native unique-payload precedence, bounded phase capacity and original staged +source extraction. Their reports retain the narrower evidence and exact source +subjects. No unresolved material product finding remains in these reviewed +subjects. This does not assert exact-head CI success. + +## Evidence and final gate boundaries + +The direct native case trained and separately verified a real CPU Run, copied +an independent project and reconstructed it through a separate PostgreSQL role. +Its metadata-only Git tail initially expected the wrong typed missing-artifact +error. The corrected tail passed against the same retained Run without fitting +again; the original whole-case result remains ERROR. + +The Study's original case and retained tail each stopped at a fixture assertion: +fresh continuation legitimately creates a new operation receipt, and a new Run +completed after the fixture's arbitrary 180-second deadline. Both Runs and both +settled reservations were subsequently verified from the exact retained proof, +including 79 artifact hashes. Final original-host active-SQL counts were not +observed. A test-only deadline derived from the existing native admission budget +was independently approved; final full cases remain selected for CI. + +Final targeted review cleared portable CI selection at workflow SHA256 +`9bbe1cf75444310b0214a365bc6db89600ee960ae326996bcc92e91e8c71aad0`, +the exact bounded wait corrections (Study `6c08d170…`, Run `5429fdef…`), current +issue dispositions and the enabled Workspace matching the qualified copy. +Exact/nonzero counts and no-skip requirements remain in every named real gate. +The original full discovery has no native GO flag, so it does not duplicate +those fits. Current documentation finalization is separately reviewed before +commit. Evidence is reused only across unchanged claim boundaries; no identical +training campaign or evidence-volume target is required. + + +The final bounded contract/status read is clear with no remaining finding. +Contract SHA256 `b349f1765440d369ede7f06d68b5ddae02f1a5685775796124b41d3799dc578b` +and plan SHA256 `d5bcbb141b82aaba11de1c64dac05a101e391ac9c10cacb87f7b90d8932d4c1f` +match the finite native/deletion owners and truthful Verifying/conditional CI +status. The reviewer performed read-only inspection, no tests or edits. This +clears the commit/publication review gate, not runtime acceptance or merge. + +## 2026-10-23 — Failed-CI fixture repair review + +Independent read-only review examined all eight failed CI cases against actual +production boundaries. The repaired eight-test-file subject is SHA256 +`9472f124392cf9e2c6cb36cadcd2ddf76ae33f2c279619e5222976b93b0b1688`, +computed from compact sorted UTF-8 JSON mapping relative paths to SHA256 with +no trailing newline. No production file changed. Review cleared the finite +graph fixture, READY-to-GO budget term, exact legacy pins and explicit fallback, +well-formed negative receipt and actual child SIGKILL path. The HTTP fixture +preserves the real Dataset type, rejects its construction, and distinguishes +restored authoring metadata from absent current epochs and live authority. +Its scoped 75-second client bound permits the existing catalog-local wait and +reply allowance while all other fixtures keep their 15-second default. + +All eight failed cases have targeted passing results; the separate clean-child +import control and final HTTP receipt were inspected. Earlier full-suite and +HTTP failures remain recorded, with the timed-out Open outcome explicitly +unobserved. A documentation evidence-path typo was corrected during review. +No unresolved source finding remains in this repair subject. See [CI repair +evidence](ci-5704965-repair.md) for exact results. Final exact-candidate CI and +later browser gates remain required; this review does not grant runtime +acceptance or merge. + +## 2026-10-23 — Cold-Study fixture deadline follow-up + +Changed-candidate CI `591eb24` cleared the previous eight failures and retained +one 30-second wait expiry in the cold-Study case. Independent read-only review +confirmed the real 97-second admission and cleared the case-only 217-second +publication/observation wait, test file SHA256 +`183f8a3e8e15231e4ea3584b309be18318befe82cac158eb42a0f52e1a89a33c`. +Only two explicit waits in that case change; shared helper and product policy +remain exact. The selected real case passed in 121.259 seconds with zero skips +and all 122 subject hashes unchanged. All original completed/drained, +spent/settled, lineage and historical/current authority assertions passed. + +Rereview inspected the actual receipt and exact released source; no finding +remains. [Study wait evidence](ci-591eb24-study-wait.md) retains the failed CI +Run's unobserved final outcome, scoped new pass and pending exact-head/browser +gates. No broad local suite, native fit, reviewer test or source edit ran. + + +## 2026-10-23 — Browser handler compatibility follow-up + +Candidate `3ac3351` passed full discovery and all project acceptance browsers, +then failed before classification HTTP readiness at an old fixture wrapper. +Independent read-only source review cleared explicit keyword-only +`transfer_controller=None` forwarding in the classification fixture SHA256 +`ec24aee7f1b26ca31724489c05d6bde975213596e1613f5f5c21b2410dbf7ee8` +and the identical source-found Pumas fixture SHA256 +`0c7244000a2d33d27f457bcc9b49ab17f71916a2345df7c548ceca366671f1e3`. +All instrumentation and browser assertions remain unchanged; no generic keyword +swallowing or product/workflow change is admitted. + +Both complete real PostgreSQL/Chromium browser procedures subsequently passed +once in synthetic-HTTP mode, with zero application runtime exceptions and all +912 source/test hashes stable. The exact evidence and final documentation are +rereviewed before candidate commit. [Handler fixture evidence](ci-3ac3351-handler-fixtures.md) +preserves the actual startup failure, source-found later incompatibility and +unrun remaining CI gates. No additional reviewer tests, broad suite or native +fit is required. Changed-candidate exact-head CI remains necessary for acceptance. diff --git a/docs/plans/external-project-save-load/reports/independent-review.md b/docs/plans/external-project-save-load/reports/independent-review.md new file mode 100644 index 00000000..b184fb12 --- /dev/null +++ b/docs/plans/external-project-save-load/reports/independent-review.md @@ -0,0 +1,33 @@ +# Independent plan review + +Reviewer: independent read-only agent `/root/independent_plan_review`, 2026-10-22. One author integrated the collected findings. No reviewer product/document writes, application/catalog execution, local lab inspection, migration or publication occurred. + +## Complete review subject + +- Tuldok code: `133d53c0e2985a6559ba9cebd7d4a9f530051f06`. +- Coding-Standards: `5bce3110fc64d61ff8b4f79468184bd6295855d5`. +- Original complete `plan.md` SHA256: `7ba5a682fe9e64c0ef17d0aadfc3497813f9cf17041608fb6add0899d676bdbe`. +- Original `reports/baseline-and-standards.md` SHA256: `5a7bc63d5e2b1aa69fb4225b44721602740f3054a26a552eae150c2f5f4453fa`. + +The reviewer read the complete plan and cross-checked actual Projects/metadata, app composition, Workbench/Preferences, Models/Pipeline, Studies/lineage, Runs/Access, candidate preparation, runtime and extension closure against current Planning, Architecture, Persistence and Verification requirements. The initial verdict was **revise three material gaps, then targeted re-review**. + +| Finding | Source / reason | Author disposition | +| --- | --- | --- | +| R1 — High: replacement lifecycle | Current `app.py` binds one Dataset in `make_handler`; Open/New/reload could otherwise bypass Close, detach active owners or misroute old requests. | Added process-wide workspace controller, opaque project binding/session generation per request, exact pinned dispatch and Close/drain/unknown-cessation gate before replacement. T6/T8 cover delayed old reads/mutations, concurrent tabs, active jobs and failed switching. | +| R2 — Medium: editable files | Inventory hashes plus rejection/validation alone did not provide a usable path for intentional script/note edits. | Added explicit reviewed `adopt_working_files`, inert adoption context when drift prevents Open, retained prior captured versions/tombstones and a prohibition on recomputing immutable evidence hashes. T6 includes positive edited working files and negative immutable corruption. | +| R3 — Medium: direct owner population | `preferences.py` directly writes judgment history; `candidate_packages.py` composes prepared files/snapshots/material/Trials/receipts; runtime snapshots and extension readers own retained identity closure. | Added concrete family/disposition table, finite milestone path groups, M2 historical/metadata integration and M3 direct-writer integration or pre-effect unavailability. T2/M3 cover judgment rationale/review/history, candidate preparation and runtime/extension closure. | + +The reviewer found the canonical-text/disposable-SQL authority, inert historical evidence, namespace-preserving fork, artifact manifests and stopped extraction direction coherent and source-supported. The full architecture probe and claim-directed evidence apply the standards substantively. `Planned` was adequately separated from implementation authority by explicit no-start language; no runtime acceptance or migration authority follows from it. + +## Revised material and targeted review + +- Revised `plan.md` SHA256: `3bd7e87f305bdff06c44dd9ac5168b3cfc0ac95f90b7f1b55c0e738985d1c674`. +- Revised `reports/baseline-and-standards.md` SHA256: `67a0d17727c1467cc1c979c0147462156c47a257dd134ffff6be3bbee9071911`. + +Targeted re-review confirmed all three material findings resolved, with no blocking findings. The reviewer identified one nonblocking M1 bootstrap detail: define the controller's no-project-open binding so initial New/Open does not require an existing project UUID. The author added an explicit authenticated `no_project_open` state, installation/session generation, local root-selection authority and atomic transition to the project binding; project-specific operations are unavailable before selection. + +The reviewer separately confirmed that narrow closure against final `plan.md` SHA256 `d6d3832a494a44aebae15bed20748a6d5b90dfa98919d315a179abae02d60b4d`; the baseline report remains `67a0d17727c1467cc1c979c0147462156c47a257dd134ffff6be3bbee9071911`. + +**Final result: independent plan review clear; no unresolved findings remain.** No denied, pending or uncertain action blocked review. Adding this report, validation evidence or ledger bookkeeping does not change the reviewed plan/source subject. Only findings with changed semantic content were re-reviewed; no identical complete-review campaign was repeated. + +This review is evidence about a documentation proposal. All product A1–A9 remain prospective; no implementation or runtime validation is claimed. diff --git a/docs/plans/external-project-save-load/reports/m1-implementation.md b/docs/plans/external-project-save-load/reports/m1-implementation.md new file mode 100644 index 00000000..0b8c5a31 --- /dev/null +++ b/docs/plans/external-project-save-load/reports/m1-implementation.md @@ -0,0 +1,101 @@ +# M1 implementation evidence + +This dated record concerns the external text-project vertical. The [plan](../plan.md) +alone owns current status, next slice and acceptance. Later scientific families, +execution, independent Save As and extraction need M2–M5 gates. + +## Source and environment + +- Tuldok main: [`133d53c0e2985a6559ba9cebd7d4a9f530051f06`](https://github.com/MrScripty/Tuldok/tree/133d53c0e2985a6559ba9cebd7d4a9f530051f06). +- Local reviewed plan: `2c9c9b098c9f1c4c2557c58818574ebdbc1e7d41`. +- Standards: [`5bce3110fc64d61ff8b4f79468184bd6295855d5`](https://github.com/MrScripty/Coding-Standards/tree/5bce3110fc64d61ff8b4f79468184bd6295855d5). + Core, implementation, verification/oracles/GUI/platforms, persistence, + contracts/evolution, ownership, concurrency, security, diagnostics and commit + guidance applied; the [baseline report](baseline-and-standards.md) gives the + original routing/source context. The Standards Engine authoring skill is + explicitly inapplicable to ordinary application edits; no application Engine + connection is available and no automated standards certification is claimed. +- Disposable PostgreSQL 17.11 over a private Unix socket; Python 3.12.14; + Node 24.19.0; Chromium 151.0.7922.173; Linux overlayfs. Source and cold roles + are non-superuser fixture roles, and the cold role is denied source DB access. + No user database, project or lab is an input. + +## Deciding evidence + +| Boundary | Performed evidence | Practical limit | +| --- | --- | --- | +| Filesystem publication | `tests/test_project_store.py`: real files/processes, contention, process kills, canonical closure, no-clobber New, fsync faults, malformed/future/path cases | Supports named local Linux filesystem; no destructive power-loss or network/Windows promise | +| Canonical before SQL acknowledgement | `tests/test_project_text.py`: 18 real PostgreSQL tests passed, including a deferrable COMMIT failure after file publication and fresh reconstruction | Text/annotation family only | +| Current local authority | Text tests include scoped agent drafts/read, human review refusal, inspect-only mutation refusal and revocation ordering | No imported current credentials, grants or resumable authority | +| Workspace recovery/retry/pages | `tests/test_project_workspace.py`: nine real-PG tests, then two targeted persistent-fsync tests after barrier repair passed | Later worker/run lifecycle remains fenced | +| Legacy affected behavior | `test_metadata_transactions.py` 15 passed; three selected Workbench tests passed | No full-suite or unrelated D1 requalification claimed | +| HTTP, real clients and browser | Changed-subject cold reconstruction, stale requests, actual SQL COMMIT fault/recovery, real SDK/CLI/MCP and Chromium workflow passed; exact receipts in [M1 QA](m1-qa.json) | Text scope; original failed MCP initialization retained separately | + +The final HTTP campaign selected four nonzero methods. Three passed immediately; +the actual-adapter method failed MCP initialization at its 20-second deadline. +An isolated production probe measured 20.431 seconds, with CPU work dominating. +`main()` already performed full registry validation in bootstrap, and `serve()` +repeated it before reading initialization. After independent consequence review, +only the redundant `serve()` validation was removed. Bootstrap admission and +per-call input/output checks remain. A focused main-order test passed and proves +bootstrap failure prevents serving. Only the failed real-adapter method reran: +1 passed, no skips, stable source, 103.987 seconds overall. Actual MCP initialized +in 10.955 seconds under the unchanged 20-second initialization deadline. + +That real adapter method used an actual ProjectWorkspace HTTP server, owner SDK +New, ordinary agent SDK drafts/read and owner-review denial, private-config CLI +Save/Close/Open subprocesses, and the official MCP SDK against production stdio +and actual HTTP. It verified retained historical notes and revocation 401 without +canonical effects. It did not substitute the controlled HTTP client fixtures. +The focused client suite separately passed 16 tests without skips, including +maximum Unicode sizing, exact pins, page/receipt/recovery contracts and controlled +consumer faults. The affected preexisting Agent API suite passed 17 tests. + +The final changed Chromium subject passed with matching start/end file hashes: +keyboard New/import, retained corpus note followed by human-reviewed classification, +Save/Close, real local Git commit and clone, fresh process/control catalog with +the cold role denied source DB access, exact identity/text/history of three +revisions, older-note UI pagination, no-op Save byte identity and inert script +sentinel. Runtime and HTTP error lists were empty; desktop/mobile screenshots +had no horizontal overflow. The later MCP-only startup edit does not affect the +browser's owners and did not justify another identical browser campaign. + +The three legacy Workbench methods were +`test_normalized_unicode_revisions_and_reopen`, +`test_intent_recipe_labels_and_template_lineage`, and +`test_http_static_export_and_errors` (3 passed, 2.232 seconds). + +Commands use the qualified fixture environment, then standard nonzero unittest +discovery/explicit method selection and `node tests/browser_project_files.cjs`. +The fixture environment is `/workspace/scratch/tuldok-save-load-qa/env.sh`; +raw logs and browser receipts are retained in that task-owned scratch directory. +Tests clean only their owned schemas/processes/profiles. PostgreSQL remains owned +by the task while dependent milestone checks need it. + +## Review findings and repairs + +The independent source reviewer requested seven concrete fixes: recoverable +unknown publication/committed outcome delivery; bounded list/history responses; +receipt-correlated historical revision; New creation without selection; stale +lifecycle replay; legacy capability truth; and bootstrap against the real project +server. They were closed together at the affected owners. Text authority +correction was independently re-reviewed at SHA256 +`adf90ee6e53b8dc1e8b68287269e907a4da1112704489db4eee1efddf0b65e5b` +and cleared. Final targeted source re-review cleared every collected finding; +the reviewer performed no runtime tests. The reviewed product files include +workspace `2b0dbace8e2840e4f60997985ac7f52618c0fe15ed32cf6ca548c3832e9b8c54`, +text `adf90ee6e53b8dc1e8b68287269e907a4da1112704489db4eee1efddf0b65e5b` +and UI `5bb198e00191c1b39b74998b0c54c14129aa7470f8dba1bd71819cab81c2874b`. +The app, registry, SDK, CLI, contract and authority decision were also inspected +on their frozen final subjects. Final affected transport checks remain distinct +from that source-review verdict. The additional MCP startup change was independently +cleared at final SHA256 +`a37cf211ded719242499a90772ccf377c2ba52a895621c1ed618c37ed065deeb`. +No source-review finding remains. The lead accepted the M1 text milestone after +the named real checks; final A1–A9 feature acceptance remains governed by the plan. + +Failed checks are retained with their causes: missing exact contract headers, +incorrect static routes, a misplaced HTTP guard, test quoting/fixture name errors, +and a browser observation racing the acknowledged edit. The team corrected the +specific defect and selected the affected rerun; it did not repeat unchanged +campaigns or use test-count targets. diff --git a/docs/plans/external-project-save-load/reports/m1-qa.json b/docs/plans/external-project-save-load/reports/m1-qa.json new file mode 100644 index 00000000..af78fb3f --- /dev/null +++ b/docs/plans/external-project-save-load/reports/m1-qa.json @@ -0,0 +1,102 @@ +{ + "actual_adapters": { + "agent_owner_lifecycle_and_human_review_denied": true, + "draft_edit_and_historical_note": true, + "initialize_deadline_seconds": 20, + "initialize_seconds": 10.954900544999873, + "material_start_end_match": true, + "official_mcp_stdio_to_real_http": true, + "ordinary_agent_sdk": true, + "owner_cli_subprocess": true, + "owner_sdk": true, + "revocation_401_without_canonical_effect": true, + "successful": true + }, + "baseline": "133d53c0e2985a6559ba9cebd7d4a9f530051f06", + "blockers": [], + "browser": { + "actual_git_clone": true, + "cold_source_database_connect": false, + "evidence": "browser-project-files-r2.log", + "history_count": 3, + "http_errors": [], + "inert_sentinel": true, + "no_op_canonical_sha_unchanged": true, + "receipt": "artifacts/project-files/run-y9XvAA/receipt.json", + "retained_corpus_note_ui_pager": true, + "runtime_errors": [], + "screenshots": [ + "authored-1440.jpg", + "authored-390.jpg", + "cold-open-1440.jpg", + "cold-open-390.jpg", + "cold-history-390.jpg" + ], + "successful": true + }, + "changed_http_claims": { + "evidence": [ + "http-project-files-r2.log", + "http-r2-receipt.json", + "http-project-files-adapter-final.log", + "http-adapter-final-receipt.json" + ], + "note": "First selected campaign: deferred SQL receipt/recovery, cold history and stale-binding methods passed; real-adapter method failed20sMCPinitialization. Only failed real-adapter method rerun after scoped startup fix:1/1 passed, no skips, source SHA stable.", + "passed_claims": [ + "test_project_files_http.ProjectFilesHTTP.test_real_deferred_sql_failure_returns_committed_receipt_and_explicit_recovery", + "test_project_files_http.ProjectFilesHTTP.test_real_owner_sdk_cli_and_agent_sdk_mcp_share_authenticated_project", + "test_project_files_http.ProjectFilesHTTP.test_cold_copy_reconstructs_exact_identity_values_and_history_without_credentials", + "test_project_files_http.ProjectFilesHTTP.test_old_binding_and_stale_checkpoint_reject_without_canonical_effect" + ], + "selected_count": 4 + }, + "cleanup": { + "postgres_service": "left running for team", + "stop_command": "/workspace/scratch/tuldok-save-load-qa/cleanup.sh", + "test_owned_processes_profiles_roots_and_schemas": "cleaned" + }, + "environment": "environment-qualification.json", + "files_owned": [ + "tests/test_project_files_http.py", + "tests/browser_project_files.cjs" + ], + "material_sha256": { + "agent_api.py": "5eed592dac37231c0ad3114e7793b4dfdfa27ffeb1d10d63778e78bb106cc4b0", + "app.py": "e26c8774ca963e908ee54574d4b44e2f83ba11e8694d33a630c80e543216d1dd", + "mcp_stdio.py": "a37cf211ded719242499a90772ccf377c2ba52a895621c1ed618c37ed065deeb", + "metadata.py": "bc807e1da0c2fdae6104623f54745f622998851d5dc9eead8281ed9f9aa443ce", + "project_cli.py": "ba6a23fc544257f79fbc24851275c07e53765ce301a4a2c0e7141deb1a6bd37a", + "project_store.py": "a81cb46d3e4987a6ce444fc5da57a17a0fed74ddf388e30e45b62a90fc497bf1", + "project_text.py": "adf90ee6e53b8dc1e8b68287269e907a4da1112704489db4eee1efddf0b65e5b", + "project_workspace.py": "2b0dbace8e2840e4f60997985ac7f52618c0fe15ed32cf6ca548c3832e9b8c54", + "static/project-files.css": "fdb0b78765d1944d07676578ddf222f8f1dcdba06c8777b14bd011f5b8e7979d", + "static/project-files.html": "dc6df837d33dda085d84ddd5c6e38664e0de6101e992ce5ff7693f3ba6cc5d5f", + "static/project-files.js": "5bb198e00191c1b39b74998b0c54c14129aa7470f8dba1bd71819cab81c2874b", + "tests/browser_project_files.cjs": "1a1263fd2f0a4cb502579265c59144d3f377c21424631b278e4428e9c641ab3c", + "tests/test_project_files_clients.py": "292147c966d0f325ae5dfc059fee668c7f47eb02f71b676423df1107dd3925e2", + "tests/test_project_files_http.py": "2f8c6b6f1f90e674aa26cdc4d23e4f0682951e69499835e60d14d6a3183d0121", + "tests/test_project_store.py": "5215f59384c2cfd1b9fa0f73fcb77c3f1395313a8611f9978444079ea02cbf47", + "tests/test_project_text.py": "f841dd669339d96d6ff66a3f9efbf30161697b6d27186b29c78c9d16f7b26e70", + "tests/test_project_workspace.py": "093594028a5475c8876d62dca46a6188d4133b2195303f2329f7e464d2385c49", + "tests/test_workspace_project_files.cjs": "6d5c3c2751bbac40198d987287ce32c6c85319f71aaad3eaf91b4273d599ab7c", + "tuldok_client.py": "d7981b9b4f84f1195bb3ff837923d99c9845f660c20bbc31e856f41a101e559a" + }, + "mcp_startup_diagnosis": { + "evidence": "mcp-isolated-startup.json", + "fix_scope": "Lead removed only repeated registry validation from serve; bootstrap and per-call validation retained", + "fixed_initialize_seconds": 10.954900544999873, + "prior_child_cpu_seconds": 20.263033, + "prior_isolated_initialize_seconds": 20.430954294000003 + }, + "plan_commit": "2c9c9b098c9f1c4c2557c58818574ebdbc1e7d41", + "product_files_modified_by_this_worker": false, + "publication_performed": false, + "raw_evidence_root": "/workspace/scratch/tuldok-save-load-qa", + "scope": "M1 disposable-environment and real integration QA only; no lab/scientific execution or later-family acceptance claim", + "standards": "5bce3110fc64d61ff8b4f79468184bd6295855d5", + "status": "pass", + "unchanged_http_claims": { + "authorization_inert_control": "Prior scoped lead run passed; reused unchanged", + "legacy_mutation_unavailability": "http-project-files-fourth.log; reused unchanged" + } +} diff --git a/docs/plans/external-project-save-load/reports/m2-ci-repair.md b/docs/plans/external-project-save-load/reports/m2-ci-repair.md new file mode 100644 index 00000000..c45c872d --- /dev/null +++ b/docs/plans/external-project-save-load/reports/m2-ci-repair.md @@ -0,0 +1,28 @@ +# M2 CI observation-fixture repair + +For M2 head `c22e9d02c87938514a7a1c9b3b0532158008ca0d`, the lifecycle workflow +[39842528122](https://github.com/MrScripty/Tuldok/actions/runs/39842528122) +failed at step 61, `node tests/browser_workspace_project_observation.cjs`, in job +`120047066904`. The three other workflows passed. Full Python discovery and the +new real external-project transport/cold-browser steps had passed; that does not +make the overall lifecycle result successful. + +The failure was `TypeError: Cannot read properties of null (reading 'focus')` +through fixture `activate` → `holdInner` → the recipe-change loop. The fixture +counted an incoming snapshot request, then waited an arbitrary 50 ms; it could +try the next recipe button before the restored observation reached the DOM. +The exact failure was reproduced with one controlled 200 ms restoration-response +delay against unchanged c22 product controllers. + +Only the observation fixture changed. It now acknowledges the exact observation +after the real Activity callback has applied it, with an incremented applied +sequence even when identity is unchanged. It neither skips assertions nor raises +timeouts. Actual Chromium 151 passed all sixteen existing checks, twenty applied +observations and zero mutations/exceptions. Syntax and whitespace checks passed. + +Repaired fixture SHA256: +`5d98fff29377ba187c40c1db2cba604a37ccdc516d24bba5a3480f59093d3bbc`. +Before/after logs are retained at +`/workspace/scratch/tuldok-save-load-qa/m2ci-observation-before-20261022.log` and +`m2ci-observation-after-20261022.log` in that directory. No duplicate full-suite +campaign or CI rerun was requested; future exact-head CI must include this fix. diff --git a/docs/plans/external-project-save-load/reports/m2-implementation.md b/docs/plans/external-project-save-load/reports/m2-implementation.md new file mode 100644 index 00000000..25fadfda --- /dev/null +++ b/docs/plans/external-project-save-load/reports/m2-implementation.md @@ -0,0 +1,117 @@ +# M2 — Inert reconstruction, exact history and metadata publication + +This report records the M2 qualification subject and results. The [plan](../plan.md) +owns current status; this is dated evidence, not a second status authority. + +## Subject and outcome + +Baseline: M1 `af276565fae9c08c1084d37d8937ba5d97599f17`, based on main +`133d53c0e2985a6559ba9cebd7d4a9f530051f06`. Main was re-read and unchanged. +Coding-Standards remains unchanged at +`5bce3110fc64d61ff8b4f79468184bd6295855d5`; the applicable principles and +workflow selection remain pinned in [baseline-and-standards.md](baseline-and-standards.md). + +The tested/reviewed 226-file product subject is +[m2-tested-source-manifest.json](m2-tested-source-manifest.json), whose **file** SHA256 is +`7a8969d58594b4dc6f9412c5a31c77b2a13f59b807ad013084bfc351cdfde8f4`. +Its compact JSON content, excluding the terminating LF, has fingerprint +`cb070f64dfa11579ec3b759e6d2b02c12df59b6da2882f7e764489fe99dd3abe`. +The final staged whitespace check then caught one redundant EOF newline in the +new `project_data.py` file, which earlier unstaged checks could not inspect. +Removing that one newline produced an identical Python AST. No runtime campaign +was repeated for this formatting-only change. The final +[m2-source-manifest.json](m2-source-manifest.json) records that explicit delta and +has file SHA256 `ef2dc94d76d73fc11a45bcb06cf32dc5399d84370ef2a94dfbf6db800cfa9937`; +the exact old/new data-source hashes and AST parity are in the verification JSON. +Earlier receipts used content fingerprints; these are not mislabeled as file +hashes. Tests and CI configuration are separately identified in +[m2-verification.json](m2-verification.json) and the enclosing commit. + +The concrete production result is one fixed owner composition for data and +histories, immutable material/runtime declarations, Study/Trial/Run/lineage proof, +producer history and artifact custody. Metadata mutations publish canonical +records and original-result receipts before SQL acknowledgement. Real SQL commit +failure retains the authoritative files and fences the projection. Legacy domain +routes remain unavailable in project mode before effects. + +New/Open rebuild a fresh physical SQL projection without Dataset, live Runs, +producer workers or recovery constructors. Imported credentials, grants, +admissions and replay authority are absent. Historical namespaces, scientific +hash inputs, reservations/charges and lineage remain exact. Missing bytes have +explicit availability; consumers verify captured snapshots. Original wire bytes +which a legacy owner never retained are reported unverified rather than invented. + +The 85 fixed project domain operations include existing modalities, authored +history, nonexecuting model/graph/recipe preparation, explicit local source +registration, bounded source references, current disclosure and rooted artifact +supply. Built-in preparation is fenced before its native probe until M3 owns +execution. Plain-text current records and manifests are tracked; binary sidecars +remain untracked. No source script is imported or executed on Open. + +## Performed verification + +Required-real environment: Linux/POSIX overlay filesystem, PostgreSQL 17.11, +Python 3.12.14, Node 24 and Chromium 151. Disposable authored fixtures only. A +separate cold database role cannot CONNECT to the source database. No local lab, +production data, external provider, training or GPU campaign was used. + +| Boundary | Deciding evidence | +| --- | --- | +| Artifact/store publication | 42 artifact/store cases across the scoped campaign; verified fd snapshots, no-clobber/fsync/process boundaries, actual SQL rollback custody; targeted request-key/parser checks. | +| Shared session | Five actual PostgreSQL cases, including deferred COMMIT failure after canonical/artifact publication; the two initial fixture descriptor errors were corrected and only affected cases repeated. | +| Data/current source | 16 actual PostgreSQL cases after acquisition guards: all seven modality sources, annotations/rights/deletions, response/preference history, exact release variants, lost-reply compatibility and cold reconstruction. Existing native diagnostics were corrected and 21 affected native tests passed. | +| Material | 32 scoped material/package/evidence cases; targeted exact receipt/audit/agent-before-replay/built-in-probe guards; one actual opaque closure reader regression. | +| Historical proof | Six real owner cases passed on the first frozen subject. The failed composed-history fixture first needed multi-segment assembly, then exposed a real callback-shadow bug. After its repair, the composed proof plus two new classification/parser guards passed on the frozen corrected subject. Charges, producer inputs, original namespaces and cross-Study lineage survived cold SQL; active execution/grant/admission/replay tables remained empty. | +| Producers | 12 new real PostgreSQL cases, five affected live-owner regressions and targeted final checks: inert readers, exact payload custody, deleted source history, current authorization and credential classification. | +| Registration/resolution | Seven source lifecycle/import cases plus bounded reference reading; one actual rooted artifact-supply/replay/cold test. No imported root binding is activated. | +| Composition/cutover | Three complete-projection cases and all 11 M1 workspace tests passed after switching to the complete owner composition. | +| Public boundaries | Three real HTTP cases, including actual SDK/CLI/MCP, roles, stale binding/lost reply, missing artifacts, explicit supply, original disclosure facts versus new local permission, Git clone plus explicit untracked payload transfer. | +| Fresh metadata vertical | One actual HTTP case passed on the final 226-file subject: New, neutral source registration, captured model publication, external graph creation, actual nonexecuting recipe preparation, Study/Trial metadata, and cold exact material closure. Process launch and legacy Dataset/Run construction were forbidden throughout. | +| Browser | Actual keyboard/native authoring and cold reconstruction passed. The full receipt truthfully retained a responsive failure. A measured one-rule history-wrapping repair then passed the focused real cold-history gate at 390 and 1440 pixels, with zero HTTP/runtime errors and unchanged final source fingerprints. | +| Transport/controller | Finite registry/dispatch parity, current graph grammar, legacy grammar preservation, closed pages, thin SDK/CLI/MCP forwarding and controller stale/dirty/outcome checks passed. Named real gates preflight nonzero selection; optional MCP dependencies were present for its actual gate. | + +The public summary and exact receipt locations are retained in +[m2-verification.json](m2-verification.json). Scratch evidence is under +`/workspace/scratch/tuldok-save-load-qa/`. Results are scoped to their source +subject. Unchanged valid evidence was reused; changed callbacks, guards, graph +grammar and CSS received targeted checks. No evidence-volume target or repeated +identical campaign was used. An earlier broad historical run during mutable host +source failed fingerprint checks and is excluded from acceptance evidence. + +## Independent review and resolved findings + +An independent reviewer read actual implementation and applicable standards, +then reviewed only the justified repairs/deltas against stable fingerprints. +Final source review: clear, no residual findings. This was read-only source +review and makes no independent runtime-execution claim. + +| Finding | Resolution and evidence | +| --- | --- | +| Built-in material preparation could launch a probe. | Typed pre-execution fence before cache publication/owner preparation; actual PostgreSQL/Popen-forbidden regression. | +| Historical Run metadata exposed frozen holdout fields. | Shared live/inert public projection; current disclosure gates on exact documents, evidence and publication proof; cold current-permission refusal. | +| Historical audit/operation/acquisition shapes admitted unclassified content. | Exact finite owner variants and retained-fact binding. A residual permissive `studies.admit` result was refused: actual legacy admission persists reservations/audit, never that operation receipt. Admission proof effects now match exact real owner maps. No legitimate admission facts were removed. | +| Retained recipe reader overwrote its callback with a blob descriptor. | Separate reader/source variables; actual opaque preparation/closure and corrected cold composed proof pass. | +| UI treated native saved-selection `items` as a page. | Page checks limited to declared page actions; actual selection acknowledgement and controller regressions pass. | +| Unpinned references ignored observed hashes. | Current observed digest accepted with an unverified label; declared mismatches disabled and refused; actual unpinned import and mismatch negative pass. | +| Long cold-history hashes widened the page. | Measured 644–650px overflow at 390px; one history wrapping rule gives 375px document width without hiding content. Actual cold responsive gate passes. | +| Fresh external metadata lacked direct graph creation/current grammar. | Existing nonexecuting owner added to canonical dispatch; current graph schemas reused after legacy version construction, preserving old contracts. Focused contracts and fresh real HTTP vertical pass. | + +The reviewer also cleared the narrowly added CI readiness/setup: job-owned cold +role/database, source CONNECT refusal, exact source subject, mandatory non-skipped +real MCP case and real Chromium gate. Existing broad discovery is unchanged. +These CI changes are configured, **not yet executed on the M2 commit**. A PR +synchronize event triggers them; the existing branch-push filter alone does not. + +## Remaining scope and rollback + +Live execution/producer starts, custody, candidate external publication and +runtime capture remain unavailable pending M3. Complete independent Save As, +working-file adoption/relocation/Git handling and mixed-catalog extraction remain +M4/M5. Early pure fork storage/data constructors are tested ownership seams; +their existence is not public Save As acceptance. Final A1–A9 acceptance remains +pending the complete implementation. No branch is merge-ready on this report. + +If this slice regresses, close new project writes, retain canonical records and +payloads, and reconstruct only with a compatible reader. Never restore SQL over +canonical facts or delete orphan/published unique bytes as rollback. No user data +was migrated; no branch was merged or deleted. diff --git a/docs/plans/external-project-save-load/reports/m2-source-manifest.json b/docs/plans/external-project-save-load/reports/m2-source-manifest.json new file mode 100644 index 00000000..10c1285d --- /dev/null +++ b/docs/plans/external-project-save-load/reports/m2-source-manifest.json @@ -0,0 +1 @@ +{"baseline":"af276565fae9c08c1084d37d8937ba5d97599f17","files":{"access.py":"50681dd5b2aaba01a311a7465b15161ca264fa4ca9812b95f15914adb5d808f4","agent_api.py":"2d3e105742e1de37d94e031b1523cf9b63119c099760341e1ac5a4eec093c3c4","ai.py":"a3b04a3015496ff3c66c3eb450ccf887bd5726229669ac0515d95a2761ad3a9a","ai_codex.py":"debd26342e73a87635d772e3ee59d30bb822877008d667b47997f52e22f94f16","ai_http.py":"73ca648fe00e8f5a4022b14b72c80dd695228706a0f2b79aee605a524b8f2585","ai_openrouter.py":"70930124fac7ef08941f571c1d4492f72b29aa94807cfd5d66bc32555432b160","app.py":"d71fc0740f8fab0dbffdfd11cbc132e5180149611dec045f488ab9d053be4c48","audio_assets.py":"7279a3b5fb3d6f84ed5a6e366ff9aa916a62b242c2b0b97dbe779f4da3d01755","audio_classification_export.py":"0f0643470cfa3b015c2d56c0e8e1dc2ad4944bc825127b9131fbbdd36349052d","audio_export.py":"bc20d32089b3a004504c6cb072e18db9e285c9195db84679670fe460ef51cfc9","audio_training.py":"396500bb2a63054c88b2720b8c168617641fe2ff9fd2285860a6dd894523f949","backup_metadata.py":"4d050ed173b686c249157f513506eea9485f4cd6bbd05cb919a0f10113ba4a7d","bulk_import.py":"b8f7c55947cb82689850716b5a8f6f95422efde2e87c4fa3c640af004b8a2fdb","candidate_packages.py":"148c2a53df5850fa3f9de869da7f4885b152eb49d3f5f5cf4a829befdb9eabff","canonical_release.py":"1f92e2d822602211475a5e7c13198388cc0a36aa36e0872f66d9fd353dcc2061","caption_import.py":"86496e5ccaa5dbbf32d9731e7acfb76cc5c0b6a147e333e9a53aa8e9629ca406","caption_proposals.py":"a091a252631693cbf92423b8eefc054916156739598ff2b0bf46ed6e5f3930a6","curation.py":"b25576749afd01f9e699133c5db7e6113f9a5f0ad8961dafc4084292896b0049","dataset_recipes.py":"78ae918228f15f9d5924985075cb96535b8953b4ad5faa821d0cacd0a50e3c88","dataset_releases.py":"45c039d91f6ab86628a2aaf42e34df684b59954c62a747ede5844b477659e4f3","experiment_lineage.py":"386f964dd2488ca0f5905cd6823a19228b6ff80e767a3b1c52130553f6188623","experiments.py":"232e2acbb1e9af3d410fac73a4e9742c41cc3358824f0e825fa6c536a708ba43","extension_bindings.py":"4172b8209a8952af04b8f495f301ce52c02258526454bcc790f8fd1676b5c3b0","extension_canonical.py":"fc82f6631e911f8f5459ebe73e6c70effeebe3e6c1414edd242d03406a46ef26","extension_contract.py":"890bb254d67bd79c9da9f4e8bf77f5b00e7bb850d696b9eb1ac6b632189e49df","extension_evidence.py":"244340a36b6593f58f1645b65ecf648b9805fe43cac8b20f80c733ea7e025a32","extension_schemas/contribution-v1.json":"2e1e038c3c1ba09c6b7525a1f9ed2f8cb083fde31cf067f12cf57b76f3f7dfe3","extension_schemas/extension-v1.json":"e78280176fd239b387ffc8b850a7bbff61f069cb47d4c7f9077e7d690c116d25","extension_schemas/parameter-v1.json":"12ea65f4eb51fc71ba4c98217813b0953aa73cda0fe13e8a6c4b0d4d8acc7305","field_evidence.py":"da5db2e2a6cea72b687d2e85d53f6a509a26f6ad0384669230ac2d2ba0be9adf","frozen_classification.py":"a6a9fc244bcb7de7da5217fb36d5e73bb03ca1756c9c63770c23a78336606649","gateway_discovery.py":"8a02e2ef9038859c1accce9ea3e31ea489423f19799a279f4be6f9afd61297d0","grounded_candidates.py":"2ca917b14fa988116b50cc873f34f0d9aefca60182ecb94d3c3588419b29ceeb","image_classification_export.py":"995edefa24fc039505337fd8d2915e45d173c18a03af513d9f5d8eb234225b96","image_generation.py":"c3a62b3cf0bf1d6f6d453a63f7f2a7d08de63864d880959bd0eab237d9856858","image_sources.py":"374d822033ddf16c1593aab130853aa6e96d28c6e4d0723e845b6d1e158c3f5f","image_training.py":"35150740f898c33149a6c52289a9296b9e6f611a201fb5da03c7fd7b1f3e4fa0","immutable_assets.py":"82d177c273240dfca1f3cca539cd12dd54940521f298190f9a58c8fc0fa58aed","mcp_observer.py":"f138c2bb20cb2677e46ce1a96637971efbc5a7c6975113c1177e104c766512ab","mcp_stdio.py":"a37cf211ded719242499a90772ccf377c2ba52a895621c1ed618c37ed065deeb","mesh_release.py":"09d29a60bc37826b906097debaf7eb8577a425d3d791ff5df8d3b03607b10de1","meshes.py":"2a0a5b55fe2f0a412d8827bd12b3c129ae6c0c44ed822584276610a44fe67be3","metadata.py":"bc807e1da0c2fdae6104623f54745f622998851d5dc9eead8281ed9f9aa443ce","migrate_sqlite.py":"be1a79a1b6f5e2c8e4c99862043a5afcd4f30d6539388c1600908f4a4f3f92da","model_definitions.py":"96d7b3659b50badb42e7a40c0788ec4046368218d0c5fd629e7300985c4730a0","model_evidence.py":"37d42570855b8c8acc70d947912c1a9af09639761c5ed32d01a756aa4fffd14e","native_detection_import.py":"80466232bd51f58bca9a00ef68e278da087b012e7f1b3b1171e6e51db3b8c572","native_text_import.py":"3fc35bc1f04e056a588e8e05f3f7b41ff894c90dae6cee2f1baaf37a7098d167","one_fit_evidence.py":"e70e64499ccee8614824e499789944a64d0abea5a6eee772c5950b9deb9428c5","phase_timings.py":"03406d957da7da1305a07793a3be0830f2169c02971fdd9fb2e855f54ac627bc","pipeline.py":"6d68a8fda198917d5f4e61d6a452ad39e54ae03479d30768fb9ddb5f6734c341","pointclouds.py":"3c7b9e56b6a3cdb244b7059d44ae5b941e717a99042bf1a5ab0d28934d8d8310","preferences.py":"d6279b18375fa8695584a02cfe962b56e77cf4f3521c5e33cc4baf09f8e4dab7","project_activity.py":"8b6cabcb3f84b35b7b8f6cc2bedd4df43a1eb285eaec9090975d5ae30cfc49d0","project_artifacts.py":"706a65d3ab5eaf1f3b40298dbe2fe982ab5c41b7a383faa7cbe3ef16549efa78","project_cli.py":"a2a0121273e9cc4ba4253c80aa53dde6019480b289c911969fba2d2abea75d42","project_data.py":"227ea9c3dd7b7c9fa45b781b0f3aef92f6b776ebf74e108f0594e048afede64f","project_data_fork.py":"3f49c244eba627e35d6f0fc9e7384c665113de6e2afa2219593440b7350ac911","project_dispatch.py":"c3c302a226f62198596d4227d4965e5df13a15975d158ff02929258058fe1daa","project_fork.py":"cbe1e3eddc7a9ba3ae095de946b9c2280fca9611e15b8912134dd120fa2d7d96","project_history.py":"b946b5baf667268707cc1b4a349541e9bf18975f26d5cc4dc774f2672301d679","project_material.py":"e6063a06d7d87a2b818f9b182b550a2b699b786cc54bbe682d12ef5afadd95c8","project_producers.py":"8548603cf235bb532461f29dfae7a7a000e41dedc996aa53c703a8d071dbd352","project_projection.py":"852ab1305a43a8911f54f31a570720476d5e76d7f8aa2d18bf21051de3335c66","project_session.py":"093f3b3b1551f091a9d296c034e63a272a5d7e4c0a5e85442ea16d9cf8e4ee51","project_sources.py":"614d57e9ba7af3934194198d8e239348c9553abcf509163f6eeaf24986d6d4ab","project_store.py":"bb0e4c480f093ea06b6258303f3447daefd886355ad6345355238cef22e4ff2f","project_text.py":"adf90ee6e53b8dc1e8b68287269e907a4da1112704489db4eee1efddf0b65e5b","project_workspace.py":"a1845b16c84b4f4472ce2a0e5fa9775dde6f30d3e88dda96869eacaa77dcfca1","projects.py":"d9047cf93809ea6a848bf7cf233f25f5e9b137ee2c8027b9aed6b3c6da4aaa75","pumas_gateway_descriptor.py":"b9c4259bda856fd19d1d82b3a2c000a74ab3979832248fd3f1f1d3d6309c0752","pumas_operations.py":"e8b8acee54a027633ae39f00a23b3d4845ae474d031690134cafb1c75bf73511","pumas_vision.py":"5659c7731b5011ff39a0a9b7c27712382983f16286540f47f6b1337941bbda7d","recipe_contract.py":"dc57a657dd59cee5297cbdaf2426a2d147424a1e737cc0dad799b6084b873646","recipe_process.py":"59e5829e28f11363ad4738e5998c0a4a52df2a1f18f08580b1da4b25c378c3a4","release_validation.py":"a04e0f3c941256422b0c3a8c28665b6fba025df9d3cfa6cd5d5686e2bb8de95b","retained_analysis.py":"bf8ba164bc1ab01521bc90025d95f5f6ee8155e7f4beca0101b0260d7fff7382","retained_analysis_schema.py":"8d6a743567eab6b99e9132721eabd12e7a381c742c84911e6ef58478a0ea43d3","rheon_sequence_contract.py":"063d17a3bbc92a27c26c0f5dd4588a9478091ebbf53ce0ccb818cee6a9393b98","rheon_sequences.py":"25193c926bd17ef8bf89456eabb3fe77b4eccddbe2b50bae1c0dc5362c79bfb8","run_capacity.py":"6811c5b2255b2a57c92147bab448a82c89876072d260ef217eb7284c511e8cfa","run_coordinator.py":"b09636e6896557898c88bfc3ebc0bba01077cbb1769bafb3497d45283c89de1d","run_publication.py":"45fb8fd06ebf6e457be6cac2dc966e1bee632a648f93e4bf37718a8d7596acc2","runs.py":"56caf719a28d21ed4ca518470cad802cb71ec32106afaa5bec6743e698b6bb1d","runtime_snapshots.py":"dad9e4ddf9c11c0e1f2d5b9a7afd6877e7be375fe02030c65b684bca2b0ef45e","saved_searches.py":"fcd4021fc20702568f1e44a165473aa752b9a9794a958ff31b33c42aba7114d5","saved_selections.py":"4e89ee272fc8a685ec76b3737879d6560661a73e03fca264c2057785905418a3","scientific_fields.py":"0dc4b5de499c115f1adbd0c7d14bdcece1a4c503ff9def48546306559f2eaddf","scientific_release.py":"b419730e7cc00212632b3adcdf63997343b6fb9aecd07a6ad2cb6f5c1728fb60","sequence_assets.py":"c1cca2c57eea007dfb2b9c3383ff941c13ac1f446d69f7950909353a32d8fe30","sequence_batch_import.py":"b04b814531f73b0af99f5e99aadeab581c4d626030c37cd7eb9cf28e60eb569f","sequence_release.py":"07a3129e3cc0091a3f21fea00d245e90af0062d076cb1951ebad59d9179aa65f","spatial_assets.py":"4dce8a453715ef7567686298b3151124795d3ee5253885a6ddbd237d59b620f1","spatial_release.py":"4d6ec06fceb72257a9d25da09ab24bcf13fc38f66ae50e8b39d5a5796f9b357c","static/app.js":"b82969e67e7a7792a11f239eff2fec959158e1dd78d08a84d68daedb93aad6bb","static/audio-editor.css":"6c6cecd83f78d0a37af9011f5a3716dec6d41686e7de85af7e8b3d9e171bd628","static/audio-editor.js":"d397bb98480077326b4c96035d891f1d8eb77cef8a04f3b2aef1eb34d5bbcf40","static/auth.css":"4ad4c7e8e4aaa04b4837698493b59365859b89f2cedd8717492ba6b57615f620","static/auth.js":"6a38fa7dabf57b2c99d293c6851a732af76a19193bea43f7d2a8b8460d71faec","static/bulk_import.js":"2d19443342dad5d083ad66e32f5b4877a825296f1a41d8996befd336cec7d367","static/caption-proposals.js":"09906d8ea799ee7def657a5d1c7746a5dcbe25797f7849c7ac27364089a083d7","static/caption_import.js":"280a116a086c3e8c69fcd17e24aba6630bb6c3d3c1e653a5ed84c184895ae850","static/curation.js":"2f51c2e063eddbe16cb92dfcaa96c35c749dd61960ebd92c8c538bf0c6ff389a","static/experiment-lineage.css":"232fec3043b92ae7aa91db499a14e8cdb9ebe5598c8d7f01d805e3e3d8d4859d","static/experiment-lineage.js":"085ee3cc990f1986db357e1e8f7bace0c16a2257bca9443ff412c464d91a53db","static/index.html":"34ef87f168f4f1cd291be6c86464e02969b3c760316f0a59f01f50890ec973b3","static/instruction-responses.js":"3fb4fb8d78154bedb37ef90afbcc269fdab0938c4ac50236f38a7f26f54ac940","static/meshes.css":"0658afff9e77542442b1c5bf1c10ce50603fab4beec1f812ac40a6574f81076e","static/meshes.js":"c2eb7622dd4e0a172db9db36e46a0ce66800d5b03aca989cd1f08dd40b8558c8","static/native_detection_import.js":"b07d3a9663d5b1534fca24dc7481caf4cb166bb4a83c4c97c2a78fb9c8d50d82","static/native_text_import.js":"1704549e9e5cf489e6351d5126858eb774ddd94d060e596a0bfba5fc00ea8773","static/pipeline-editor.js":"2272f1a9b106945203112a46d73643cc5d4dbbfd92f0d7c09d1cc36a713042d2","static/pointclouds.js":"34851447f34ea0204887b6aae4a86d73f6f94c8d1726b8e82b892027336c0c84","static/preferences.js":"fb22c04b6856d33b7e298aa1989d073c937ab40020025f39b86daaff62b948f7","static/project-files.css":"d1e051bc15f0b949e28860ed2e4acce2cc4c349b4329ecf41abd8da0e5f3f4d9","static/project-files.html":"689e1a1b323dcfbe19f72491ed084bcbace6162c82a851e6fff5940eb4629024","static/project-files.js":"b7ee53e6c2fee1e7dfbcc84840b1dd05e9eb920b7e28c3f604040f17f70bb2d4","static/pumas-gateways.js":"02df8f34362fc2e32f6a4582a9cb71adaa5d7b83ccd897803b9e1d02c7bcb932","static/pumas-typed.js":"50fd9fc961dafdd0c34cfcb1137d04f85a99f324f76fd5217f227b789c8835ad","static/retained-analysis.js":"d9dab53d15e6e50ecf8f1b9e182c43eb330c4731e574758a0e3026f44acb44df","static/retained-checkpoint-curve.vl.json":"6603c860ce76a434a11e6c96cf7418246ce2dd5da3d7a56a5f2448a0537ec183","static/rights-note.js":"fd09cef5c52b2635ad42998483c7054260140459444fbd948bb596e5966feb11","static/runs-view.js":"7568b843ad9b56c1c6878a9756553cdf8092dea3b954aec1947b8f32d730ca85","static/saved-searches.js":"49210b9da9dd7f7372e977c15543a77c5ada1df82ad9abdcb350026e16517892","static/saved-selections.js":"2c2feecca798217644247f98aab8e7a00d707a25d4af75e02f9d906ccbd905a3","static/scientific-fields.js":"96e6289ade2a634e9648cbe8aa899e5bfaddacddcea120835bda8cb6d65e14a6","static/sequence-batch.js":"081bbeae46b381a3ed8ebdfed5c03334004795b96e37cb18feb867b09d22cd53","static/sequences.js":"aff0628408e5e69f1364228f3cb9a4fd821f5f2122036d3497532f3bfe1c0c93","static/style.css":"25103299f9add27eafbee40c9a12634d7640a6c54a785c981066b6403de0603d","static/text-classification-proposals.js":"03dbec1114700f383f8bc9883c8a4cdaae59d898fc44240022b2011893965d6f","static/vendor/retained-analysis/licenses/d3-array.txt":"552e22a104bb752daf35286bfc180b7bb6c206cc6502853979bd19390102d644","static/vendor/retained-analysis/licenses/d3-color.txt":"decfad6f87be1e200338d792b77793997db27b3f4e610f870370393ed53daf4b","static/vendor/retained-analysis/licenses/d3-delaunay.txt":"718da82757e9041ac674c157d9fc70435543b32f4c1baf4a9f08b71c5fa6495e","static/vendor/retained-analysis/licenses/d3-dispatch.txt":"2127431ebe97ddcbabb32b1049bade99e4c4b2412d3f8b39ec161814f5e2e11e","static/vendor/retained-analysis/licenses/d3-dsv.txt":"2823e0d11f6e134bfe32ec3b60f042db2d7be3e4eb3e7f09b7c93a3bef502ec3","static/vendor/retained-analysis/licenses/d3-force.txt":"2127431ebe97ddcbabb32b1049bade99e4c4b2412d3f8b39ec161814f5e2e11e","static/vendor/retained-analysis/licenses/d3-format.txt":"dfd3916622f5f66f001a5c9ec0beb6d4b0208b9ba41a81206bf6fa0940b6e980","static/vendor/retained-analysis/licenses/d3-geo-projection.txt":"8d78a83093868a5a6d86835807514b0f5bda66403a0d75cbf804ad72a3619463","static/vendor/retained-analysis/licenses/d3-geo.txt":"e3243e2788023f118b725d27189d529d13da89dca0801380a9ecf96da3a5fa67","static/vendor/retained-analysis/licenses/d3-hierarchy.txt":"2127431ebe97ddcbabb32b1049bade99e4c4b2412d3f8b39ec161814f5e2e11e","static/vendor/retained-analysis/licenses/d3-interpolate.txt":"2127431ebe97ddcbabb32b1049bade99e4c4b2412d3f8b39ec161814f5e2e11e","static/vendor/retained-analysis/licenses/d3-path.txt":"16d2d2a1800677ddece729f5acb30205b5acbc21529df90cd8bf3d202c08d0b7","static/vendor/retained-analysis/licenses/d3-quadtree.txt":"2127431ebe97ddcbabb32b1049bade99e4c4b2412d3f8b39ec161814f5e2e11e","static/vendor/retained-analysis/licenses/d3-scale-chromatic.txt":"b982e295bd298c8d3e328a7cc623396e6e708fb6fba5b35e2dc4daf36728c8ed","static/vendor/retained-analysis/licenses/d3-scale.txt":"2127431ebe97ddcbabb32b1049bade99e4c4b2412d3f8b39ec161814f5e2e11e","static/vendor/retained-analysis/licenses/d3-shape.txt":"decfad6f87be1e200338d792b77793997db27b3f4e610f870370393ed53daf4b","static/vendor/retained-analysis/licenses/d3-time-format.txt":"2127431ebe97ddcbabb32b1049bade99e4c4b2412d3f8b39ec161814f5e2e11e","static/vendor/retained-analysis/licenses/d3-time.txt":"decfad6f87be1e200338d792b77793997db27b3f4e610f870370393ed53daf4b","static/vendor/retained-analysis/licenses/d3-timer.txt":"2127431ebe97ddcbabb32b1049bade99e4c4b2412d3f8b39ec161814f5e2e11e","static/vendor/retained-analysis/licenses/delaunator.txt":"9994d103e3af6ded25bad0ae99f08f6758f08cb9b261ab921ae7a6279769cdf0","static/vendor/retained-analysis/licenses/fast-json-patch.txt":"488a71c4303f0860094c1e59fa32e3302e9e79e2b63b8731761c22c2101c1dc9","static/vendor/retained-analysis/licenses/internmap.txt":"c596ec7154f49d5b5354b5483891de003b9af0f127aa007a6c5cea659e53d7c4","static/vendor/retained-analysis/licenses/json-stringify-pretty-compact.txt":"0454b235e715629194c3a1efb8b5a1b5c4a26bea9e8d54f4061b603d34cbea18","static/vendor/retained-analysis/licenses/robust-predicates.txt":"4731daf16265d7f0acd5c305a2dc745cf4c7562e027d44c206ab38637d8558ab","static/vendor/retained-analysis/licenses/semver.txt":"3ef9e4a3ab62e8eebd271519b275803e13e59a790cbdca7f617cbff34c56ed80","static/vendor/retained-analysis/licenses/topojson-client.txt":"140e4489d9507aedeb615404008fe1d51b418b7cdb428ea840481105c14eda97","static/vendor/retained-analysis/licenses/vega-canvas.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-crossfilter.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-embed.txt":"9ba0f1b61d8bb4c228264ed5305c543914fa0fd3f3e302623138a3f93d649304","static/vendor/retained-analysis/licenses/vega-encode.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-event-selector.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-force.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-geo.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-hierarchy.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-interpreter.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-label.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-lite.txt":"1d88c9e50bf518513f0db4b4faff48d26fce5591025277a79f687f1961fe5ed8","static/vendor/retained-analysis/licenses/vega-parser.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-projection.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-regression.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-runtime.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-schema-url-parser.txt":"886fb7d90ade30e3ddb1509c644b649738f4461427d128d278b41e0894175881","static/vendor/retained-analysis/licenses/vega-statistics.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-themes.txt":"0344cdac38613ef402506fb143f7e37fb4cd91aead07a51ebfe916a32909f3d6","static/vendor/retained-analysis/licenses/vega-tooltip.txt":"6dbbe75e03459c41a0d380bbf85c2221d817733e603960d674c0de546f7daee1","static/vendor/retained-analysis/licenses/vega-transforms.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-view-transforms.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-view.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-voronoi.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-wordcloud.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/manifest.json":"298dbcaf1a3852db247cd781ef60c620d67c6ddfdcd646cae38a321368a280a5","static/vendor/retained-analysis/vega-embed.min.js":"12d02acfbe3ec59ef9a37dd4822a2e04e2961b5bbb671bbe661d2221715b99da","static/vendor/retained-analysis/vega-interpreter.min.js":"13002339c7e6231ef919584cbdf7391b246ea5073f686ffd707c62628b7f87df","static/vendor/retained-analysis/vega-lite.min.js":"58c27358e26f2d319cf62f45bc17a4c8362f08645001df2ec8d341eee4097c7f","static/vendor/retained-analysis/vega.min.js":"a71f14828abacac82fbe8adb07ce65cbb82561d8eef188053c4a1c09e0164dcb","static/volume-editor.js":"d48806cd446ea05586b524c24340d87abe8c69063ea6b15c254f76b3e51c5db8","static/workbench-layout.css":"7c5ab2b939ade04c44b4d028d29bc08da70e05881c744cca78bbe72214da0268","static/workbench-layout.js":"da0bc85d8c5e14db95eb161d2142c4c75a9d79f49a9e4da9ce3066b11b568d03","static/workbench.css":"669aab1c0c8d680c60848ae96ec1925151ea663238d4412d45862ef60112391e","static/workbench.html":"2e7234abdceb8d4e4c1a2d74196fc2d8d51900982cb28dd05dd7d701ade00e1c","static/workbench.js":"167a48d39b0831ba814c31321f0f936d969e9b322c71b11a25a3f24ca9fe889b","static/workspace-external.js":"117522dba6004de9e321795435334bcc75a2635c3d21d58c36e7da2be4ebd3de","static/workspace-fixtures.json":"3874c3f996c9676549b7a7117ba5219a7a58eee275cbb302fcaede85478ee119","static/workspace-phase-timings.js":"2230164b2b7200ef941e4f0c65a02b66ed16d477e483029d02306c403c58071e","static/workspace-project-activity.js":"720da82509c23b5de0c04e880e13d23bafbe3476c06446f6467760b417a110a6","static/workspace-projects.js":"3f465ad13a9f6bea9c7e812507510b4b5469e7569d7a1c22884115b6628a4747","static/workspace-real.js":"de4036bab21c3ca2702488658a9fa8733cf0adb822f4ec3322d5e101a96b60a2","static/workspace-scientific-comparison.js":"e2a1068011135bf845c1d962cf1e11147419c8c9ab8da36a80267be427762ade","static/workspace-service.js":"568b8258c35e6b3ca2cfe08b64d914111e16ae98d45f5d40f7aa40a20c20098b","static/workspaces-data.css":"9f6151c17baf04b589ab997a3063435b1d035a397a92a48b0badf2911f08091a","static/workspaces-data.js":"da2807d18da4ef4c366bb178d30b336d0b9896fd349b76dd97183e7237022dd7","static/workspaces.css":"3d0eacbd50425b4cd2ce9cfacdf5bcdb8fe533d7a57248f16325242f625cd278","static/workspaces.html":"c16bf3ad0872b8f40f9d00ad6f2ae6366d2c3193aafa80bb496bbf3a41c183cd","static/workspaces.js":"099c17dc3f40cfb55d5ee01e9aaac118c63f7a04fa93539d76d866f33a70daa4","synthetic.py":"ef7ab97a4f2a8e60a24ffecbee10f29d1c48779c0d80bc76c2843b720c7c51c3","text_classification_export.py":"2510ad278a95892d4453ffb5b146ae47c11f8e31f3dc4cf9fb005eec79270bdf","text_classification_proposals.py":"673375f7b718a6b9f2f64624de6c6ff7fa080a3754b263b9f4e34c7585cf5d15","text_corpus_export.py":"3af4883df1b714519fb4e9be872f39de20dc3013deca720e32a89789cbdbff69","text_training.py":"1eeaa53ba21885e6e0afc933f7161dbf20a2681dbfebedaee4e1eb7ec1c59914","torch_runtime.py":"84a66b6a195c30db01ffc574f94e0139f4df7d7c7872452a3f50ce82e9db3ad0","torch_runtimes.py":"010ed4ac47e013dbda07f52e3dbbddf95ab3a5367e4e67dfb6f4d7ae7b10bb39","tuldok_client.py":"f45e60dc4132cc1b9e4b30ed8dd6100795aaa9f224efc40e82403dac86ba8f60","tuldok_extension_sdk/README.md":"ef96d551014c30d0a4ce364840ecf987be087e4b396e6dc6c15eabde814c616a","tuldok_extension_sdk/__init__.py":"b7e0b3167de54dd1ce8bf5209af92ee0bcab9ba6eb6fc9f8c7db4e3fea96fc18","tuldok_extension_sdk/__main__.py":"33da45a3a096e4177e83d3f1c718dd27851f795c85665c33bea0329159baac1e","tuldok_extension_sdk/contracts/contribution-v1.json":"2e1e038c3c1ba09c6b7525a1f9ed2f8cb083fde31cf067f12cf57b76f3f7dfe3","tuldok_extension_sdk/contracts/extension-v1.json":"e78280176fd239b387ffc8b850a7bbff61f069cb47d4c7f9077e7d690c116d25","tuldok_extension_sdk/contracts/parameter-v1.json":"12ea65f4eb51fc71ba4c98217813b0953aa73cda0fe13e8a6c4b0d4d8acc7305","tuldok_extension_sdk/diagnostics.py":"d2439f8ab20dc7a3b829b547745fd7fa84d320ac4df2930b600cd36014689699","tuldok_extension_sdk/evidence.py":"53027c44b78fde2db62e736d9b8ed66915b17bfca3fefe36eee30cae59af0a70","tuldok_extension_sdk/manifest.py":"ed5d54298bbdac8bc272ff27f75d6163f05dbd74c390d2955acbc74e673f2dae","tuldok_extension_sdk/publication.py":"2483b2ae5cb6deef05d48a0f4b9d10b0b29166da139a909ea3c91b57911d9701","upgrade_metadata.py":"f78f6113b3687d808536f873153c83893ed91bf1e247986b29ac00d4c226af8f","volume_assets.py":"def73d68685a1a231ec1095e5c5161dafde86d0379425fac0782a9e177a17b1d","workbench.py":"c71aac71dbeed841b7f24d8d8bfc6da020a9587b4c3d304bfc4b68c608fdf89d"}} diff --git a/docs/plans/external-project-save-load/reports/m2-tested-source-manifest.json b/docs/plans/external-project-save-load/reports/m2-tested-source-manifest.json new file mode 100644 index 00000000..ca5e87d7 --- /dev/null +++ b/docs/plans/external-project-save-load/reports/m2-tested-source-manifest.json @@ -0,0 +1 @@ +{"baseline":"af276565fae9c08c1084d37d8937ba5d97599f17","files":{"access.py":"50681dd5b2aaba01a311a7465b15161ca264fa4ca9812b95f15914adb5d808f4","agent_api.py":"2d3e105742e1de37d94e031b1523cf9b63119c099760341e1ac5a4eec093c3c4","ai.py":"a3b04a3015496ff3c66c3eb450ccf887bd5726229669ac0515d95a2761ad3a9a","ai_codex.py":"debd26342e73a87635d772e3ee59d30bb822877008d667b47997f52e22f94f16","ai_http.py":"73ca648fe00e8f5a4022b14b72c80dd695228706a0f2b79aee605a524b8f2585","ai_openrouter.py":"70930124fac7ef08941f571c1d4492f72b29aa94807cfd5d66bc32555432b160","app.py":"d71fc0740f8fab0dbffdfd11cbc132e5180149611dec045f488ab9d053be4c48","audio_assets.py":"7279a3b5fb3d6f84ed5a6e366ff9aa916a62b242c2b0b97dbe779f4da3d01755","audio_classification_export.py":"0f0643470cfa3b015c2d56c0e8e1dc2ad4944bc825127b9131fbbdd36349052d","audio_export.py":"bc20d32089b3a004504c6cb072e18db9e285c9195db84679670fe460ef51cfc9","audio_training.py":"396500bb2a63054c88b2720b8c168617641fe2ff9fd2285860a6dd894523f949","backup_metadata.py":"4d050ed173b686c249157f513506eea9485f4cd6bbd05cb919a0f10113ba4a7d","bulk_import.py":"b8f7c55947cb82689850716b5a8f6f95422efde2e87c4fa3c640af004b8a2fdb","candidate_packages.py":"148c2a53df5850fa3f9de869da7f4885b152eb49d3f5f5cf4a829befdb9eabff","canonical_release.py":"1f92e2d822602211475a5e7c13198388cc0a36aa36e0872f66d9fd353dcc2061","caption_import.py":"86496e5ccaa5dbbf32d9731e7acfb76cc5c0b6a147e333e9a53aa8e9629ca406","caption_proposals.py":"a091a252631693cbf92423b8eefc054916156739598ff2b0bf46ed6e5f3930a6","curation.py":"b25576749afd01f9e699133c5db7e6113f9a5f0ad8961dafc4084292896b0049","dataset_recipes.py":"78ae918228f15f9d5924985075cb96535b8953b4ad5faa821d0cacd0a50e3c88","dataset_releases.py":"45c039d91f6ab86628a2aaf42e34df684b59954c62a747ede5844b477659e4f3","experiment_lineage.py":"386f964dd2488ca0f5905cd6823a19228b6ff80e767a3b1c52130553f6188623","experiments.py":"232e2acbb1e9af3d410fac73a4e9742c41cc3358824f0e825fa6c536a708ba43","extension_bindings.py":"4172b8209a8952af04b8f495f301ce52c02258526454bcc790f8fd1676b5c3b0","extension_canonical.py":"fc82f6631e911f8f5459ebe73e6c70effeebe3e6c1414edd242d03406a46ef26","extension_contract.py":"890bb254d67bd79c9da9f4e8bf77f5b00e7bb850d696b9eb1ac6b632189e49df","extension_evidence.py":"244340a36b6593f58f1645b65ecf648b9805fe43cac8b20f80c733ea7e025a32","extension_schemas/contribution-v1.json":"2e1e038c3c1ba09c6b7525a1f9ed2f8cb083fde31cf067f12cf57b76f3f7dfe3","extension_schemas/extension-v1.json":"e78280176fd239b387ffc8b850a7bbff61f069cb47d4c7f9077e7d690c116d25","extension_schemas/parameter-v1.json":"12ea65f4eb51fc71ba4c98217813b0953aa73cda0fe13e8a6c4b0d4d8acc7305","field_evidence.py":"da5db2e2a6cea72b687d2e85d53f6a509a26f6ad0384669230ac2d2ba0be9adf","frozen_classification.py":"a6a9fc244bcb7de7da5217fb36d5e73bb03ca1756c9c63770c23a78336606649","gateway_discovery.py":"8a02e2ef9038859c1accce9ea3e31ea489423f19799a279f4be6f9afd61297d0","grounded_candidates.py":"2ca917b14fa988116b50cc873f34f0d9aefca60182ecb94d3c3588419b29ceeb","image_classification_export.py":"995edefa24fc039505337fd8d2915e45d173c18a03af513d9f5d8eb234225b96","image_generation.py":"c3a62b3cf0bf1d6f6d453a63f7f2a7d08de63864d880959bd0eab237d9856858","image_sources.py":"374d822033ddf16c1593aab130853aa6e96d28c6e4d0723e845b6d1e158c3f5f","image_training.py":"35150740f898c33149a6c52289a9296b9e6f611a201fb5da03c7fd7b1f3e4fa0","immutable_assets.py":"82d177c273240dfca1f3cca539cd12dd54940521f298190f9a58c8fc0fa58aed","mcp_observer.py":"f138c2bb20cb2677e46ce1a96637971efbc5a7c6975113c1177e104c766512ab","mcp_stdio.py":"a37cf211ded719242499a90772ccf377c2ba52a895621c1ed618c37ed065deeb","mesh_release.py":"09d29a60bc37826b906097debaf7eb8577a425d3d791ff5df8d3b03607b10de1","meshes.py":"2a0a5b55fe2f0a412d8827bd12b3c129ae6c0c44ed822584276610a44fe67be3","metadata.py":"bc807e1da0c2fdae6104623f54745f622998851d5dc9eead8281ed9f9aa443ce","migrate_sqlite.py":"be1a79a1b6f5e2c8e4c99862043a5afcd4f30d6539388c1600908f4a4f3f92da","model_definitions.py":"96d7b3659b50badb42e7a40c0788ec4046368218d0c5fd629e7300985c4730a0","model_evidence.py":"37d42570855b8c8acc70d947912c1a9af09639761c5ed32d01a756aa4fffd14e","native_detection_import.py":"80466232bd51f58bca9a00ef68e278da087b012e7f1b3b1171e6e51db3b8c572","native_text_import.py":"3fc35bc1f04e056a588e8e05f3f7b41ff894c90dae6cee2f1baaf37a7098d167","one_fit_evidence.py":"e70e64499ccee8614824e499789944a64d0abea5a6eee772c5950b9deb9428c5","phase_timings.py":"03406d957da7da1305a07793a3be0830f2169c02971fdd9fb2e855f54ac627bc","pipeline.py":"6d68a8fda198917d5f4e61d6a452ad39e54ae03479d30768fb9ddb5f6734c341","pointclouds.py":"3c7b9e56b6a3cdb244b7059d44ae5b941e717a99042bf1a5ab0d28934d8d8310","preferences.py":"d6279b18375fa8695584a02cfe962b56e77cf4f3521c5e33cc4baf09f8e4dab7","project_activity.py":"8b6cabcb3f84b35b7b8f6cc2bedd4df43a1eb285eaec9090975d5ae30cfc49d0","project_artifacts.py":"706a65d3ab5eaf1f3b40298dbe2fe982ab5c41b7a383faa7cbe3ef16549efa78","project_cli.py":"a2a0121273e9cc4ba4253c80aa53dde6019480b289c911969fba2d2abea75d42","project_data.py":"a474aa047073e425d90a822732b94dfe403ad6d2c05c3b9b2458496ded6c0516","project_data_fork.py":"3f49c244eba627e35d6f0fc9e7384c665113de6e2afa2219593440b7350ac911","project_dispatch.py":"c3c302a226f62198596d4227d4965e5df13a15975d158ff02929258058fe1daa","project_fork.py":"cbe1e3eddc7a9ba3ae095de946b9c2280fca9611e15b8912134dd120fa2d7d96","project_history.py":"b946b5baf667268707cc1b4a349541e9bf18975f26d5cc4dc774f2672301d679","project_material.py":"e6063a06d7d87a2b818f9b182b550a2b699b786cc54bbe682d12ef5afadd95c8","project_producers.py":"8548603cf235bb532461f29dfae7a7a000e41dedc996aa53c703a8d071dbd352","project_projection.py":"852ab1305a43a8911f54f31a570720476d5e76d7f8aa2d18bf21051de3335c66","project_session.py":"093f3b3b1551f091a9d296c034e63a272a5d7e4c0a5e85442ea16d9cf8e4ee51","project_sources.py":"614d57e9ba7af3934194198d8e239348c9553abcf509163f6eeaf24986d6d4ab","project_store.py":"bb0e4c480f093ea06b6258303f3447daefd886355ad6345355238cef22e4ff2f","project_text.py":"adf90ee6e53b8dc1e8b68287269e907a4da1112704489db4eee1efddf0b65e5b","project_workspace.py":"a1845b16c84b4f4472ce2a0e5fa9775dde6f30d3e88dda96869eacaa77dcfca1","projects.py":"d9047cf93809ea6a848bf7cf233f25f5e9b137ee2c8027b9aed6b3c6da4aaa75","pumas_gateway_descriptor.py":"b9c4259bda856fd19d1d82b3a2c000a74ab3979832248fd3f1f1d3d6309c0752","pumas_operations.py":"e8b8acee54a027633ae39f00a23b3d4845ae474d031690134cafb1c75bf73511","pumas_vision.py":"5659c7731b5011ff39a0a9b7c27712382983f16286540f47f6b1337941bbda7d","recipe_contract.py":"dc57a657dd59cee5297cbdaf2426a2d147424a1e737cc0dad799b6084b873646","recipe_process.py":"59e5829e28f11363ad4738e5998c0a4a52df2a1f18f08580b1da4b25c378c3a4","release_validation.py":"a04e0f3c941256422b0c3a8c28665b6fba025df9d3cfa6cd5d5686e2bb8de95b","retained_analysis.py":"bf8ba164bc1ab01521bc90025d95f5f6ee8155e7f4beca0101b0260d7fff7382","retained_analysis_schema.py":"8d6a743567eab6b99e9132721eabd12e7a381c742c84911e6ef58478a0ea43d3","rheon_sequence_contract.py":"063d17a3bbc92a27c26c0f5dd4588a9478091ebbf53ce0ccb818cee6a9393b98","rheon_sequences.py":"25193c926bd17ef8bf89456eabb3fe77b4eccddbe2b50bae1c0dc5362c79bfb8","run_capacity.py":"6811c5b2255b2a57c92147bab448a82c89876072d260ef217eb7284c511e8cfa","run_coordinator.py":"b09636e6896557898c88bfc3ebc0bba01077cbb1769bafb3497d45283c89de1d","run_publication.py":"45fb8fd06ebf6e457be6cac2dc966e1bee632a648f93e4bf37718a8d7596acc2","runs.py":"56caf719a28d21ed4ca518470cad802cb71ec32106afaa5bec6743e698b6bb1d","runtime_snapshots.py":"dad9e4ddf9c11c0e1f2d5b9a7afd6877e7be375fe02030c65b684bca2b0ef45e","saved_searches.py":"fcd4021fc20702568f1e44a165473aa752b9a9794a958ff31b33c42aba7114d5","saved_selections.py":"4e89ee272fc8a685ec76b3737879d6560661a73e03fca264c2057785905418a3","scientific_fields.py":"0dc4b5de499c115f1adbd0c7d14bdcece1a4c503ff9def48546306559f2eaddf","scientific_release.py":"b419730e7cc00212632b3adcdf63997343b6fb9aecd07a6ad2cb6f5c1728fb60","sequence_assets.py":"c1cca2c57eea007dfb2b9c3383ff941c13ac1f446d69f7950909353a32d8fe30","sequence_batch_import.py":"b04b814531f73b0af99f5e99aadeab581c4d626030c37cd7eb9cf28e60eb569f","sequence_release.py":"07a3129e3cc0091a3f21fea00d245e90af0062d076cb1951ebad59d9179aa65f","spatial_assets.py":"4dce8a453715ef7567686298b3151124795d3ee5253885a6ddbd237d59b620f1","spatial_release.py":"4d6ec06fceb72257a9d25da09ab24bcf13fc38f66ae50e8b39d5a5796f9b357c","static/app.js":"b82969e67e7a7792a11f239eff2fec959158e1dd78d08a84d68daedb93aad6bb","static/audio-editor.css":"6c6cecd83f78d0a37af9011f5a3716dec6d41686e7de85af7e8b3d9e171bd628","static/audio-editor.js":"d397bb98480077326b4c96035d891f1d8eb77cef8a04f3b2aef1eb34d5bbcf40","static/auth.css":"4ad4c7e8e4aaa04b4837698493b59365859b89f2cedd8717492ba6b57615f620","static/auth.js":"6a38fa7dabf57b2c99d293c6851a732af76a19193bea43f7d2a8b8460d71faec","static/bulk_import.js":"2d19443342dad5d083ad66e32f5b4877a825296f1a41d8996befd336cec7d367","static/caption-proposals.js":"09906d8ea799ee7def657a5d1c7746a5dcbe25797f7849c7ac27364089a083d7","static/caption_import.js":"280a116a086c3e8c69fcd17e24aba6630bb6c3d3c1e653a5ed84c184895ae850","static/curation.js":"2f51c2e063eddbe16cb92dfcaa96c35c749dd61960ebd92c8c538bf0c6ff389a","static/experiment-lineage.css":"232fec3043b92ae7aa91db499a14e8cdb9ebe5598c8d7f01d805e3e3d8d4859d","static/experiment-lineage.js":"085ee3cc990f1986db357e1e8f7bace0c16a2257bca9443ff412c464d91a53db","static/index.html":"34ef87f168f4f1cd291be6c86464e02969b3c760316f0a59f01f50890ec973b3","static/instruction-responses.js":"3fb4fb8d78154bedb37ef90afbcc269fdab0938c4ac50236f38a7f26f54ac940","static/meshes.css":"0658afff9e77542442b1c5bf1c10ce50603fab4beec1f812ac40a6574f81076e","static/meshes.js":"c2eb7622dd4e0a172db9db36e46a0ce66800d5b03aca989cd1f08dd40b8558c8","static/native_detection_import.js":"b07d3a9663d5b1534fca24dc7481caf4cb166bb4a83c4c97c2a78fb9c8d50d82","static/native_text_import.js":"1704549e9e5cf489e6351d5126858eb774ddd94d060e596a0bfba5fc00ea8773","static/pipeline-editor.js":"2272f1a9b106945203112a46d73643cc5d4dbbfd92f0d7c09d1cc36a713042d2","static/pointclouds.js":"34851447f34ea0204887b6aae4a86d73f6f94c8d1726b8e82b892027336c0c84","static/preferences.js":"fb22c04b6856d33b7e298aa1989d073c937ab40020025f39b86daaff62b948f7","static/project-files.css":"d1e051bc15f0b949e28860ed2e4acce2cc4c349b4329ecf41abd8da0e5f3f4d9","static/project-files.html":"689e1a1b323dcfbe19f72491ed084bcbace6162c82a851e6fff5940eb4629024","static/project-files.js":"b7ee53e6c2fee1e7dfbcc84840b1dd05e9eb920b7e28c3f604040f17f70bb2d4","static/pumas-gateways.js":"02df8f34362fc2e32f6a4582a9cb71adaa5d7b83ccd897803b9e1d02c7bcb932","static/pumas-typed.js":"50fd9fc961dafdd0c34cfcb1137d04f85a99f324f76fd5217f227b789c8835ad","static/retained-analysis.js":"d9dab53d15e6e50ecf8f1b9e182c43eb330c4731e574758a0e3026f44acb44df","static/retained-checkpoint-curve.vl.json":"6603c860ce76a434a11e6c96cf7418246ce2dd5da3d7a56a5f2448a0537ec183","static/rights-note.js":"fd09cef5c52b2635ad42998483c7054260140459444fbd948bb596e5966feb11","static/runs-view.js":"7568b843ad9b56c1c6878a9756553cdf8092dea3b954aec1947b8f32d730ca85","static/saved-searches.js":"49210b9da9dd7f7372e977c15543a77c5ada1df82ad9abdcb350026e16517892","static/saved-selections.js":"2c2feecca798217644247f98aab8e7a00d707a25d4af75e02f9d906ccbd905a3","static/scientific-fields.js":"96e6289ade2a634e9648cbe8aa899e5bfaddacddcea120835bda8cb6d65e14a6","static/sequence-batch.js":"081bbeae46b381a3ed8ebdfed5c03334004795b96e37cb18feb867b09d22cd53","static/sequences.js":"aff0628408e5e69f1364228f3cb9a4fd821f5f2122036d3497532f3bfe1c0c93","static/style.css":"25103299f9add27eafbee40c9a12634d7640a6c54a785c981066b6403de0603d","static/text-classification-proposals.js":"03dbec1114700f383f8bc9883c8a4cdaae59d898fc44240022b2011893965d6f","static/vendor/retained-analysis/licenses/d3-array.txt":"552e22a104bb752daf35286bfc180b7bb6c206cc6502853979bd19390102d644","static/vendor/retained-analysis/licenses/d3-color.txt":"decfad6f87be1e200338d792b77793997db27b3f4e610f870370393ed53daf4b","static/vendor/retained-analysis/licenses/d3-delaunay.txt":"718da82757e9041ac674c157d9fc70435543b32f4c1baf4a9f08b71c5fa6495e","static/vendor/retained-analysis/licenses/d3-dispatch.txt":"2127431ebe97ddcbabb32b1049bade99e4c4b2412d3f8b39ec161814f5e2e11e","static/vendor/retained-analysis/licenses/d3-dsv.txt":"2823e0d11f6e134bfe32ec3b60f042db2d7be3e4eb3e7f09b7c93a3bef502ec3","static/vendor/retained-analysis/licenses/d3-force.txt":"2127431ebe97ddcbabb32b1049bade99e4c4b2412d3f8b39ec161814f5e2e11e","static/vendor/retained-analysis/licenses/d3-format.txt":"dfd3916622f5f66f001a5c9ec0beb6d4b0208b9ba41a81206bf6fa0940b6e980","static/vendor/retained-analysis/licenses/d3-geo-projection.txt":"8d78a83093868a5a6d86835807514b0f5bda66403a0d75cbf804ad72a3619463","static/vendor/retained-analysis/licenses/d3-geo.txt":"e3243e2788023f118b725d27189d529d13da89dca0801380a9ecf96da3a5fa67","static/vendor/retained-analysis/licenses/d3-hierarchy.txt":"2127431ebe97ddcbabb32b1049bade99e4c4b2412d3f8b39ec161814f5e2e11e","static/vendor/retained-analysis/licenses/d3-interpolate.txt":"2127431ebe97ddcbabb32b1049bade99e4c4b2412d3f8b39ec161814f5e2e11e","static/vendor/retained-analysis/licenses/d3-path.txt":"16d2d2a1800677ddece729f5acb30205b5acbc21529df90cd8bf3d202c08d0b7","static/vendor/retained-analysis/licenses/d3-quadtree.txt":"2127431ebe97ddcbabb32b1049bade99e4c4b2412d3f8b39ec161814f5e2e11e","static/vendor/retained-analysis/licenses/d3-scale-chromatic.txt":"b982e295bd298c8d3e328a7cc623396e6e708fb6fba5b35e2dc4daf36728c8ed","static/vendor/retained-analysis/licenses/d3-scale.txt":"2127431ebe97ddcbabb32b1049bade99e4c4b2412d3f8b39ec161814f5e2e11e","static/vendor/retained-analysis/licenses/d3-shape.txt":"decfad6f87be1e200338d792b77793997db27b3f4e610f870370393ed53daf4b","static/vendor/retained-analysis/licenses/d3-time-format.txt":"2127431ebe97ddcbabb32b1049bade99e4c4b2412d3f8b39ec161814f5e2e11e","static/vendor/retained-analysis/licenses/d3-time.txt":"decfad6f87be1e200338d792b77793997db27b3f4e610f870370393ed53daf4b","static/vendor/retained-analysis/licenses/d3-timer.txt":"2127431ebe97ddcbabb32b1049bade99e4c4b2412d3f8b39ec161814f5e2e11e","static/vendor/retained-analysis/licenses/delaunator.txt":"9994d103e3af6ded25bad0ae99f08f6758f08cb9b261ab921ae7a6279769cdf0","static/vendor/retained-analysis/licenses/fast-json-patch.txt":"488a71c4303f0860094c1e59fa32e3302e9e79e2b63b8731761c22c2101c1dc9","static/vendor/retained-analysis/licenses/internmap.txt":"c596ec7154f49d5b5354b5483891de003b9af0f127aa007a6c5cea659e53d7c4","static/vendor/retained-analysis/licenses/json-stringify-pretty-compact.txt":"0454b235e715629194c3a1efb8b5a1b5c4a26bea9e8d54f4061b603d34cbea18","static/vendor/retained-analysis/licenses/robust-predicates.txt":"4731daf16265d7f0acd5c305a2dc745cf4c7562e027d44c206ab38637d8558ab","static/vendor/retained-analysis/licenses/semver.txt":"3ef9e4a3ab62e8eebd271519b275803e13e59a790cbdca7f617cbff34c56ed80","static/vendor/retained-analysis/licenses/topojson-client.txt":"140e4489d9507aedeb615404008fe1d51b418b7cdb428ea840481105c14eda97","static/vendor/retained-analysis/licenses/vega-canvas.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-crossfilter.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-embed.txt":"9ba0f1b61d8bb4c228264ed5305c543914fa0fd3f3e302623138a3f93d649304","static/vendor/retained-analysis/licenses/vega-encode.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-event-selector.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-force.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-geo.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-hierarchy.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-interpreter.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-label.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-lite.txt":"1d88c9e50bf518513f0db4b4faff48d26fce5591025277a79f687f1961fe5ed8","static/vendor/retained-analysis/licenses/vega-parser.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-projection.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-regression.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-runtime.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-schema-url-parser.txt":"886fb7d90ade30e3ddb1509c644b649738f4461427d128d278b41e0894175881","static/vendor/retained-analysis/licenses/vega-statistics.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-themes.txt":"0344cdac38613ef402506fb143f7e37fb4cd91aead07a51ebfe916a32909f3d6","static/vendor/retained-analysis/licenses/vega-tooltip.txt":"6dbbe75e03459c41a0d380bbf85c2221d817733e603960d674c0de546f7daee1","static/vendor/retained-analysis/licenses/vega-transforms.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-view-transforms.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-view.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-voronoi.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-wordcloud.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/manifest.json":"298dbcaf1a3852db247cd781ef60c620d67c6ddfdcd646cae38a321368a280a5","static/vendor/retained-analysis/vega-embed.min.js":"12d02acfbe3ec59ef9a37dd4822a2e04e2961b5bbb671bbe661d2221715b99da","static/vendor/retained-analysis/vega-interpreter.min.js":"13002339c7e6231ef919584cbdf7391b246ea5073f686ffd707c62628b7f87df","static/vendor/retained-analysis/vega-lite.min.js":"58c27358e26f2d319cf62f45bc17a4c8362f08645001df2ec8d341eee4097c7f","static/vendor/retained-analysis/vega.min.js":"a71f14828abacac82fbe8adb07ce65cbb82561d8eef188053c4a1c09e0164dcb","static/volume-editor.js":"d48806cd446ea05586b524c24340d87abe8c69063ea6b15c254f76b3e51c5db8","static/workbench-layout.css":"7c5ab2b939ade04c44b4d028d29bc08da70e05881c744cca78bbe72214da0268","static/workbench-layout.js":"da0bc85d8c5e14db95eb161d2142c4c75a9d79f49a9e4da9ce3066b11b568d03","static/workbench.css":"669aab1c0c8d680c60848ae96ec1925151ea663238d4412d45862ef60112391e","static/workbench.html":"2e7234abdceb8d4e4c1a2d74196fc2d8d51900982cb28dd05dd7d701ade00e1c","static/workbench.js":"167a48d39b0831ba814c31321f0f936d969e9b322c71b11a25a3f24ca9fe889b","static/workspace-external.js":"117522dba6004de9e321795435334bcc75a2635c3d21d58c36e7da2be4ebd3de","static/workspace-fixtures.json":"3874c3f996c9676549b7a7117ba5219a7a58eee275cbb302fcaede85478ee119","static/workspace-phase-timings.js":"2230164b2b7200ef941e4f0c65a02b66ed16d477e483029d02306c403c58071e","static/workspace-project-activity.js":"720da82509c23b5de0c04e880e13d23bafbe3476c06446f6467760b417a110a6","static/workspace-projects.js":"3f465ad13a9f6bea9c7e812507510b4b5469e7569d7a1c22884115b6628a4747","static/workspace-real.js":"de4036bab21c3ca2702488658a9fa8733cf0adb822f4ec3322d5e101a96b60a2","static/workspace-scientific-comparison.js":"e2a1068011135bf845c1d962cf1e11147419c8c9ab8da36a80267be427762ade","static/workspace-service.js":"568b8258c35e6b3ca2cfe08b64d914111e16ae98d45f5d40f7aa40a20c20098b","static/workspaces-data.css":"9f6151c17baf04b589ab997a3063435b1d035a397a92a48b0badf2911f08091a","static/workspaces-data.js":"da2807d18da4ef4c366bb178d30b336d0b9896fd349b76dd97183e7237022dd7","static/workspaces.css":"3d0eacbd50425b4cd2ce9cfacdf5bcdb8fe533d7a57248f16325242f625cd278","static/workspaces.html":"c16bf3ad0872b8f40f9d00ad6f2ae6366d2c3193aafa80bb496bbf3a41c183cd","static/workspaces.js":"099c17dc3f40cfb55d5ee01e9aaac118c63f7a04fa93539d76d866f33a70daa4","synthetic.py":"ef7ab97a4f2a8e60a24ffecbee10f29d1c48779c0d80bc76c2843b720c7c51c3","text_classification_export.py":"2510ad278a95892d4453ffb5b146ae47c11f8e31f3dc4cf9fb005eec79270bdf","text_classification_proposals.py":"673375f7b718a6b9f2f64624de6c6ff7fa080a3754b263b9f4e34c7585cf5d15","text_corpus_export.py":"3af4883df1b714519fb4e9be872f39de20dc3013deca720e32a89789cbdbff69","text_training.py":"1eeaa53ba21885e6e0afc933f7161dbf20a2681dbfebedaee4e1eb7ec1c59914","torch_runtime.py":"84a66b6a195c30db01ffc574f94e0139f4df7d7c7872452a3f50ce82e9db3ad0","torch_runtimes.py":"010ed4ac47e013dbda07f52e3dbbddf95ab3a5367e4e67dfb6f4d7ae7b10bb39","tuldok_client.py":"f45e60dc4132cc1b9e4b30ed8dd6100795aaa9f224efc40e82403dac86ba8f60","tuldok_extension_sdk/README.md":"ef96d551014c30d0a4ce364840ecf987be087e4b396e6dc6c15eabde814c616a","tuldok_extension_sdk/__init__.py":"b7e0b3167de54dd1ce8bf5209af92ee0bcab9ba6eb6fc9f8c7db4e3fea96fc18","tuldok_extension_sdk/__main__.py":"33da45a3a096e4177e83d3f1c718dd27851f795c85665c33bea0329159baac1e","tuldok_extension_sdk/contracts/contribution-v1.json":"2e1e038c3c1ba09c6b7525a1f9ed2f8cb083fde31cf067f12cf57b76f3f7dfe3","tuldok_extension_sdk/contracts/extension-v1.json":"e78280176fd239b387ffc8b850a7bbff61f069cb47d4c7f9077e7d690c116d25","tuldok_extension_sdk/contracts/parameter-v1.json":"12ea65f4eb51fc71ba4c98217813b0953aa73cda0fe13e8a6c4b0d4d8acc7305","tuldok_extension_sdk/diagnostics.py":"d2439f8ab20dc7a3b829b547745fd7fa84d320ac4df2930b600cd36014689699","tuldok_extension_sdk/evidence.py":"53027c44b78fde2db62e736d9b8ed66915b17bfca3fefe36eee30cae59af0a70","tuldok_extension_sdk/manifest.py":"ed5d54298bbdac8bc272ff27f75d6163f05dbd74c390d2955acbc74e673f2dae","tuldok_extension_sdk/publication.py":"2483b2ae5cb6deef05d48a0f4b9d10b0b29166da139a909ea3c91b57911d9701","upgrade_metadata.py":"f78f6113b3687d808536f873153c83893ed91bf1e247986b29ac00d4c226af8f","volume_assets.py":"def73d68685a1a231ec1095e5c5161dafde86d0379425fac0782a9e177a17b1d","workbench.py":"c71aac71dbeed841b7f24d8d8bfc6da020a9587b4c3d304bfc4b68c608fdf89d"}} diff --git a/docs/plans/external-project-save-load/reports/m2-verification.json b/docs/plans/external-project-save-load/reports/m2-verification.json new file mode 100644 index 00000000..2c71f46b --- /dev/null +++ b/docs/plans/external-project-save-load/reports/m2-verification.json @@ -0,0 +1,143 @@ +{ + "format": "tuldok_project_m2_verification_v1", + "baseline": "af276565fae9c08c1084d37d8937ba5d97599f17", + "main_baseline": "133d53c0e2985a6559ba9cebd7d4a9f530051f06", + "standards_revision": "5bce3110fc64d61ff8b4f79468184bd6295855d5", + "source_manifest": "m2-source-manifest.json", + "source_manifest_file_sha256": "ef2dc94d76d73fc11a45bcb06cf32dc5399d84370ef2a94dfbf6db800cfa9937", + "source_manifest_content_fingerprint": "128302cf010c2d34b50c40d8f077b086bf35b9954b1b01bbad923b985caae24d", + "source_files": 226, + "public_qa": { + "status": "passed_with_scoped_evidence", + "deliverables": [ + "tests/test_project_files_m2_http.py", + "tests/browser_project_files_m2.cjs" + ], + "passed_http_cases": [ + { + "test": "test_real_sdk_cli_mcp_roles_lost_reply_and_stale_binding", + "log": "m2-http-public-first.log", + "note": "This method passed; same log also contains an earlier independent mixed-fixture error.", + "receipt": "artifacts/m2-http/test_real_sdk_cli_mcp_roles_lost_reply_and_stale_binding-87e5b66ddb2d42d2af79baf043230ef0.json" + }, + { + "test": "test_unpinned_scientific_reference_uses_current_observed_hash", + "log": "m2-http-public-mixed-third.log", + "note": "This method passed; same log also contains an earlier independent mixed lexical-check error.", + "receipt": "artifacts/m2-http/test_unpinned_scientific_reference_uses_current_observed_hash-d275379e89044005902d9042f3cc6b54.json" + }, + { + "test": "test_public_mixed_authoring_git_clone_and_cold_reconstruction", + "log": "m2-http-public-mixed-fourth.log", + "receipt": "artifacts/m2-http/test_public_mixed_authoring_git_clone_and_cold_reconstruction-2b655c5307474903bced1e1a46b03ba3.json" + } + ], + "actual_browser_functional_and_cold": { + "passed": true, + "source_manifest": "m2-final-product-freeze.json", + "source_manifest_file_sha256": "3de54866faec96fd5d8236178a2664ed93b791dc62fb8ea0166a2522cb798a31", + "receipt": "artifacts/project-files-m2/run-DDH5l1/receipt.json", + "overall_status": "failed due responsive overflow only", + "source_files": 226, + "http_errors": 0, + "runtime_errors": 0 + }, + "issues": [ + { + "issue": "Selection result native items misclassified as page; server mutation committed but UI rejected acknowledgement", + "state": "fixed; actual UI selection and search/history flow passed on next source" + }, + { + "issue": "Unpinned scientific reference UI ignored observed_sha256", + "state": "fixed; actual unpinned import and declared mismatch negative passed" + }, + { + "issue": "Cold image history expands to650px at viewport390", + "state": "fixed by CSS owner: #project-detail-history-list overflow-wrap:anywhere; actual cold responsive check passed at390/1440" + } + ], + "precise_limits": [ + "Git carries tracked canonical metadata. Required untracked artifacts were transferred explicitly as a separate exact sidecar.", + "Study creation/revision inspected zero actual charged accounting; no new admission/training claim. Historical Run was authored through the real closed codec, not executed.", + "No CPU preflight, training, generation, lab access, production data access, product commit or publication.", + "Passed HTTP evidence retained on earlier frozen source; parent owns targeted validation of subsequent unrelated closure/grammar fixes." + ], + "unrun": [ + "CPU preflight, training, provider generation, and lab access (outside this M2 non-execution acceptance).", + "No broad HTTP rerun after the parent-scoped unrelated closure/grammar changes; original frozen-source HTTP evidence retained, latest source validated through focused browser and independent owner gates." + ], + "approval_denials": [], + "owned_file_sha256": { + "tests/test_project_files_m2_http.py": "97af3a4c378800861924fda80de80f1979358931d09dc434be188ed19c067a52", + "tests/browser_project_files_m2.cjs": "f2254befb37e932a76d41697c387b97f3a2f7c6843483d19399fec3eae8f0afb" + }, + "focused_responsive": { + "passed": true, + "receipt": "artifacts/project-files-m2/run-SImFcf/receipt.json", + "log": "m2-browser-responsive-final.log", + "mode": "TULDOK_M2_RESPONSIVE_ONLY=1", + "cold_history_revisions": 2, + "measurements": [ + { + "viewport": 390, + "scrollWidth": 375, + "clientWidth": 375, + "offenders": 0 + }, + { + "viewport": 1440, + "scrollWidth": 1425, + "clientWidth": 1425, + "offenders": 0 + } + ], + "source_connect": false, + "runtime_errors": 0, + "http_errors": 0 + }, + "final_source": { + "manifest": "/workspace/scratch/tuldok-save-load-qa/m2-accepted-candidate-freeze.json", + "manifest_file_sha256": "7a8969d58594b4dc6f9412c5a31c77b2a13f59b807ad013084bfc351cdfde8f4", + "normalized_content_fingerprint": "cb070f64dfa11579ec3b759e6d2b02c12df59b6da2882f7e764489fe99dd3abe", + "file_count": 226, + "end_changed_files": [], + "css_sha256": "d1e051bc15f0b949e28860ed2e4acce2cc4c349b4329ecf41abd8da0e5f3f4d9" + }, + "checks": { + "python_compile": "passed", + "node_syntax": "passed", + "diff_whitespace": "passed" + }, + "remaining_delegated_blockers": [] + }, + "fresh_material_http": { + "selected": 1, + "ran": 1, + "errors": 0, + "failures": 0, + "skips": 0, + "seconds": 20.812, + "freeze_file_sha256": "7a8969d58594b4dc6f9412c5a31c77b2a13f59b807ad013084bfc351cdfde8f4", + "frozen_files": 226, + "source_matches_before": true, + "source_matches_after": true + }, + "independent_review": "Clear after justified source and CI findings resolved; read-only review, not runtime evidence.", + "ci": "Configured, not yet run on the M2 commit. Existing M1 CI is a separate subject.", + "limitations": [ + "Live execution and producer starts unavailable pending M3.", + "Independent Save As/relocation/adoption and extraction remain pending M4/M5.", + "No user lab, production data, training or provider generation was used.", + "Git carries text/manifests; required binary sidecars were transferred explicitly." + ], + "tested_source_manifest": "m2-tested-source-manifest.json", + "tested_source_manifest_file_sha256": "7a8969d58594b4dc6f9412c5a31c77b2a13f59b807ad013084bfc351cdfde8f4", + "post_qualification_change": { + "file": "project_data.py", + "change": "Remove one redundant EOF newline after the staged whitespace check identified it.", + "before_sha256": "a474aa047073e425d90a822732b94dfe403ad6d2c05c3b9b2458496ded6c0516", + "after_sha256": "227ea9c3dd7b7c9fa45b781b0f3aef92f6b776ebf74e108f0594e048afede64f", + "python_ast_equal": true, + "runtime_campaign_repeated": false + } +} diff --git a/docs/plans/external-project-save-load/reports/m3-first-execution-source-manifest.json b/docs/plans/external-project-save-load/reports/m3-first-execution-source-manifest.json new file mode 100644 index 00000000..b528ab4d --- /dev/null +++ b/docs/plans/external-project-save-load/reports/m3-first-execution-source-manifest.json @@ -0,0 +1 @@ +{"baseline":"c22e9d02c87938514a7a1c9b3b0532158008ca0d","files":{"access.py":"a3588761b95d595a0fac750d669b457fbd8057a29c360c8ab0f43be5a3a4a745","agent_api.py":"22efab0a4c5cd7e758b211a467c8ae6864fad1908d80083bffb12b5d6be8a614","ai.py":"a3b04a3015496ff3c66c3eb450ccf887bd5726229669ac0515d95a2761ad3a9a","ai_codex.py":"debd26342e73a87635d772e3ee59d30bb822877008d667b47997f52e22f94f16","ai_http.py":"73ca648fe00e8f5a4022b14b72c80dd695228706a0f2b79aee605a524b8f2585","ai_openrouter.py":"70930124fac7ef08941f571c1d4492f72b29aa94807cfd5d66bc32555432b160","app.py":"d71fc0740f8fab0dbffdfd11cbc132e5180149611dec045f488ab9d053be4c48","audio_assets.py":"7279a3b5fb3d6f84ed5a6e366ff9aa916a62b242c2b0b97dbe779f4da3d01755","audio_classification_export.py":"0f0643470cfa3b015c2d56c0e8e1dc2ad4944bc825127b9131fbbdd36349052d","audio_export.py":"bc20d32089b3a004504c6cb072e18db9e285c9195db84679670fe460ef51cfc9","audio_training.py":"396500bb2a63054c88b2720b8c168617641fe2ff9fd2285860a6dd894523f949","backup_metadata.py":"4d050ed173b686c249157f513506eea9485f4cd6bbd05cb919a0f10113ba4a7d","bulk_import.py":"b8f7c55947cb82689850716b5a8f6f95422efde2e87c4fa3c640af004b8a2fdb","candidate_packages.py":"148c2a53df5850fa3f9de869da7f4885b152eb49d3f5f5cf4a829befdb9eabff","canonical_release.py":"1f92e2d822602211475a5e7c13198388cc0a36aa36e0872f66d9fd353dcc2061","caption_import.py":"86496e5ccaa5dbbf32d9731e7acfb76cc5c0b6a147e333e9a53aa8e9629ca406","caption_proposals.py":"388a097a17f52a5f92191469fa5abcc3ce311455bc0d0999f58378c76c1c48d4","curation.py":"b25576749afd01f9e699133c5db7e6113f9a5f0ad8961dafc4084292896b0049","dataset_recipes.py":"78ae918228f15f9d5924985075cb96535b8953b4ad5faa821d0cacd0a50e3c88","dataset_releases.py":"45c039d91f6ab86628a2aaf42e34df684b59954c62a747ede5844b477659e4f3","experiment_lineage.py":"c1cac79597ff2227d356e9d370e46e95ed92e035162d310a5822460bef1150c7","experiments.py":"c0c78b891bbb927a1ab802d62bf11e7dee12ef28f180130208df443eea49924a","extension_bindings.py":"4172b8209a8952af04b8f495f301ce52c02258526454bcc790f8fd1676b5c3b0","extension_canonical.py":"fc82f6631e911f8f5459ebe73e6c70effeebe3e6c1414edd242d03406a46ef26","extension_contract.py":"890bb254d67bd79c9da9f4e8bf77f5b00e7bb850d696b9eb1ac6b632189e49df","extension_evidence.py":"244340a36b6593f58f1645b65ecf648b9805fe43cac8b20f80c733ea7e025a32","extension_schemas/contribution-v1.json":"2e1e038c3c1ba09c6b7525a1f9ed2f8cb083fde31cf067f12cf57b76f3f7dfe3","extension_schemas/extension-v1.json":"e78280176fd239b387ffc8b850a7bbff61f069cb47d4c7f9077e7d690c116d25","extension_schemas/parameter-v1.json":"12ea65f4eb51fc71ba4c98217813b0953aa73cda0fe13e8a6c4b0d4d8acc7305","field_evidence.py":"da5db2e2a6cea72b687d2e85d53f6a509a26f6ad0384669230ac2d2ba0be9adf","frozen_classification.py":"a6a9fc244bcb7de7da5217fb36d5e73bb03ca1756c9c63770c23a78336606649","gateway_discovery.py":"8a02e2ef9038859c1accce9ea3e31ea489423f19799a279f4be6f9afd61297d0","grounded_candidates.py":"df59b1a7179d328c794059393e41ea80a979366f9509845d38972882af65c65b","image_classification_export.py":"995edefa24fc039505337fd8d2915e45d173c18a03af513d9f5d8eb234225b96","image_generation.py":"c3a62b3cf0bf1d6f6d453a63f7f2a7d08de63864d880959bd0eab237d9856858","image_sources.py":"374d822033ddf16c1593aab130853aa6e96d28c6e4d0723e845b6d1e158c3f5f","image_training.py":"35150740f898c33149a6c52289a9296b9e6f611a201fb5da03c7fd7b1f3e4fa0","immutable_assets.py":"82d177c273240dfca1f3cca539cd12dd54940521f298190f9a58c8fc0fa58aed","mcp_observer.py":"f138c2bb20cb2677e46ce1a96637971efbc5a7c6975113c1177e104c766512ab","mcp_stdio.py":"a37cf211ded719242499a90772ccf377c2ba52a895621c1ed618c37ed065deeb","mesh_release.py":"09d29a60bc37826b906097debaf7eb8577a425d3d791ff5df8d3b03607b10de1","meshes.py":"2a0a5b55fe2f0a412d8827bd12b3c129ae6c0c44ed822584276610a44fe67be3","metadata.py":"7bb728cab2068a415eb071191b61241dc2886a765044d9588487f4e8b8d11d86","migrate_sqlite.py":"be1a79a1b6f5e2c8e4c99862043a5afcd4f30d6539388c1600908f4a4f3f92da","model_definitions.py":"96d7b3659b50badb42e7a40c0788ec4046368218d0c5fd629e7300985c4730a0","model_evidence.py":"37d42570855b8c8acc70d947912c1a9af09639761c5ed32d01a756aa4fffd14e","native_detection_import.py":"80466232bd51f58bca9a00ef68e278da087b012e7f1b3b1171e6e51db3b8c572","native_text_import.py":"3fc35bc1f04e056a588e8e05f3f7b41ff894c90dae6cee2f1baaf37a7098d167","one_fit_evidence.py":"e70e64499ccee8614824e499789944a64d0abea5a6eee772c5950b9deb9428c5","phase_timings.py":"03406d957da7da1305a07793a3be0830f2169c02971fdd9fb2e855f54ac627bc","pipeline.py":"6d68a8fda198917d5f4e61d6a452ad39e54ae03479d30768fb9ddb5f6734c341","pointclouds.py":"3c7b9e56b6a3cdb244b7059d44ae5b941e717a99042bf1a5ab0d28934d8d8310","preferences.py":"d6279b18375fa8695584a02cfe962b56e77cf4f3521c5e33cc4baf09f8e4dab7","project_activity.py":"8b6cabcb3f84b35b7b8f6cc2bedd4df43a1eb285eaec9090975d5ae30cfc49d0","project_artifacts.py":"706a65d3ab5eaf1f3b40298dbe2fe982ab5c41b7a383faa7cbe3ef16549efa78","project_cli.py":"80a5a70d068b90702f8de958da5ebbd3db8f4aac40a9c9fc882696fa283cbd6a","project_data.py":"a7527de1b4ec48534f240adbc725b61f64b4806e409f7476c52b8e684691e9ae","project_data_fork.py":"3f49c244eba627e35d6f0fc9e7384c665113de6e2afa2219593440b7350ac911","project_dispatch.py":"e46b5b91f5ffff92748f22e912f264fd422cd68e208b700ff3d26c7bfb98de95","project_execution.py":"58d94bede5c4391cc775162358811079a262dbb1d39e4f94cf1fd0b2aef980dc","project_files.py":"b092994dd2f23995c86e098cbde7180a4d0e88e660ddda999bc6d9552f6af0e1","project_fork.py":"b886ce2380285a10f879ae46c8883b5c0872481557c8c600eb5e2296185f0745","project_history.py":"17c31ed00d544a79b9ff40425ee66e7fd17e1cf635a73de1921c62dbb1ab20cd","project_locks.py":"0f728ce6aa7cd57b2532a2bcd9430052f5a73fef0d656d0ea754a3d879021b61","project_material.py":"e4aa08633d87cf78e127b7defa7b1dd7cb0d5080b1962ac322f35f1cbc25c6e5","project_material_fork.py":"a72ec7e0edadfe68a84e5afcedce71193b19048c39097a20ea8973d5608bc9d5","project_producer_execution.py":"95aaaadcd9f18a0e9d94ba4603d6f391c935b50371fc054809a837469c023a1d","project_producers.py":"b8d377fd448279738f742bc5bc81540f5da7151bff517a07466586ad3d815b63","project_projection.py":"0d60b41574bea952c695a2919d5328003426eb7515035224dd82d375f09655b6","project_session.py":"96f59122d71f4efc4b8b02c2adc83b53a694f7451264f53abc3d23e880d54f3a","project_sources.py":"614d57e9ba7af3934194198d8e239348c9553abcf509163f6eeaf24986d6d4ab","project_store.py":"245f4dbbcf7568f54a78f8399948c4e569ea8854eeae21b89155fb5e8292d9f5","project_study_execution.py":"5eaf995dd458eeaba8b34ae279fdfb6e5b637a0dab00304890326156bef07857","project_text.py":"adf90ee6e53b8dc1e8b68287269e907a4da1112704489db4eee1efddf0b65e5b","project_workspace.py":"1b469a76179007385d0f754ce2de50fdd228d3ec20a29d9a7fade64bb573836c","projects.py":"d9047cf93809ea6a848bf7cf233f25f5e9b137ee2c8027b9aed6b3c6da4aaa75","pumas_gateway_descriptor.py":"b9c4259bda856fd19d1d82b3a2c000a74ab3979832248fd3f1f1d3d6309c0752","pumas_operations.py":"e8b8acee54a027633ae39f00a23b3d4845ae474d031690134cafb1c75bf73511","pumas_vision.py":"5659c7731b5011ff39a0a9b7c27712382983f16286540f47f6b1337941bbda7d","recipe_contract.py":"dc57a657dd59cee5297cbdaf2426a2d147424a1e737cc0dad799b6084b873646","recipe_process.py":"59e5829e28f11363ad4738e5998c0a4a52df2a1f18f08580b1da4b25c378c3a4","release_validation.py":"a04e0f3c941256422b0c3a8c28665b6fba025df9d3cfa6cd5d5686e2bb8de95b","retained_analysis.py":"bf8ba164bc1ab01521bc90025d95f5f6ee8155e7f4beca0101b0260d7fff7382","retained_analysis_schema.py":"8d6a743567eab6b99e9132721eabd12e7a381c742c84911e6ef58478a0ea43d3","rheon_sequence_contract.py":"063d17a3bbc92a27c26c0f5dd4588a9478091ebbf53ce0ccb818cee6a9393b98","rheon_sequences.py":"25193c926bd17ef8bf89456eabb3fe77b4eccddbe2b50bae1c0dc5362c79bfb8","run_capacity.py":"6811c5b2255b2a57c92147bab448a82c89876072d260ef217eb7284c511e8cfa","run_coordinator.py":"b09636e6896557898c88bfc3ebc0bba01077cbb1769bafb3497d45283c89de1d","run_publication.py":"45fb8fd06ebf6e457be6cac2dc966e1bee632a648f93e4bf37718a8d7596acc2","runs.py":"12af4f77965b333d4dd34caecd024631ac27d922a3a01e1e1dd4bfaf1675e7df","runtime_snapshots.py":"dad9e4ddf9c11c0e1f2d5b9a7afd6877e7be375fe02030c65b684bca2b0ef45e","saved_searches.py":"fcd4021fc20702568f1e44a165473aa752b9a9794a958ff31b33c42aba7114d5","saved_selections.py":"4e89ee272fc8a685ec76b3737879d6560661a73e03fca264c2057785905418a3","scientific_fields.py":"0dc4b5de499c115f1adbd0c7d14bdcece1a4c503ff9def48546306559f2eaddf","scientific_release.py":"b419730e7cc00212632b3adcdf63997343b6fb9aecd07a6ad2cb6f5c1728fb60","sequence_assets.py":"c1cca2c57eea007dfb2b9c3383ff941c13ac1f446d69f7950909353a32d8fe30","sequence_batch_import.py":"b04b814531f73b0af99f5e99aadeab581c4d626030c37cd7eb9cf28e60eb569f","sequence_release.py":"07a3129e3cc0091a3f21fea00d245e90af0062d076cb1951ebad59d9179aa65f","spatial_assets.py":"4dce8a453715ef7567686298b3151124795d3ee5253885a6ddbd237d59b620f1","spatial_release.py":"4d6ec06fceb72257a9d25da09ab24bcf13fc38f66ae50e8b39d5a5796f9b357c","static/app.js":"b82969e67e7a7792a11f239eff2fec959158e1dd78d08a84d68daedb93aad6bb","static/audio-editor.css":"6c6cecd83f78d0a37af9011f5a3716dec6d41686e7de85af7e8b3d9e171bd628","static/audio-editor.js":"d397bb98480077326b4c96035d891f1d8eb77cef8a04f3b2aef1eb34d5bbcf40","static/auth.css":"4ad4c7e8e4aaa04b4837698493b59365859b89f2cedd8717492ba6b57615f620","static/auth.js":"6a38fa7dabf57b2c99d293c6851a732af76a19193bea43f7d2a8b8460d71faec","static/bulk_import.js":"2d19443342dad5d083ad66e32f5b4877a825296f1a41d8996befd336cec7d367","static/caption-proposals.js":"09906d8ea799ee7def657a5d1c7746a5dcbe25797f7849c7ac27364089a083d7","static/caption_import.js":"280a116a086c3e8c69fcd17e24aba6630bb6c3d3c1e653a5ed84c184895ae850","static/curation.js":"2f51c2e063eddbe16cb92dfcaa96c35c749dd61960ebd92c8c538bf0c6ff389a","static/experiment-lineage.css":"232fec3043b92ae7aa91db499a14e8cdb9ebe5598c8d7f01d805e3e3d8d4859d","static/experiment-lineage.js":"085ee3cc990f1986db357e1e8f7bace0c16a2257bca9443ff412c464d91a53db","static/index.html":"34ef87f168f4f1cd291be6c86464e02969b3c760316f0a59f01f50890ec973b3","static/instruction-responses.js":"3fb4fb8d78154bedb37ef90afbcc269fdab0938c4ac50236f38a7f26f54ac940","static/meshes.css":"0658afff9e77542442b1c5bf1c10ce50603fab4beec1f812ac40a6574f81076e","static/meshes.js":"c2eb7622dd4e0a172db9db36e46a0ce66800d5b03aca989cd1f08dd40b8558c8","static/native_detection_import.js":"b07d3a9663d5b1534fca24dc7481caf4cb166bb4a83c4c97c2a78fb9c8d50d82","static/native_text_import.js":"1704549e9e5cf489e6351d5126858eb774ddd94d060e596a0bfba5fc00ea8773","static/pipeline-editor.js":"2272f1a9b106945203112a46d73643cc5d4dbbfd92f0d7c09d1cc36a713042d2","static/pointclouds.js":"34851447f34ea0204887b6aae4a86d73f6f94c8d1726b8e82b892027336c0c84","static/preferences.js":"fb22c04b6856d33b7e298aa1989d073c937ab40020025f39b86daaff62b948f7","static/project-files.css":"56c239c9390dca16a7ad24b64b808c24f0a83544345d3970bb60f46ce88d5df7","static/project-files.html":"df20d5db4ef332d04ee3f4493d2a57511f84e58ca6a04206d3fdfc284339da9f","static/project-files.js":"0ad059ec5b2d82dbecfac2680d6772322b08bb4084f459ad7d07859f8805d61a","static/pumas-gateways.js":"02df8f34362fc2e32f6a4582a9cb71adaa5d7b83ccd897803b9e1d02c7bcb932","static/pumas-typed.js":"50fd9fc961dafdd0c34cfcb1137d04f85a99f324f76fd5217f227b789c8835ad","static/retained-analysis.js":"d9dab53d15e6e50ecf8f1b9e182c43eb330c4731e574758a0e3026f44acb44df","static/retained-checkpoint-curve.vl.json":"6603c860ce76a434a11e6c96cf7418246ce2dd5da3d7a56a5f2448a0537ec183","static/rights-note.js":"fd09cef5c52b2635ad42998483c7054260140459444fbd948bb596e5966feb11","static/runs-view.js":"7568b843ad9b56c1c6878a9756553cdf8092dea3b954aec1947b8f32d730ca85","static/saved-searches.js":"49210b9da9dd7f7372e977c15543a77c5ada1df82ad9abdcb350026e16517892","static/saved-selections.js":"2c2feecca798217644247f98aab8e7a00d707a25d4af75e02f9d906ccbd905a3","static/scientific-fields.js":"96e6289ade2a634e9648cbe8aa899e5bfaddacddcea120835bda8cb6d65e14a6","static/sequence-batch.js":"081bbeae46b381a3ed8ebdfed5c03334004795b96e37cb18feb867b09d22cd53","static/sequences.js":"aff0628408e5e69f1364228f3cb9a4fd821f5f2122036d3497532f3bfe1c0c93","static/style.css":"25103299f9add27eafbee40c9a12634d7640a6c54a785c981066b6403de0603d","static/text-classification-proposals.js":"03dbec1114700f383f8bc9883c8a4cdaae59d898fc44240022b2011893965d6f","static/vendor/retained-analysis/licenses/d3-array.txt":"552e22a104bb752daf35286bfc180b7bb6c206cc6502853979bd19390102d644","static/vendor/retained-analysis/licenses/d3-color.txt":"decfad6f87be1e200338d792b77793997db27b3f4e610f870370393ed53daf4b","static/vendor/retained-analysis/licenses/d3-delaunay.txt":"718da82757e9041ac674c157d9fc70435543b32f4c1baf4a9f08b71c5fa6495e","static/vendor/retained-analysis/licenses/d3-dispatch.txt":"2127431ebe97ddcbabb32b1049bade99e4c4b2412d3f8b39ec161814f5e2e11e","static/vendor/retained-analysis/licenses/d3-dsv.txt":"2823e0d11f6e134bfe32ec3b60f042db2d7be3e4eb3e7f09b7c93a3bef502ec3","static/vendor/retained-analysis/licenses/d3-force.txt":"2127431ebe97ddcbabb32b1049bade99e4c4b2412d3f8b39ec161814f5e2e11e","static/vendor/retained-analysis/licenses/d3-format.txt":"dfd3916622f5f66f001a5c9ec0beb6d4b0208b9ba41a81206bf6fa0940b6e980","static/vendor/retained-analysis/licenses/d3-geo-projection.txt":"8d78a83093868a5a6d86835807514b0f5bda66403a0d75cbf804ad72a3619463","static/vendor/retained-analysis/licenses/d3-geo.txt":"e3243e2788023f118b725d27189d529d13da89dca0801380a9ecf96da3a5fa67","static/vendor/retained-analysis/licenses/d3-hierarchy.txt":"2127431ebe97ddcbabb32b1049bade99e4c4b2412d3f8b39ec161814f5e2e11e","static/vendor/retained-analysis/licenses/d3-interpolate.txt":"2127431ebe97ddcbabb32b1049bade99e4c4b2412d3f8b39ec161814f5e2e11e","static/vendor/retained-analysis/licenses/d3-path.txt":"16d2d2a1800677ddece729f5acb30205b5acbc21529df90cd8bf3d202c08d0b7","static/vendor/retained-analysis/licenses/d3-quadtree.txt":"2127431ebe97ddcbabb32b1049bade99e4c4b2412d3f8b39ec161814f5e2e11e","static/vendor/retained-analysis/licenses/d3-scale-chromatic.txt":"b982e295bd298c8d3e328a7cc623396e6e708fb6fba5b35e2dc4daf36728c8ed","static/vendor/retained-analysis/licenses/d3-scale.txt":"2127431ebe97ddcbabb32b1049bade99e4c4b2412d3f8b39ec161814f5e2e11e","static/vendor/retained-analysis/licenses/d3-shape.txt":"decfad6f87be1e200338d792b77793997db27b3f4e610f870370393ed53daf4b","static/vendor/retained-analysis/licenses/d3-time-format.txt":"2127431ebe97ddcbabb32b1049bade99e4c4b2412d3f8b39ec161814f5e2e11e","static/vendor/retained-analysis/licenses/d3-time.txt":"decfad6f87be1e200338d792b77793997db27b3f4e610f870370393ed53daf4b","static/vendor/retained-analysis/licenses/d3-timer.txt":"2127431ebe97ddcbabb32b1049bade99e4c4b2412d3f8b39ec161814f5e2e11e","static/vendor/retained-analysis/licenses/delaunator.txt":"9994d103e3af6ded25bad0ae99f08f6758f08cb9b261ab921ae7a6279769cdf0","static/vendor/retained-analysis/licenses/fast-json-patch.txt":"488a71c4303f0860094c1e59fa32e3302e9e79e2b63b8731761c22c2101c1dc9","static/vendor/retained-analysis/licenses/internmap.txt":"c596ec7154f49d5b5354b5483891de003b9af0f127aa007a6c5cea659e53d7c4","static/vendor/retained-analysis/licenses/json-stringify-pretty-compact.txt":"0454b235e715629194c3a1efb8b5a1b5c4a26bea9e8d54f4061b603d34cbea18","static/vendor/retained-analysis/licenses/robust-predicates.txt":"4731daf16265d7f0acd5c305a2dc745cf4c7562e027d44c206ab38637d8558ab","static/vendor/retained-analysis/licenses/semver.txt":"3ef9e4a3ab62e8eebd271519b275803e13e59a790cbdca7f617cbff34c56ed80","static/vendor/retained-analysis/licenses/topojson-client.txt":"140e4489d9507aedeb615404008fe1d51b418b7cdb428ea840481105c14eda97","static/vendor/retained-analysis/licenses/vega-canvas.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-crossfilter.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-embed.txt":"9ba0f1b61d8bb4c228264ed5305c543914fa0fd3f3e302623138a3f93d649304","static/vendor/retained-analysis/licenses/vega-encode.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-event-selector.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-force.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-geo.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-hierarchy.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-interpreter.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-label.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-lite.txt":"1d88c9e50bf518513f0db4b4faff48d26fce5591025277a79f687f1961fe5ed8","static/vendor/retained-analysis/licenses/vega-parser.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-projection.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-regression.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-runtime.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-schema-url-parser.txt":"886fb7d90ade30e3ddb1509c644b649738f4461427d128d278b41e0894175881","static/vendor/retained-analysis/licenses/vega-statistics.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-themes.txt":"0344cdac38613ef402506fb143f7e37fb4cd91aead07a51ebfe916a32909f3d6","static/vendor/retained-analysis/licenses/vega-tooltip.txt":"6dbbe75e03459c41a0d380bbf85c2221d817733e603960d674c0de546f7daee1","static/vendor/retained-analysis/licenses/vega-transforms.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-view-transforms.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-view.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-voronoi.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega-wordcloud.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/licenses/vega.txt":"b686cdefc306925f182fce89952147997ef214e8c2a8c9b7bb3f5084edffcc76","static/vendor/retained-analysis/manifest.json":"298dbcaf1a3852db247cd781ef60c620d67c6ddfdcd646cae38a321368a280a5","static/vendor/retained-analysis/vega-embed.min.js":"12d02acfbe3ec59ef9a37dd4822a2e04e2961b5bbb671bbe661d2221715b99da","static/vendor/retained-analysis/vega-interpreter.min.js":"13002339c7e6231ef919584cbdf7391b246ea5073f686ffd707c62628b7f87df","static/vendor/retained-analysis/vega-lite.min.js":"58c27358e26f2d319cf62f45bc17a4c8362f08645001df2ec8d341eee4097c7f","static/vendor/retained-analysis/vega.min.js":"a71f14828abacac82fbe8adb07ce65cbb82561d8eef188053c4a1c09e0164dcb","static/volume-editor.js":"d48806cd446ea05586b524c24340d87abe8c69063ea6b15c254f76b3e51c5db8","static/workbench-layout.css":"7c5ab2b939ade04c44b4d028d29bc08da70e05881c744cca78bbe72214da0268","static/workbench-layout.js":"da0bc85d8c5e14db95eb161d2142c4c75a9d79f49a9e4da9ce3066b11b568d03","static/workbench.css":"669aab1c0c8d680c60848ae96ec1925151ea663238d4412d45862ef60112391e","static/workbench.html":"2e7234abdceb8d4e4c1a2d74196fc2d8d51900982cb28dd05dd7d701ade00e1c","static/workbench.js":"167a48d39b0831ba814c31321f0f936d969e9b322c71b11a25a3f24ca9fe889b","static/workspace-external.js":"117522dba6004de9e321795435334bcc75a2635c3d21d58c36e7da2be4ebd3de","static/workspace-fixtures.json":"3874c3f996c9676549b7a7117ba5219a7a58eee275cbb302fcaede85478ee119","static/workspace-phase-timings.js":"2230164b2b7200ef941e4f0c65a02b66ed16d477e483029d02306c403c58071e","static/workspace-project-activity.js":"720da82509c23b5de0c04e880e13d23bafbe3476c06446f6467760b417a110a6","static/workspace-projects.js":"3f465ad13a9f6bea9c7e812507510b4b5469e7569d7a1c22884115b6628a4747","static/workspace-real.js":"de4036bab21c3ca2702488658a9fa8733cf0adb822f4ec3322d5e101a96b60a2","static/workspace-scientific-comparison.js":"e2a1068011135bf845c1d962cf1e11147419c8c9ab8da36a80267be427762ade","static/workspace-service.js":"568b8258c35e6b3ca2cfe08b64d914111e16ae98d45f5d40f7aa40a20c20098b","static/workspaces-data.css":"9f6151c17baf04b589ab997a3063435b1d035a397a92a48b0badf2911f08091a","static/workspaces-data.js":"da2807d18da4ef4c366bb178d30b336d0b9896fd349b76dd97183e7237022dd7","static/workspaces.css":"3d0eacbd50425b4cd2ce9cfacdf5bcdb8fe533d7a57248f16325242f625cd278","static/workspaces.html":"c16bf3ad0872b8f40f9d00ad6f2ae6366d2c3193aafa80bb496bbf3a41c183cd","static/workspaces.js":"099c17dc3f40cfb55d5ee01e9aaac118c63f7a04fa93539d76d866f33a70daa4","synthetic.py":"7d406f1cc67431b4ae3e0dd2cbe6eb8fc5b18388e2359854b6576fd764de0bfd","text_classification_export.py":"2510ad278a95892d4453ffb5b146ae47c11f8e31f3dc4cf9fb005eec79270bdf","text_classification_proposals.py":"3abee9ad97ce4baf1d51fce10b254f2cbb2e54d222d688d48c58a116edfb8386","text_corpus_export.py":"3af4883df1b714519fb4e9be872f39de20dc3013deca720e32a89789cbdbff69","text_training.py":"1eeaa53ba21885e6e0afc933f7161dbf20a2681dbfebedaee4e1eb7ec1c59914","torch_runtime.py":"84a66b6a195c30db01ffc574f94e0139f4df7d7c7872452a3f50ce82e9db3ad0","torch_runtimes.py":"010ed4ac47e013dbda07f52e3dbbddf95ab3a5367e4e67dfb6f4d7ae7b10bb39","tuldok_client.py":"9a99e446965be8df6be6631add5987f97ee28c09c93a389e7e4f09bfb13aceee","tuldok_extension_sdk/README.md":"ef96d551014c30d0a4ce364840ecf987be087e4b396e6dc6c15eabde814c616a","tuldok_extension_sdk/__init__.py":"b7e0b3167de54dd1ce8bf5209af92ee0bcab9ba6eb6fc9f8c7db4e3fea96fc18","tuldok_extension_sdk/__main__.py":"33da45a3a096e4177e83d3f1c718dd27851f795c85665c33bea0329159baac1e","tuldok_extension_sdk/contracts/contribution-v1.json":"2e1e038c3c1ba09c6b7525a1f9ed2f8cb083fde31cf067f12cf57b76f3f7dfe3","tuldok_extension_sdk/contracts/extension-v1.json":"e78280176fd239b387ffc8b850a7bbff61f069cb47d4c7f9077e7d690c116d25","tuldok_extension_sdk/contracts/parameter-v1.json":"12ea65f4eb51fc71ba4c98217813b0953aa73cda0fe13e8a6c4b0d4d8acc7305","tuldok_extension_sdk/diagnostics.py":"d2439f8ab20dc7a3b829b547745fd7fa84d320ac4df2930b600cd36014689699","tuldok_extension_sdk/evidence.py":"53027c44b78fde2db62e736d9b8ed66915b17bfca3fefe36eee30cae59af0a70","tuldok_extension_sdk/manifest.py":"ed5d54298bbdac8bc272ff27f75d6163f05dbd74c390d2955acbc74e673f2dae","tuldok_extension_sdk/publication.py":"2483b2ae5cb6deef05d48a0f4b9d10b0b29166da139a909ea3c91b57911d9701","upgrade_metadata.py":"f78f6113b3687d808536f873153c83893ed91bf1e247986b29ac00d4c226af8f","volume_assets.py":"def73d68685a1a231ec1095e5c5161dafde86d0379425fac0782a9e177a17b1d","workbench.py":"c71aac71dbeed841b7f24d8d8bfc6da020a9587b4c3d304bfc4b68c608fdf89d"}} diff --git a/docs/plans/external-project-save-load/reports/m3-implementation.md b/docs/plans/external-project-save-load/reports/m3-implementation.md new file mode 100644 index 00000000..3b2dfd06 --- /dev/null +++ b/docs/plans/external-project-save-load/reports/m3-implementation.md @@ -0,0 +1,243 @@ +# M3 integration evidence (in progress) + +This is scoped supporting evidence. Current milestone status and the next action +remain authoritative in [the plan](../plan.md). M3 is not accepted and the draft +implementation PR is not ready to merge. + +The integration baseline is M2 commit +`c22e9d02c87938514a7a1c9b3b0532158008ca0d`; main remains +`133d53c0e2985a6559ba9cebd7d4a9f530051f06` and the standards reference remains +`5bce3110fc64d61ff8b4f79468184bd6295855d5`. Work is isolated on +`feat/external-project-save-load-20261022`. No local lab was inspected or migrated. + +The shared boundary now captures named owner changes before the actual outer +PostgreSQL commit, with canonical facts selected before acknowledgement or a +worker wake. Foreground capture composes fixed additional Run/Study/producer +facts without a second publication. Background capture requires a current local +authority callback and an owned transaction. SQL rollback after canonical +selection, lost SQL acknowledgement, and unknown checkpoint fsync all fence the +projection. Imported operation facts do not supply a current grant or admission. +The lock facade acquires host authorization, project session and actual +projection CatalogLock in that order, retaining PostgreSQL advisory ownership. + +Performed shared-boundary checks, using disposable PostgreSQL 17: + +- `test_project_session.py`: ten tests passed in 4.105 seconds. The additional + cases exercise nested hook ordering, capture refusal without wake, canonical + observation retention on an actual deferred SQL constraint failure, complete + foreground capture and background lock entry. Existing publication, stale + authorization and replay tests also passed. +- Two targeted tests passed in 1.518 seconds after adding actual checkpoint + replacement/fsync uncertainty and simplifying the wake test to use the public + hook installation API. The former verifies SQL rollback, fenced availability + and retained canonical facts after reopening; the latter starts a second real + transaction in an acknowledged commit callback. These are the changed cases, + not an identical repeat of the earlier campaign. +- Existing `test_metadata_transactions.py`: fifteen tests passed in 6.515 seconds, + covering nested rollback, independent connections, file cleanup, lost commit + acknowledgement, exact retained text and PostgreSQL configuration failures. +- Existing `test_project_workspace.py`: eleven tests passed in 9.254 seconds after + the initial current-custody guards and inert execution attachment. Later + producer/Study/file composition and shutdown attachment were not present in + that subject; final integrated lifecycle checks remain pending. + +The shared production files used by these checks have SHA256: + +```text +metadata.py 7bb728cab2068a415eb071191b61241dc2886a765044d9588487f4e8b8d11d86 +project_session.py 9f694d1c38dbf7c5780c94a516f0fe4b014740bd4962df40c136520c0bcfe95a +project_locks.py d2c3493339f934ab63d582ac091ef31fbf11710ffb66ba24dffffaae48c26297 +``` + +These fingerprints identify the shared boundary, not a frozen complete M3 +candidate. Other disjoint owners were still being integrated. Test output is +retained in the execution transcript; this report does not invent raw log files. + +Early independent source review found and resolved lazy Run construction before +request rejection, commit callbacks observing a completed transaction as open, +and background canonical uncertainty failing to fence. The third repair also +requires execution to fence existing dispatch whenever Session is unavailable, +regardless of the SQL outcome label. Full independent review and the required +stable-source real process, public HTTP/browser and custody fault gates remain +pending. Producer and Study worker evidence is collected separately before +final integration; source compilation alone is not runtime qualification. + +M3 public capability advertisement remains disabled until its production gates +are ready. M4 pure constructors and working-file helpers are independent work in +progress, not accepted Save As. Native execution, candidate external publication, +runtime capture and mixed-catalog extraction must retain explicit unavailable +guards until their admitted paths are concrete and qualified. + +## First frozen execution subject + +The first complete execution candidate had 232 product files, with retained +[source manifest](m3-first-execution-source-manifest.json) file SHA256 +`d31893bd6c6562869c0a1fd63942eba17a8931ea5c07c9012eecf77ca023624b`. +All listed hashes matched before and after these required-real checks: + +- Four actual custodian cases passed in 49.647 seconds, with no skip: canonical + admission/GO and terminal payload ordering; cold history without Run owners or + imported authority; SQL acknowledgement loss after actual GO-intent commit + without sending GO; and filesystem checkpoint replacement uncertainty with + exact same-host handle drainage/reconciliation. Raw log and hash receipt: + `/workspace/scratch/tuldok-save-load-qa/m3-run-custody-first.log` and + `m3-run-custody-first-receipt.json` in that directory. +- The one selected actual Study case failed in 7.519 seconds before SQL + acknowledgement or GO. Reservation validation called a host-authorized + material reader under a logical project reconciliation context. Its raw log, + `/workspace/scratch/tuldok-save-load-qa/m3-study-one-frozen.log`, has SHA256 + `f55cb6b2135653e951eb58b74d53ef9c3a4c645861189300e7ca04d9559a0764`. + The owner now uses the same pure retained-material codec on that path, keeping + legacy behavior intact; the targeted regression passed. The actual Study + rerun remains pending a new stable execution subject. + +Independent review of that exact subject found four additional justified issues: +approved agents were rejected by ordinary execute guards; producer-only recovery +could release its writer before custody checks; normal reconciliation skipped +checkpoint CAS; and producer cancellation signals preceded canonical commitment. +The source fixes are integrated or owned, with current transport policy checked +before replay and cancellation split into preparation and post-acknowledgement +signalling. Actual PostgreSQL/HTTP cancellation faults passed without premature +signals. Two actual workspace cases passed in 3.457 seconds, proving an active +and then unknown producer retains its original store, root descriptor and real +flock during recovery, and a stale reconciliation request cannot drain it. +Relevant scratch logs are `m3-producer-cancel-journal-final.log` and +`m3-workspace-custody-first.log`. Approved-agent positive HTTP acceptance and +targeted independent re-review remain pending. + +Subsequent implementation adds an ignored original-host custody index and +restart reconciliation, fixed runtime/package writer guards, independent fork +receipt namespaces (`project_operation_v2`, with exact v1 reading), and explicit +legacy extraction. These are later source changes: the first frozen execution +results are not presented as their validation. Current-source tests and review +must qualify their own effects before milestone acceptance. No duplicate full +campaign was started and no incomplete feature was merged. + +## Later isolated qualification and recovery checkpoint + +Three original-host crash cases passed in 66.118 seconds with no skips. The +2,422-file immutable qualification manifest has SHA256 +`5f82530d0b86610e4e3aa645f4359d45f8c5d0b844341d356d73264fd31e4694`. +All hashes matched before/after. Actual subprocess termination followed admission +ACK, GO-intent ACK before GO, and actual recipe start. Fresh Open constructed no +Run owner or process; reconciliation used exact original physical SQL/journal +and observed drain receipts without GO replay. Logs and receipt are +`/workspace/scratch/tuldok-save-load-qa/m3-originalhost-crash-three-first.log` +and its adjacent `-receipt.json`. + +The subsequent Study case exposed distinct real defects: background capture +called a decorated policy reader outside its internal settlement scope, and +explicit cold approval lacked reconstruction of exact canonical material bytes +into the new private cache. Both were corrected without restoring live authority +or changing Open. A fixture then needed explicit current-supervisor drainage +before Close. The original 10-second helper preparation deadline also covered +durable pre-GO acknowledgements. A separate bounded 60-second READY-to-GO phase +now permits that required publication, rejects late GO, and is reserved in new +material resource envelopes; historic envelopes are not rewritten. Seven real +custodian cases passed in 13.030 seconds, including delay beyond the old limit, +expiry without execution, EOF/cancel, streams and source drift. Evidence is +`m3-go-wait-protocol.log` and its receipt in the scratch directory above. + +The complete actual Study case subsequently passed in 110.146 seconds, selected +one/no skips. All 2,441 files matched immutable manifest +`b1dbf055f7abc5888bc30079316032d687910343d557237664b3e547d6907449` +before/after. Both generic Runs completed; cold reconstruction preserved spent +budget and lineage while old Run/grant/admission/reservation SQL remained empty. +Fresh Continue/current approval and exact private-cache reconstruction were +required, and missing canonical bytes could not issue a grant or change the +checkpoint. Log `m3-study-current-go60.log` has SHA256 +`ec0a79d6d1db6687525eedd9e11f9bb3320c7389d4564627d9d2cd1c0964ef63`. +Earlier failed logs remain retained rather than overwritten. + +Candidate verifier preparation passed actual PostgreSQL/filesystem/canonical +publication checks without launching captured binaries or constructing Runs. +The extended positive/cold case passed in 64.254 seconds. Its selected-candidate +return was explicitly authored controlled fixture data; package, source, input, +disclosure, material and canonical receipt validators remained real. It makes +no scientific/model-quality claim. A separate actual original-journal recovery +case passed in 153.990 seconds: partial source/graph/model commits and directory +bytes survived a native failure; explicit abandonment cleared only the qualified +local marker after canonical ACK; final-journal ACK loss reconciled the existing +receipt without rerunning the writer; cold imported facts supplied no marker or +grants. Log `m3-candidate-native-recovery.log` has SHA256 +`92e3f9d27f30c63b0ceff6390ebe9aeb0623c45f7f561185ff308c8f82d66a9c`. +That case preceded the later read-only journal-refresh strengthening; five +targeted PostgreSQL/filesystem guard/refresh cases passed in 2.206 seconds for +that later boundary. + +The approved-agent HTTP workflow observed both real Run and Study completion, +canonical/SQL ACK before GO with exact current actor, wrong-agent 403, revoked +credential 401, explicit drainage, and cold Open without local authority. Its +full local case is **not passed**: fixture corrections for missing origin fields +and receipt-less reconciliation were retained as failed runs; the latest case +failed because it expected only 403/409 for an old cold approval while the +correct observed response was 404 `unavailable`. The final cold Study denial +tail remains unrun. Manifest +`08041e0eb447394fd9eab4620539b455cce7e9310dab8d8c616783046b678a39` +matched all 2,441 files; log `m3-approved-agent-http-reconcile-fixed.log` records +that distinction. No pass is inferred from partial positive phases. + +A temporary executor disconnect interrupted process creation and a pending +Runtime-abandonment edit. Actual subsequent shell/status checks confirmed the +workspace survived. The pending patch failed to read its target and did not +apply. The already-running candidate recovery process was inspected first and +found successful; it was not rerun. Public runtime recovery/abandonment, public +package recovery wiring, remaining transport/UI gates, full independent review +and exact-head CI remain pending at this unaccepted recovery checkpoint. + +## Qualified recovery and retained observations + +Four actual PostgreSQL/HTTP runtime recovery cases passed in 13.729 seconds, +with all owned source hashes unchanged. They cover a real reserve failure after +durable intent and abandonment after its source disappears; final SQL deferred +COMMIT failure followed by exact-key recovery without recopying; original-host +physical projection recovery without configured aliases/source; stale binding +and checkpoint refusal; and foreign-clone intent remaining inert. Neither Runs +nor child-process construction was permitted in this offline capture fixture. +Log: `/workspace/scratch/tuldok-save-load-qa/m3-runtime-recovery-http-first.log`. +Four affected runtime identity/replay compatibility cases also passed in 2.410 +seconds; that selection did not repeat the HTTP campaign. + +The named Chromium runtime workflow passed four groups on immutable derived +manifest `d008a0a729812f1d2af8436d317a0dbe6d799d76598937812b9c75d746e399ba`. +All 2,449 copied source hashes matched before/after. The sole product delta in +that isolated copy enabled M3 for qualification; the shared feature flag stayed +false. It exercised paged Inspect, an actual high-reserve 409 after durable +intent, explicit abandonment with reason/key, a fresh capture, and Git cold Open +with source/config/cache absent. App audit-hook installation was asserted and +no child-execution attempt occurred. The cold role lacked source CONNECT. +At 390/1440 viewports document widths were 375/1425, with no overflow or runtime +errors and exactly one expected 409. Receipt: +`/workspace/scratch/tuldok-save-load-qa/artifacts/project-files-m3/run-aQAOCI/receipt.json`, +SHA256 `9c83fcacc68a10d5168933755f90aa46b8e3bddf15fe002a95850fb2a124ebc1`. + +Retained producer input authorization passed its actual PostgreSQL/native-writer +case in 191.000 seconds. The selected candidate remains authored fixture data; +there was no scientific training/process claim. Original producer receipts, +current disclosure and scientific closure were validated, with source writes +and exact original-journal recovery. Log `m3-retained-producer-consumer-corrected.log` +has SHA256 `f9d03b1c89b05fb25dc9e0f8ed483c3c36d99918b02f943f73169e2b8e3b5390`. +The prior attempt reached those phases but failed a fixture message assertion; +that failed 231.061-second log remains retained separately. + +The candidate terminal-before-wrapper window has its own focused actual +PostgreSQL gate: one case passed in 107.490 seconds. Complete native preparation +could not be abandoned, and exact proof reconstructed only its missing wrapper +acknowledgement without rerunning the writer. Log +`m3-native-wrapper-ack-recovery.log` has SHA256 +`6381d125182d6d833b6ff0da5d7fd4b7876b600e808a6a61e7da3bde559ed4eb`. + +Two thirteen-case focused PostgreSQL/codec selections passed for immutable +disclosure snapshots and affected history/Study behavior, followed by one +targeted forged-foreign-artifact snapshot negative. Snapshot facts remain plain +canonical text, survive later same-name payload replacement, retain old bytes as +unique and restore no disclosure grant. The additional negative passed in 0.320 +seconds; log `m3-disclosure-foreign-artifact.log` has SHA256 +`0185f43bbf13daaa69b7f058eddc24fa76d3c93005b0aca435414301f69d3f89`. +Independent source re-review cleared these snapshot and acknowledgement repairs. + +Full M3 acceptance is still blocked by the explicitly fenced built-in probe and +native Run execution paths. Independent review confirmed that the external-script +subset cannot replace the reviewed current-family scope. A finite native child, +admission, payload and recovery slice is being implemented under the existing +authorization; no flag or documentation change waives its required real gates. diff --git a/docs/plans/external-project-save-load/reports/m3-m4-custody-review.md b/docs/plans/external-project-save-load/reports/m3-m4-custody-review.md new file mode 100644 index 00000000..589f6950 --- /dev/null +++ b/docs/plans/external-project-save-load/reports/m3-m4-custody-review.md @@ -0,0 +1,110 @@ +# Independent M3/M4 custody review + +This is a scoped source-review record, not milestone acceptance. The review found +the issues below and verified their described source corrections. No remaining +finding is open within those targeted corrections. M3 Study execution still +needs its causal repair and actual qualification; candidate publication is being +implemented, M4 public/lifecycle acceptance is unfinished, and M5 is unfinished. +Approved-agent transport and original-host recovery through the actual public +clients also remain qualification gates. + +## References and review boundary + +- Main: `133d53c0e2985a6559ba9cebd7d4a9f530051f06`. +- Integration baseline: `c22e9d02c87938514a7a1c9b3b0532158008ca0d`. +- Coding Standards: `5bce3110fc64d61ff8b4f79468184bd6295855d5`, read from + `/workspace/Coding-Standards-readonly`. Applicable material included + `CORE-STANDARDS.md`, `STANDARDS-ROUTER.md`, `PLAN-STANDARDS.md`, + `ARCHITECTURE-PATTERNS.md`, `SECURITY-STANDARDS.md`, + `profiles/boundaries/persistence.md`, `workflows/planning.md`, and + `workflows/verification.md`. +- Requirements: [the reviewed plan](../plan.md), especially canonical-before-GO, + current local authority, original custody retention, independent fork identity, + exact historical closure, and explicit unavailable boundaries. + +The first complete M3 source review began with 232 matching product files in +`/workspace/scratch/tuldok-save-load-qa/m3-run-study-freeze.json`, file SHA256 +`d31893bd6c6562869c0a1fd63942eba17a8931ea5c07c9012eecf77ca023624b`. +Later custody/M4 review was explicitly against an evolving tree with individual +file hashes. The separate 2,422-file custody qualification manifest, +`/workspace/scratch/tuldok-save-load-qa/m3-custody-qualification-source.json`, has +file SHA256 +`5f82530d0b86610e4e3aa645f4359d45f8c5d0b844341d356d73264fd31e4694`. +Its runtime results apply to that captured subject, not automatically to later +changes. + +The reviewer read source, relevant focused tests and recorded evidence; performed +file hashing and syntax-tree inspection; and did not execute application code, +tests, processes, catalogs, or migrations. No local lab was inspected. The only +reviewer-authored file is this report. No publication or commit was performed. + +## Findings and source resolutions + +| Finding | Consequence | Source resolution verified | +| --- | --- | --- | +| Lazy Run construction preceded request validation. | A stale/conflicting request could reach current-local recovery before rejection. | Construction moved into `Session.mutate`'s `invoke`, after exact receipt/body/CAS checks. | +| Commit callbacks still observed an open transaction frame. | Deferred Run wakes could append themselves indefinitely to the callback list being drained. | Metadata pops the completed frame before outcome hooks, cleanup and wakes; nested success still propagates callbacks to its remaining parent. Representative file cleanup and in-memory restoration callbacks were inspected. | +| Background canonical uncertainty did not immediately fence execution. | A replaced checkpoint followed by failed fsync could leave Session marked available after SQL rollback. | Background publication catches canonical uncertainty; Session retains recovery identity and becomes unavailable. Execution fences when Session is unavailable regardless of SQL outcome label. | +| Ordinary `execute` guards rejected approved external agents. | Public Run submission and Study admission could not reach their dedicated approval policy. | Those routes use `require_external_submission`; Study's canonical wrapper uses inspect only after that policy check. General Access execution restrictions remain intact. | +| Producer-only recovery checked custody too late. | Recovery could close the original store and release its flock before discovering an active/unknown producer. | All-family `has_local_custody` and quiescence checks precede store replacement. Original-host pending custody is also fenced. | +| Normal reconciliation ignored checkpoint CAS. | A stale same-binding request could drain work admitted after the caller's selected checkpoint. | Normal reconciliation validates the current checkpoint before effects; recovery-required reconciliation retains its separate last-acknowledged checkpoint rule. | +| Producer cancellation signals preceded canonical commitment. | Failed publication could still stop a worker or send provider cancellation without a durable cancellation fact. | Project cancellation prepares intent inside the transaction, signals only after acknowledged canonical and SQL commitment, and drains outside command locks. Legacy standalone behavior remains separately owned. | +| Save As supplied a zero-argument allocator to a three-argument callback. | Forking any editable record raised `TypeError`; an empty source could miss the failure. | Workspace now supplies the declared family/source/revision allocator signature. | +| Custody lookup filtered by current project UUID before physical root. | A valid UUID-changing checkout could hide unresolved original-host work on the same inode. | Lookup scans host-owned journals and matches physical root first; reconciliation refuses a retained/current project identity mismatch. | +| Original-cache promotion rotated the binding inside an ordinary execution response. | SDK and browser correlation rejected successful recovery as a transport failure. | Promotion preserves the same logical workspace binding. Workspace serialization, checkpoint CAS, exact replay and current authority fence delayed requests. Ordinary Open/switch/Save As still invalidate the selection generation. No concrete stale-request hazard was found in this scoped correction. | +| A fork could reactivate an unnamespaced v1 receipt by editing its archive list. | An original source key could replay against the destination using retained archived result records. | Active v1 receipts are refused whenever a fork overlay exists. Unforked legacy reading and archived historical receipt validation remain available; new writes use the v2 project namespace. | +| Disclosure history used the active replay-key index. | Save As retained source receipts but hid original Run disclosure observations and reasons from public historical results. | `ProjectHistory.disclosure_facts` now reads validated `operation_documents`, filters exact origin/Run, and leaves present disclosure permission to the separate current gate. | + +The final disclosure correction was read at SHA256 +`4cec697b7de96f4632c735de26b97c30c3bc306aa4700ad1d8ef328493519b50`. +Its focused test explicitly checks that the archived fact remains visible, the +active replay index stays empty, another origin does not receive the fact, and +the fact does not satisfy current disclosure permission. + +## Sampled source identities + +These identify the sampled files containing the reviewed paths. Where another +owner edited a module during review, targeted correction verification does not +claim full review of that owner's later changes. + +```text +project_custody.py +17e3b76cc8ea5aff7863e75520a2cba9cef786bca78d5227d7b5da56c2ffe0b0 +project_workspace.py +d0e85a90121a2242a0dea85d7061cb705001e136a45fc5cafcc3ec0d3f87da66 +project_session.py +dee671d824433d67af68f24b17011196c86931f79de0eefb36e4d8afd8757503 +project_execution.py +924faf003bcad64f7d1c154823af408b5a707a9d195d133b149c033bd51f5824 +project_producer_execution.py +41a0e4dca2bb2059a82d91563eae2b9a28a11a87869a187c67a6c555de0ec4e2 +project_history.py +4cec697b7de96f4632c735de26b97c30c3bc306aa4700ad1d8ef328493519b50 +project_fork.py +b886ce2380285a10f879ae46c8883b5c0872481557c8c600eb5e2296185f0745 +project_fork_composition.py +69d052062e7c7e824f0ffb4133ff05c8c4b5236da46976c627883a91a43c08f8 +project_material_fork.py +349fc4619fa49d9df3e52a3d042d2f5523d0b8acdacfd675bc11b8bca53bf481 +project_data_fork.py +3f49c244eba627e35d6f0fc9e7384c665113de6e2afa2219593440b7350ac911 +project_files.py +b092994dd2f23995c86e098cbde7180a4d0e88e660ddda999bc6d9552f6af0e1 +metadata.py +7bb728cab2068a415eb071191b61241dc2886a765044d9588487f4e8b8d11d86 +``` + +## Validation limits and remaining gates + +The lead reported nine targeted controls passing with the disclosure correction, +and two pure Session controls passing for the fork/v1 receipt refusal. These are +reported test results, not reviewer-run campaigns. Existing recorded Run, +custody-crash and workspace-provider results retain their own exact source scope; +none is upgraded here to current complete-product acceptance. + +Still required are the causal Study execution repair and its actual gate, +approved-agent public transport qualification, actual client correlation for +original-host recovery, completed candidate/runtime effect ownership where +advertised, and M4 public/lifecycle/independent-edit qualification. M5 extraction +is outside this acceptance claim. No full-suite rerun, CI result, merge readiness, +or scientific/model-quality claim is implied by this source review. diff --git a/docs/plans/external-project-save-load/reports/m3-native-completion.md b/docs/plans/external-project-save-load/reports/m3-native-completion.md new file mode 100644 index 00000000..2abf048d --- /dev/null +++ b/docs/plans/external-project-save-load/reports/m3-native-completion.md @@ -0,0 +1,434 @@ +# Native completion evidence + +The [plan](../plan.md) owns current status and acceptance. This report retains +scoped evidence for the previously fenced built-in/native path; it does not +accept M3 or the complete feature. + +## Scope and runtime policy + +Existing native Pipeline, Runs and Study owners keep their scientific contracts +and current execution policy. Native probe, training and checkpoint verifier +children use the existing custodian, with current authorization and canonical +and SQL acknowledgement before GO. Open does not activate Run workers, create +native preparation contexts or launch a native process. + +The explicit current `TULDOK_TRAINING_PYTHON` setting, otherwise the application +interpreter, remains the operator's runtime selection. The observed executable, +virtual-environment configuration and staged source hashes are retained together +with raw bounded probe observations. Installed packages/native libraries remain +operator-managed dependencies. Package versions do not establish complete binary +closure, reproducibility or model quality. Independent plan/source review found +that a mandatory captured-CPU workflow would exceed the necessary save/load and +custody change; existing captured runtime profiles remain separate capabilities. + +## Child foundation + +Nine actual helper tests passed in 4.691 seconds: train/verifier separation, +bounded probe with sanitized environment, failed owner acknowledgement without +GO, controller EOF with escaped descendant drainage, immutable template refusal, +bounded streams and both helper-death windows. The helper now arms parent-death +kill and checks its inherited exact parent before exec. A new effective job and +parent observation are measured separately; the original template is unchanged. +Missing full descendant receipt remains unknown. + +That subject used: + +```text +recipe_process.py 2d16f8ca0ed749f2dca8eed977437f80c829704e57428acc51fcf9075f78efd1 +project_native_process.py ce76eb17976304176092349d1e358262ec23bdd3b7d2a61e1219388a6b272082 +native_process_bootstrap.py aa98061a75699d77e60255118f7e8f920b510cfb048ce6fb9e5caf6116bd2c2e +``` + +Six additional tests passed in 4.199 seconds for a causal integration delta. +The actual configured symlinked venv retained its selected `sys.executable`, +`sys.prefix` and installed fixture module through sealed probe and training +launches. A private verified executable-FD seam preserves the configured argv0; +the default launcher remains strict. Wrong bytes fail before helper creation, +only duplicated descriptors are closed, caller offsets remain unchanged, and +alias or `pyvenv.cfg` drift prevents GO. A pure retained-spec decoder removes +only the current UID comparison; launching still requires the live check. + +That later subject used: + +```text +recipe_process.py d10b27b19f20e8c871d6c9adae164bda19861e1427fcd71ff6e2fef2c4ee589b +project_native_process.py 34d443a51a876a34d1ce5416461e6f0fb052bccb4814c309611a576906564464 +native_process_bootstrap.py aa98061a75699d77e60255118f7e8f920b510cfb048ce6fb9e5caf6116bd2c2e +``` + +The original nine-case campaign was not repeated. Independent read-only source +review cleared both subjects and specifically required current-runtime checking +immediately before GO. It did not execute tests or accept domain integration. +The consolidated receipt, with exact raw log hashes and source identities, is +`/workspace/scratch/tuldok-save-load-qa/artifacts/native-process-foundation-20261022.json`, +SHA256 `d0e13c01907d0e84ff5a8a9003ddb51fd71a231edfdad8497a63d664ca45f2ef`. + +The affected existing default-launcher `test_recipe_process.CustodyTests` then +passed 18/18 in 22.113 seconds with no skips. A minimal immutable three-file copy +used manifest SHA256 +`bb65e4986e235a0038b9aca814e7abd8296497bec7969b191c2c9bf924f0b62f` +and the final launcher SHA256 `d10b27b19f20e8c871d6c9adae164bda19861e1427fcd71ff6e2fef2c4ee589b`. +Recorded driver/helper dependency identities remained stable. This verifies the +affected default path rather than repeating the new native campaigns. The 16 +separate CPU snapshot contract cases were not run in this campaign. Receipt: +`/workspace/scratch/tuldok-save-load-qa/artifacts/recipe-process-default-regression-20261022.json`, +SHA256 `024fbdda0056272c0256255c6da343de3692c4f381cb7ddea46d5444dd9b80c4`. + +## Real CPU verification prerequisite + +A dedicated scratch venv was provisioned from Python 3.12.14 using the repository's +training and production requirements. One import check passed for eight modules; +no native probe, training, scientific test, GPU work or model-weight download was +performed by provisioning. This is a test-environment action, never product Open +behavior. The interpreter is +`/workspace/scratch/tuldok-save-load-qa/native-training-env/bin/python3`. +Observed versions include Torch `2.8.0+cpu`, torchvision `0.23.0+cpu`, sklearn +`1.7.2`, Pillow `12.3.0`, NumPy `2.5.4` and SciPy `1.18.1`. The receipt, exact +requirements snapshot, resolver report and installation/import logs are under +`/workspace/scratch/tuldok-save-load-qa/artifacts/native-training-environment-730c515b978c4797b70cb75498dca86e`. + +Native phase codecs/private custody have focused checks, but actual complete +native material/Run/Study/public recovery qualification remains pending. No +scientific or full-feature pass is inferred from foundation or prerequisite +results. The final candidate needs its own independent review and exact-head CI. + +## Preparation and public controls + +Nine focused real PostgreSQL/pure-owner preparation checks passed in 3.243 seconds, +covering denied/unscoped probes, canonical intent before a controlled probe, +one-use lexical qualification outside the admission transaction, replay without +another call, source drift and pure integrity without a fork. The probe/release +substitutions are explicit controlled fixture seams; these checks are not actual +native child acceptance. Ten affected material regressions passed in 0.590 +seconds, preserving cold built-in closure and original hashes. + +The preparation source subject was: + +```text +project_native_preparation.py db856e9c0819698ee1e382acd9e5d294880765026482c23c0243f79f14d53dfa5 +pipeline.py ca4beb883cece514e94e649934a92b707ec097ebc9adaf4e50be355867408667 +project_material.py 9fcdea438077ed21c26abdbe9694dca40ab3118453b1855e77b91ae963e02627 +project_study_execution.py d1ad78134bda45f2ee6ed09376b5cdce10dfb4acd42dc830ece98f0f6c846d67 +``` + +Logs `native-preparation-final.log` and +`native-preparation-material-regression.log` in the QA scratch directory have +SHA256 `816557025b50bbb62d21c64878510244bfa19e7f9c4354dcbf50b30aa848df29` +and `fa05ab0fad2958cae9237c5ba1557dd6371634a523e228b4fffc2dd80a282648`. + +The release owner now supplies shared finite decoders for integrity summaries and +classification vocabularies. The latter retain the actual `{label,index}` shape; +whole-asset releases do not acquire classification-only fields. Two final actual +PostgreSQL cases passed in 3.779 seconds: mesh freeze, validation, canonical +acknowledgement and cold reconstruction, plus actual text classification export. +They disallowed child construction. Earlier failed fixture/decoder results remain +retained; this is preparation evidence, not native training evidence. The final +log is `/workspace/scratch/tuldok-save-load-qa/native-integrity-actual-final.log`, +SHA256 `2a7ae5b202ce6ca14fef61c73e303ea5fb9e1aecad364dccada2d97bdd6ded4e`. +The subject uses `release_validation.py` SHA256 +`a833ce47ddb4c608dbc7855016e54eb07d00715385f31aab7ec42e47df53dcff` +and `project_native_preparation.py` SHA256 +`2c2bf32e30fec572a8014db71cd7eef3d01210cd2a62b55d644be16dc181b1b9`. +Targeted independent source rereview cleared this subject, including exact +historical text and the actual whole-asset/classification field variants. + +Four fixed native operations and named controls have focused SDK/controller +checks. Health v2 adds a bounded current-local phase page while preserving health +v1 and all 238 published baseline pins. Independent review found malformed +successful-preflight responses were accepted. The repair rejects contradictory +truth fields, malformed errors and non-string hashes; a new probe also clears the +previous submission proof. Focused SDK and Node checks covered 18 negative replies, +valid success/failure and same-checkpoint proof invalidation. Targeted rereview +cleared this repair. Actual native SDK/CLI/MCP/Chromium qualification remains +pending. Existing phase facts cannot populate current execution or admission +contexts. + +## Canonical native proof and original-stage reading + +Native phase records retain exact bounded generated proof text: job template, +effective job, parent observation, drain receipt and bounded UTF-8 probe response. +A shared pure validator checks their existing artifact hash/size edges and fixed +relationships without filesystem, runtime or process lookup. Large source, +training logs, weights and outputs remain declared payloads. Missing payloads +must permit inert metadata inspection; current consumption and complete copying +still require verified unique bytes. Historical process observations establish no +current execution authority. + +Five new original-stage reader cases passed, followed by two pure inline-proof +cases and one changed oversized-probe case. These test only the new delta; earlier +foundation campaigns were not repeated. Oversized or non-UTF-8 stdout cannot make +a known drained process appear resumable: raw evidence is retained and the domain +adapter records a typed protocol failure rather than a valid runtime preflight. +The chained receipt is +`/workspace/scratch/tuldok-save-load-qa/artifacts/native-process-inline-proof-20261022.json`, +SHA256 `48489fef933809d2f3e4e4679e13a383bd1e3ec9072678f830d704d26c247b8b`. +This subject uses `project_native_process.py` SHA256 +`5a750c359c8989342b67a829de63fe107c5eee6029396afc080df0681c5d7fa6`. +The reader/parser result does not itself qualify native domain integration, +failure reconciliation or the complete native Run/Study path. + +Two subsequent actual PostgreSQL/helper cases passed in 3.575 seconds. A real +deferred-constraint failure at GO-intent SQL COMMIT sent no GO; explicit current +reconciliation recovered exact drainage without relaunch. A fixture probe exited +zero with non-UTF-8 stdout, followed by terminal SQL COMMIT failure. Reconciliation +retained an `invalid_utf8` protocol observation with completed/drained process +facts, without granting a valid preflight or sending another GO. The initial +fault fixture tried to replace the transaction-hook owner and was rejected before +process creation; only those two blocked cases were corrected and rerun. +The raw log is +`/workspace/scratch/tuldok-save-load-qa/m3-native-protocol-recovery-hook-fixed.log`. +The actual fixture worker is deliberately malformed; this is custody/protocol +evidence, not a native scientific Run. Original-host Workspace/HTTP phase health, +full native Run/Study and public adapter gates remain pending. + +A parallel source audit caught that the training path's existing scientific +`execution_test_exposure` field was absent from the portable Run allowlist. The +Run owner is preserving and validating that finite observation before the real +training gate; no claim of complete native execution is made while it is open. + +Independent review also found that the new native phase's unique bytes could +share a digest with a regenerable runtime file and inherit its weaker disposition. +The finite fork adapter now marks native phase payload ownership explicitly. One +new empty-byte collision/declaration control passed in 0.008 seconds, covering +streams, receipts, source and measured output: each promotes the shared blob to +unique, and a missing declaration blocks complete copying. The source subject is +`project_fork_composition.py` SHA256 +`65fa584905282c820e50e3fc6edc3b81f4651b3c9da4be634ac2f9b0137013d7`; +the test file SHA256 is +`9725a7e5c71119bac114523456ee162111f91fe43a5b9a8f9a8c91f178af8928`. +Targeted independent source rereview cleared this fix. Complete native Run Save +As/cold acceptance remains pending. The test is a pure fixed-owner classification +control, not a filesystem copy or scientific execution claim. + +Two further current-source review findings remain with the core owner: generic +current/original recovery must distinguish native Runs before reconstruction, and +native output retention must anchor the output directory against symlink +substitution as well as reject unsafe leaves. Neither source finding is evidence +of a successful or failed native training campaign. They are being repaired before +the frozen complete native gate. + +The affected no-ML controls subsequently passed four cases in 1.566 seconds: +current proven prelaunch settlement versus restarted launch ambiguity, typed +generic/phase recovery separation before reconstruction, stage/output/leaf +symlink and rebinding refusal, and private native-admission binding tamper. Raw +log: `/workspace/scratch/tuldok-save-load-qa/m3-native-reconciliation-anchors-first.log`. +Targeted source review cleared output anchoring and the finite recovery distinction. + +The complete prelaunch boundary needed one further correction: the volatile +known-not-launched marker must survive all foundation validations and be cleared +immediately before entering actual `Popen`. A private optional callback now +forwards that exact boundary; it grants no execution authority. Four new focused +cases passed in 0.575 seconds, without repeating baseline campaigns. Configuration, +alias and capture refusal retains the marker; an actual READY helper invokes the +callback once and drains without GO; Popen-entry and post-spawn Handle failures +leave the marker absent and custody uncertain. The receipt is +`/workspace/scratch/tuldok-save-load-qa/artifacts/native-process-spawn-boundary-20261022.json`, +SHA256 `1f2a0c3b2fdfc1057be5a200fa567930ef1e86fc19592049f29dda8f60af0413`. +This delta uses `project_native_process.py` SHA256 +`8619d07e8b35c87681e9011a7c14b5c52b329d5fc7f3fee9908919a17f278394` +and `recipe_process.py` SHA256 +`eb6dea6adce86c5460585135c9f2a7b7171d970a8fe6f26b7d5350a88b973946`. +Targeted independent callback rereview cleared this exact subject and verified +the supplied receipt hash. This closes the residual current known-no-spawn +liveness finding; it does not waive genuinely uncertain launch fencing. + +Two actual PostgreSQL native admission ACK-loss cases then passed in 11.401 +seconds. A real deferred-COMMIT failure leaves canonical queued history and an +original-host v3 pending marker while live SQL Run/admission rows remain absent. +Explicit current recovery and reopened-original recovery clear only the proven +non-effect local intent, preserving canonical bytes and importing no authority. +The first fixture expected the raw constraint exception instead of the owner's +wrapped projection error; its failed result is retained, and only that expectation +was corrected. Raw log: +`/workspace/scratch/tuldok-save-load-qa/m3-native-admission-ack-wrapped-fixture.log`. +Actual Workspace/HTTP health/recovery and complete native scientific gates remain +pending. Private v1/v2 shapes stay readable; v3 records native admissions separately +from immutable canonical historical facts. + +## First complete native qualification subject + +Source and test preparation reached a stable boundary and the lead made an +immutable repository copy at +`/workspace/scratch/tuldok-save-load-qa/native-complete-qualification-119ec2048c3248afa110cfa1280c36d2`. +Manifest SHA256 is +`d9f7fcc6e0a79483d8d48820acd78f4638b20f6077aff0fe81d62efec454da6e`; +824 product/test/static/schema files are hashed and 2,467 repository files copied. +The sole qualification delta enables M3 in that copy; the shared branch remains +disabled. Source hashes matched before and after copying. The native direct Run +gate, real SDK/CLI/MCP gate, Chromium gate and complete independent source review +were started in parallel. The prepared Study gate follows the first direct +scientific-path result. These started gates are not passing results. + +The complete review found a structural model-aware closure gap: retained +train/verifier job proof was validated individually, but its scientific context +was not fully compared to its associated Run. The Run/phase owner is adding the +existing scientific owner's exact Run-derived job comparison and a pure forged +context negative. Running gates keep their frozen subject; no source copy is +edited mid-campaign or retroactively declared fixed. Retaining disposable actual +canonical proof allows the stricter decoder to check the same measured Run later. + +One additional real PostgreSQL double-fault case passed in 4.587 seconds: canonical +failed-phase publication followed by SQL ACK loss, then exact recovery without +Popen, fake helper/receipt or duplicate canonical write. It changes only the main +test file; all other 823 frozen source-file hashes matched before/after. Raw log +and receipt are `m3-native-never-launched-double-fault.log` and +`m3-native-never-launched-double-fault-receipt.json` under the QA scratch directory. + +## Control/project separation + +Independent review found a reachable placement of `--data` inside the selected +portable project, causing private projection/custody state to enter its Git tree. +It did not find automatic credential bytes on disk. A shared anchored directory-FD +ancestry guard now rejects overlapping selected targets and control directories +before New/Open/Save As or extraction publication. A broad alias parenting +disjoint control/project siblings remains supported. The constructor's already +created explicit control directory is retained; no cleanup is claimed. + +Four pure guards passed in 0.006 seconds and one PostgreSQL transfer aggregate +passed in 1.166 seconds, proving invalid Stop/Extract targets perform no source +stop/publication and valid sibling targets remain usable. Store SHA256 +`52f6bc9b01dff50c783c21a7a4e618c590e74d80b1e23e4320d4c49752e8e68b`; +controller SHA256 +`c76bce481669bc7afac1af6af67ddba3104584905f1f2fd4b15e55767d43d203`. +The affected Workspace aggregate passed in 1.995 seconds; its first fixture +expected the old exception type and was corrected before that affected case was +rerun. Targeted independent source review cleared the path guard and integrations. + + +## Completed native science and causal fixture corrections + +The chronological pending statements above describe their original checkpoints. +The dispositions below supersede them without turning failed campaigns into +passes. Source findings and repairs are in [implementation review](implementation-review.md). + +The derived immutable subject used manifest SHA256 +`5e407f0a6a556a8b313a53fb61327d92dbd447b2d89c3d4235a72b9fa6377607`: +exact Run-derived scientific context, unknown-spawn fencing and the fork-intent +repair over the first complete subject. All 824 source hashes stayed stable. + +Actual direct native Run `d9ded76241124c4182903b95842e30fe` trained and separately +verified its measured checkpoint, retained scientific evidence, completed +independent Workspace Save As with new editable IDs/no hardlinks, and cold-opened +under a separate PostgreSQL role denied CONNECT to the source database. +Historical hashed facts were unchanged. The whole case then ERRORed at its +metadata-only Git tail: the fixture expected a generic Workbench error, while +precise missing-input `ProjectArtifactError` `unavailable`/404 was correct. + +Only that corrected no-fit tail ran again against the same actual Run: one case +passed in 16.800 seconds. It verified metadata-only Git Open, no Popen/new Runs, +zero imported SQL authority, unavailable evidence/metrics, and complete Save As +refusal with missing unique bytes. Derived manifest SHA256: +`8a5705e45b225ac3f6d85d65c8d1c82a7e86a1d1879890796f6a0a814fe4ed81`. +Retained snapshot SHA256: +`490bec1ec1fd84fd7bc152223a4a0d272b0be0d9a6a553f9c5f009720624e64a`; +all 47 actual artifact hashes checked. Raw evidence is beneath the QA artifact +root in `native-causal-direct-4d4fc0c30148489f9fff6b7fd17c497b` and +`native-retained-tail-56fb485d36ae4b849c1c9fa3447b58a4`. +This is composed evidence from one measured Run, not a passing original case. + +The actual native Study SQL-ACK fault case passed: a canonical receipt survives +a real deferred-constraint failure, SQL Run/admissions remain absent, no +train/verifier phase starts, and explicit recovery/cold history retain it. +The positive case completed Run `79139857bd374f199acc4f3b32319786`, retained +train/verifier proof, settled its 990-second envelope, cold-opened with zero +authority and denied replay. Its fixture then wrongly demanded zero operation +receipts after explicit fresh Continue. That fresh current operation legitimately +owns one `studies.continue` receipt; the cold pre-Continue zero check stays. + +A retained tail loaded the same proof through the separate cold role and +explicitly admitted one new Run `e807c8d4930642b3a1fe295bdb5cd226`. The test +remains FAIL at its 180-second wait. Inspection of the existing process and +retained canonical proof established this same Run completed in about 198 +seconds, with probe/train/verifier drained. No second fit was started to diagnose +it. Read-only owner checks then verified all 79 artifact hashes, checkpoint +evidence, both settled reservations and unchanged original Run/phase facts. +Original-host final active-SQL assertions were never reached and stay unobserved. + +An additional cold PostgreSQL check reused this same completed two-Run proof +without a fit or child launch. It reconstructed the exact inventory and all 79 +artifacts under a role denied source CONNECT. Current Runs, reservations, +replay receipts, grants, admissions, consumptions, actors and credentials were +zero; both historical charges remained. Protected evidence first refused access, +then a fresh explicit local disclosure permitted the verified checkpoint read. +Original Run and phase documents stayed exact. Receipt `cold-projection-check.json` +SHA256 `2734eceb6bfd4c009f44e2a575ad4ffbda1cd027be5e74c8e8a36055375471fe` +explicitly keeps `past_source_sql_counts_observed: false`; current reconstructed +zero authority does not substitute for those past source assertions. + +Retained two-Run snapshot SHA256: +`9064303d057ecf488bbaf620d2aea3788dea63d6b85e775831ecb61d00114f55`. +Evidence is beneath the QA artifact root in +`native-retained-study-caa4eb8f40a24f20a2f83f1b41e8d304`; +`completed-evidence-check.json` explicitly says `active_sql_counts_observed: false`. + +Independent review approved a finite fixture deadline: existing 990-second +`native_admission_wall_seconds()` plus a named 120-second publication/observation +allowance (twice the existing catalog-local timeout). Native helper cleanup is +already inside 990 and is not counted again. This 1,110-second test bound changes +no product policy. One pure behavior case passed in 0.018 seconds, exercising +both await helpers beyond 180 seconds, active-count drainage and short overrides. +No database, training or native subprocess ran in that delta check. + +## Actual native public controls + +One actual SDK/CLI/MCP control passed in 79.902 seconds on the first complete +subject: one admitted GO, replay without another GO, zero Runs, exact failure +and original-host phase recovery. Receipt is beneath `artifacts/native-public-clients`, +suffix `c50ab71cf9b7484497a66a36b81cc8e3.json`. + +The Chromium fixture initially expected two unresolved phase buttons; one was +correct because the other completed phase was canonical history. Only that +fixture was corrected. Three functional/cold groups then passed, four screenshots +at 390/1440, no JavaScript/HTTP errors, source CONNECT denied to the cold role. +Receipt SHA256 `91717dc0c447a5034c18ba744a57c42f11758e8d7110f3e7138705de6b4fb003` +at `artifacts/project-native/run-lV2T6Y/receipt.json`. These public controls cover +preflight/recovery, not public actual training or model quality. + +## CI portability and remaining integrated gate + +Preflight found scratch-specific interpreter/evidence paths in the new native +fixtures. They now require explicit `TULDOK_TRAINING_PYTHON` and use the configured +QA output root for retention. Three exact native cases were selected without +skips under an alternate configured interpreter; unset-gate checks selected the +same three and confirmed all guarded. SDK/note selection found two without +skips. These loader checks executed zero tests, database calls or child processes. +The browser's Python script now starts from `/tmp` with `PYTHONPATH` unset; both +help modes exit zero and select one case without effects. + +Final exact-head CI selects all three original complete native cases, including +the final Study SQL assertions, and the corrected approved-agent cold-denial +tail. It also selects actual native/note public and Chromium controls. These are +unrun on the final shared candidate as of this report; local component passes +do not substitute for that required integrated result. + +The final shared product matches all 829 files in the last note/browser +qualification manifest, except three test-only deltas: script-mode import +portability, and the two native fixtures' explicit runtime/QA-root portability +and finite deadline corrections. The shared Workspace's enabled M3 flag is now +byte-identical to the already qualified copy. Independent targeted rereview +cleared those exact deltas and CI workflow SHA256 +`9bbe1cf75444310b0214a365bc6db89600ee960ae326996bcc92e91e8c71aad0`. + + +Final integration preflight selected the three native domain cases and two +native/note transport cases: 5 selected, 0 executed, 0 skips, no database calls +or child launches, M3 enabled. Syntax parsing of all 39 changed Python files, +Node syntax checks for the changed page and new browser fixtures, and +`git diff --check` passed. These are supporting checks, not runtime acceptance. + +## 2026-10-23 — First complete candidate CI outcome + +At feature head `5704965d1a441946aacf3548e0b9f57dc4312d35`, lifecycle run +`39874125909` passed all three complete native domain cases in 525.522 seconds, +including the Study's original final SQL assertions, without skips. Native +SDK/CLI/MCP (one case, 22.140 seconds) and note-deletion/cold-fork transports +(one case, 10.541 seconds) also passed without skips. This closes those named +case gaps on that exact product subject; it does not turn the earlier local +ERROR/FAIL attempts into passes. + +Full discovery subsequently failed with six failures and two errors in stale +test fixtures. Later Chromium gates were skipped. The three other workflows +succeeded; complete-feature acceptance remains pending. Exact checkout/tree +identity, failed-case diagnosis and narrowly scoped repairs are preserved in +[the CI report](ci-5704965-repair.md). Production code is unchanged during those +repairs; no native fit or broad suite has been repeated locally. diff --git a/docs/plans/external-project-save-load/reports/m4-implementation.md b/docs/plans/external-project-save-load/reports/m4-implementation.md new file mode 100644 index 00000000..805020f9 --- /dev/null +++ b/docs/plans/external-project-save-load/reports/m4-implementation.md @@ -0,0 +1,94 @@ +# M4 integration evidence + +Current status and acceptance remain authoritative in [the plan](../plan.md). +These checks qualify their named M4 subjects. Later component results are +recorded below; the draft PR must not merge before integrated acceptance. + +The integration baseline is `c22e9d02c87938514a7a1c9b3b0532158008ca0d`. +Main was rechecked as `133d53c0e2985a6559ba9cebd7d4a9f530051f06`, and standards +remain `5bce3110fc64d61ff8b4f79468184bd6295855d5`. + +The finite fork builder preserves every source envelope, constructs independent +editable identities through Data/Material/Files owners, and records an explicit +origin mapping. Hash-bound material/history is never blindly rewritten. Payload +dispositions default to unique; only known runtime-only binary dependencies can +be absent with an explicit missing-regenerable inventory. Save As stages all +content before atomic no-overwrite publication, then selects a new inert SQL +projection. A selection failure retains the published destination and receipt. +Source working files must match their explicitly adopted versions. + +Performed owner checks include twelve focused composition/disposition cases +(five actual PostgreSQL/publication cases), exact response-revision mapping, +deterministic construction, nested forks and conservative host-source closure. +Missing unique historical host-source bytes block a complete fork; current Git +files are never substituted for those old measured identities. + +Three actual HTTP/PostgreSQL lifecycle cases passed in 7.864 seconds: nonempty +independent Save As with exact original envelopes/new editable IDs and source-key +reuse, published-but-unselected exact retry and changed-file refusal, and real +Git checkout/stale projection plus moved-folder inert reconstruction. The first +test draft selected invalid empty groups and failed input validation before +product writes; correcting that representative fixture produced these results. + +Public qualification ran in an immutable copy containing 2,429 repository/test +files. Manifest `/workspace/scratch/tuldok-save-load-qa/m4-public-qualification-source.json` +has SHA256 `55c018a7a1e62a12df59c4a4c57a5fb4ce8446a48fee476b913df6fbdc5c1e2a`. +All listed hashes matched before/after. This is the tested subject, not a claim +about later runtime/candidate/extraction edits. + +- One selected actual SDK/CLI/MCP test passed in 24.364 seconds. It covers exact + empty/NUL/Unicode working-file bytes, captured history, current roles, owner + Save As and explicit original intent references. Receipt: + `/workspace/scratch/tuldok-save-load-qa/artifacts/m4-clients/test_exact_file_revisions_save_as_and_current_role_through_sdk_cli_mcp-e1743fbbb63743d6a165522691c85729.json`. +- Three named Chromium functional/cold groups passed, including keyboard file + adoption/history, independent Save As, read-only unresolved material intent, + a local Git metadata clone with explicitly transferred untracked payloads, + and cold reconstruction under a role denied CONNECT to the source database. + Script sentinels stayed inert. At viewport widths 390/1440, measured document + scroll widths were 375/1425; no runtime JavaScript or HTTP errors were reported. + Receipt: `/workspace/scratch/tuldok-save-load-qa/artifacts/project-files-m4/run-iSbs6p/receipt.json`. + +[Scoped independent review](m3-m4-custody-review.md) identified and source-verified +corrections for the allocator signature, physical-root custody fencing, recovery +binding correlation, legacy receipt reactivation, and historical disclosure +reading. Wider final acceptance/review and exact-head CI remain separate gates. + +The additional negotiated text-contract case passed in 8.957 seconds, one +selected/no skips, on immutable recovery-commit subject `ee43d849` (manifest +SHA256 `23fbbb9fb3316c525bb2d0f69d736adb240c55058ef518bb32711ed9aa305158`). +Old get/annotate v1 refused fork provenance before mutation; v2 retained exact +origin and history. Receipt: +`/workspace/scratch/tuldok-save-load-qa/artifacts/m4-clients/test_fork_text_v1_refuses_before_mutation_and_v2_retains_exact_origin-4d510b8319a848088f08e298d053e391.json`. + + +## Explicit note deletion and native independent fork + +The complete review found the promised intentional note deletion path missing. +Explicit note-only deletion adoption now records a closed v2 tombstone under +CAS, preserves all positive revisions and never deletes a filesystem entry. +Open remains inert, Save As leaves the current note absent, and ordinary explicit +adoption restores it with a new positive revision. Missing scripts stay protected. + +Ten backend PostgreSQL cases passed in 8.483 seconds, including seven new and +three affected v1 cases. Receipt SHA256 +`2709ab2ebcd70caeda59a98f70dfbcad401e583ce7b8f4198ad56826f4332759`. +The real SDK/CLI/MCP case passed in 22.416 seconds without skips. A browser +fixture initially compared CRLF against the DOM textarea's normalized LF; +only its comparison changed, with a separate exact raw HTTP byte check retained. +Three Chromium/cold groups then passed: explicit confirmation, deletion/history, +restore revision 3, delete revision 4, independent Save As and cold Git history. +Four screenshots at 390/1440 fit, no JavaScript/HTTP errors, no source-root access, +and cold-role source CONNECT denied. Receipt SHA256 +`ffc642e167b3e62652314d86e5a7e3650b41459c503dd0bc21f7fbbb56df70d1` +at `artifacts/project-note-deletion/run-dNw9B1/receipt.json`. Derived manifest +SHA256 `6e888f2681c9fb86d21ed77475286e2b96e80cdef44b8f7aed99f012e2d5743f`; +all 829 source hashes matched before/after. Complete bounded backend/public +rereview cleared the implementation; 238 baseline operation pins stay unchanged. + +Actual native Run Save As initially exposed a fork-intent decoder error. Its +finite owner repair passed a no-fit PostgreSQL regression and source rereview. +The actual retained Run subsequently completed independent Save As and cold +separate-role reconstruction with original history unchanged. Its metadata-only +Git tail passed separately using the same Run, without another fit. See +[native completion](m3-native-completion.md) for exact subjects, failed original +case outcomes and the remaining integrated CI assertions. diff --git a/docs/plans/external-project-save-load/reports/m5-implementation.md b/docs/plans/external-project-save-load/reports/m5-implementation.md new file mode 100644 index 00000000..e1bc8724 --- /dev/null +++ b/docs/plans/external-project-save-load/reports/m5-implementation.md @@ -0,0 +1,115 @@ +# M5 implementation evidence at recovery checkpoint + +[The plan](../plan.md) remains the only authoritative current status. M5 is not +accepted and no user catalog or local lab has been inspected or migrated. + +The fixed legacy collector preserves exact original science and explicit +transitive closure, including two-Study lineage, reservations, candidate facts, +Run payloads, native BYTEA, annotations, responses, preferences and source files. +Unrecorded history, missing unique bytes and unassigned membership are visible +blockers, not fabricated history. The read-only collector's thirteen cases +passed in 4.801 seconds, including audit-only successful deleted intent IDs. + +Transfer's twenty owner cases passed in 17.495 seconds. Later targeted cases +proved that a legitimately edited destination uses its own artifact custody on +retry, and a selected destination reuses the actual workspace-owned Store under +ordered locks instead of acquiring a second writer. Retry reports the current +verified checkpoint while immutable extraction source/selection/input facts stay +unchanged. Native backup remains private legacy custody, never a portable format. + +The process-local controller's thirteen real PostgreSQL cases passed in 22.252 +seconds. These include actual selected-contract HTTP preview/report, explicit +stop and verified backup, old-source credential rejection and separate current +control-host authentication, fresh stopped report, extraction and inert Open, +exact retry, current revocation, failed publication/staging retention and a +published destination whose selection fails. A stop timeout with an old HTTP +handler still active keeps ordinary requests fenced; that handler returns its +original source identity, and only after it drains can the service switch to the +independent control host. No lease is minted on that timeout. + +Controller SHA256 at that checkpoint: +`671ada52612dbd108564e9842f61b27abd619e7251c641e57aa77ee8fb0ea1e5`. +Collector SHA256: +`85eb1e057e48dae60ba979c268c4c6867008e9f0e208d4ef0d87ced3142c8500`. + +Five closed public contracts, a separate owner SDK client, named CLI commands, +and explicit `--project-transfer-owner` MCP mode are implemented. Owner modes +never fall back to agent/catalog authority or retag an old credential. The +combined actual SDK/CLI/MCP transfer workflow subsequently **passed**, one +selected/no skips in 30.703 seconds. All 2,448 files matched immutable manifest +SHA256 `7030f8e03dc27c646257172b8105a589964b63e48763228de49842adc1ec2a52` +before/after. The first attempt lacked the already approved optional MCP +environment and performed no extraction; the corrected environment used the +same source. Receipt: +`/workspace/scratch/tuldok-save-load-qa/artifacts/m5-clients/test_owner_handoff_and_single_extraction_through_sdk_cli_and_real_mcp-763658d3d142499ca1c48db9feb52040.json`. + +The named transfer UI has twelve controlled scenarios passing. Its actual +Chromium acceptance subsequently passed four workflow checks, with eight +screenshots at 390/1440, no JavaScript exceptions and only expected 401/409 +responses. A separate cold role could not CONNECT to the source database; +source hashes remained unchanged and the project script stayed inert. +Immutable manifest SHA256: +`16f53e463fcedf3388ebe4a28682d79391fe5c7d9f8df5a958227d5dc72b3876`. +Receipt SHA256: +`24885555bf4f8cebfb9c8e0f53ab4421e8450ad2e337f407aa86fceaa1f88292` at +`/workspace/scratch/tuldok-save-load-qa/artifacts/project-transfer/run-z1Ffak/receipt.json`. + +The initial actual CLI startup exposed duplicate Python module identities: +`__main__.Dataset` differed from `app.Dataset`, so the exact-type source owner +check refused startup. Registering the executing module under its canonical +name before `main()` fixes real startup without weakening the owner check. +A later browser fixture had a missing JSON clone helper; its failure and the +startup failure remain retained. The successful derived source includes those +specific corrections; it does not retroactively pass their earlier subjects. +These results remain component evidence pending full integration/review/CI. + +Independent review found that the Run-artifact dependency discriminator was read +from the nested source instead of the input item. The fixed collector queues +same-origin upstream Runs and reports foreign origins as explicit blockers. +Two targeted PostgreSQL cases passed in 0.717 seconds: selecting a consumer +captured a three-Run dependency chain and exact payload bytes, and a foreign +source could not fall back to a matching local ID. A fresh inert historical +reader consumed the captured producer receipt with source reads disabled. +This is an authored historical fixture, not a claim of scientific execution. +Re-reviewed collector SHA256: +`26081ee6730979e846ecb8ffaea18ac29d41210893995c7a2284ed0fe49d0f4a`; +tests SHA256: +`45193fbbc023061cd8dd91c22277f41ad626809e0c7ade81c99a8d9c2443b1ad`. + +## Native original-source closure correction + +A later source audit found that legacy native Run extraction retained source +hashes but did not copy the original adapter bytes from the Run's retained +`.staging-` directory. Non-model-aware Runs could therefore lack those +unique bytes even when extraction reported complete. The finite collector now +validates the declared runtime observation with the Pipeline owner and reads +each original staged source through the anchored stopped-source capability. +These are unique `source/` history payloads. Missing/tampered originals or +missing source identities block complete extraction; current host source code +and source resolvers are never substitutes. The existing in-process integrity +adapter has no training staging and is handled by that explicit owner distinction. + +Five distinct actual PostgreSQL cases passed across a first batch with three +passes/two fixture errors and a corrected run of only those two cases (0.874s). +They cover original bytes, missing/tampered capture, missing inventory and the +affected two-Study history fixture. This is controlled retained-custody evidence; +no native probe, training, child process or model-quality campaign ran. An earlier +runner's output handle was lost; its exit status cannot be recovered and remains +unverified. A subsequent `/proc` inspection found no surviving matching unittest +process. No success/failure claim or reconstruction rerun is made for it. + +Final collector SHA256: +`5164987980a236942ec066cbf61742f8b285b5641655dee15548251dac21285f`; +new test file SHA256: +`c260ed8276c917185377a0c8c31387de18b2dca59c32953be7f8df5ed320ed5a`. +Raw logs: +`/workspace/scratch/tuldok-save-load-qa/native-extraction-focused.log` and +`/workspace/scratch/tuldok-save-load-qa/native-extraction-repaired-fixtures.log`. +Independent targeted source review cleared this subject against actual legacy +staging, the shared runtime decoder and the in-process integrity distinction. +The reviewer performed no execution. This does not accept M5 or replace +the pending complete native Run/Study/public and final integrated gates. + +See the +[runbook](../../../runbooks/external-project-extraction.md) for the explicit +source/control credential handoff and source-preservation boundary. diff --git a/docs/plans/external-project-save-load/reports/verification.md b/docs/plans/external-project-save-load/reports/verification.md new file mode 100644 index 00000000..7a47f95a --- /dev/null +++ b/docs/plans/external-project-save-load/reports/verification.md @@ -0,0 +1,50 @@ +# Planning verification + +Scope: documentation in `docs/plans/external-project-save-load/` at application baseline `133d53c0e2985a6559ba9cebd7d4a9f530051f06`. Standards baseline is `5bce3110fc64d61ff8b4f79468184bd6295855d5`. This record concerns plan structure, source reasoning, links and scope only. It does not satisfy product A1–A9 or claim runtime validation. + +## Material subject and result + +| Check | Actual result / limit | +| --- | --- | +| Source/standards baseline | Remote main and local fetched source agree on the revisions above. Checked workspace ancestry, tracked tree and checkout skill paths; no applicable AGENTS/skills. Source facts are pinned in the baseline report, not inferred from older plans. | +| Exact upstream plan structural function | PASS for one selected plan (nonzero): `validate_plan(plan_text)` returned `None`, using unchanged standard-library AST functions/constants/helpers from the source below. Initial bold probe prefixes were rejected; canonical plain prefixes repaired that markup. Structural validity does not prove semantics. | +| Relative links / pinned sources | PASS: every relative Markdown target exists; every immutable GitHub source link resolves to a file object in the recorded Tuldok/standards revision. Code fences excluded from link parsing; balanced fences checked. Source-object resolution does not certify remote publication or product behavior. | +| Independent complete review + changed findings | CLEAR after three material findings and one bootstrap detail were resolved and re-reviewed. Exact final subject hashes below match the reviewer's observations. See independent-review.md for scope/dispositions. | +| Whitespace, staged scope and sensitive-file review | PASS: `git diff --cached --check`; staged paths consist only of the six Markdown documents in this plan directory. Index bytes match the reviewed authored files; no product/config/catalog/binary/credential files are staged. | +| Original checkout and standards | Preserved at their original recorded HEADs with clean status. Planning worktree is isolated on its private named branch. No push/PR/merge or standards mutation. | +| Product/runtime tests | Not run. This is a Markdown-only task. Filesystem/PG/process/CLI/SDK/MCP/browser A1–A9 remain prospective; no runtime, power-loss, scientific or migration result is claimed. | + +Final reviewed material SHA256: + +- `plan.md`: `d6d3832a494a44aebae15bed20748a6d5b90dfa98919d315a179abae02d60b4d`. +- `reports/baseline-and-standards.md`: `67a0d17727c1467cc1c979c0147462156c47a257dd134ffff6be3bbee9071911`. + +Supporting review/ledger/verification records do not alter this material subject. There is no evidence-volume target, fixed campaign count, all-suite rerun, or exact-HEAD re-review after unchanged bookkeeping. + +## Reproduction and checker provenance + +Canonical structural checker: +`tools/standards_verifier/standards_verifier/checks/plan_contract.py` +at Coding-Standards `5bce3110fc64d61ff8b4f79468184bd6295855d5`. +File SHA256: `c7266c0564b9ee02944fc4eb12d66cc20d604fd4ddb7f051ed77d01e9fe4466e`. + +The same bounded loading method used by earlier repository plan checks avoids unrelated aggregate package initialization. Parse its Python AST, retain original Assign/FunctionDef nodes before the first ClassDef plus `re` and future imports, then execute those unmodified nodes and call `validate_plan` on this exact plan. No checker condition or expected value was weakened. The local disposable helper `/tmp/check_tuldok_external_plan.py` ran that function, checked all relative targets and pinned source objects with `git cat-file -e REV:PATH`, verified balanced fences and emitted the material hashes. It adds no production tool or permanent verification framework. + +Final documentation commands from the planning worktree: + +```sh +python /tmp/check_tuldok_external_plan.py +git diff --cached --check +git diff --cached --name-only +git diff --cached --stat +``` + +Preflight identified the exact worktree, original source/standards refs, six Markdown paths and one structural plan selection before interpreting results. The staged review compared index bytes with the reviewed files and checked the plan/source report hashes. Required unavailable product environments are not substituted by these documentation checks. No Standards Engine application MCP is exposed, so no Engine certification is claimed and no authoring bypass was attempted. + +## Deliverable and resources + +Local branch: `plan/external-project-save-load-20261022`. +Worktree: `/workspace/Tuldok-project-save-load-plan`. +Only output subtree: `docs/plans/external-project-save-load/`. +Local commit: the retained named branch's head, reported in the final response; resolve with `git rev-parse plan/external-project-save-load-20261022`. +The branch head is `retained-ref`, disposition `retained`, worktree outcome `retained-protected` for user review. The user owns integration/retirement; no remote action or cleanup is authorized here. No real blocker remained to planning, independent review or local documentation commit. diff --git a/docs/runbooks/external-project-extraction.md b/docs/runbooks/external-project-extraction.md new file mode 100644 index 00000000..0684abe0 --- /dev/null +++ b/docs/runbooks/external-project-extraction.md @@ -0,0 +1,249 @@ +# Extracting a legacy project into a portable directory + +This runbook describes the M5 extraction contract under implementation. It is +not an instruction to migrate a personal catalog before the extraction gate is +accepted. Work on a disposable representative fixture first. Do not inspect or +migrate a user's local lab as a product acceptance test. + +An extraction keeps the original stopped source and creates a new, independent +project. It is neither a database migration nor a generic SQL export. Historical +materials, Runs, Trials, source observations and their hashes retain their +original namespace. Editable copies receive destination identities through +their domain owners. No credentials, live grants, admissions, process control or +resumable operation authority enter the destination. + +## Preview first + +Preview uses a read-only, repeatable-read PostgreSQL view and pinned filesystem +capabilities. It does not construct `Metadata`, `Dataset`, Jobs or Runs; the +legacy `Metadata` constructor can append `.metadata-init.lock`, even with +`create=False`. Preview does not repair schema, enroll images, execute scripts, +install dependencies, contact producers, or regenerate missing bytes. + +Select one exact registered project snapshot and explicit roots from the finite +families: data records, responses, preferences, saved selections and searches, +graphs, recipe drafts, model definitions, Studies, Runs, producer histories and +working files. A registration snapshot identifies captured external files; it +does not establish ownership of otherwise global data, Studies or Runs. + +Review the report's required dependency closure, shared dependencies, unassigned +global records, ambiguities, missing payloads and unsupported source facts. +Parent/baseline lineage can require another Study as historical provenance. That +expansion is visible in the report and must not be presented as inferred +ownership. A required dependency cannot be excluded while claiming a complete +extraction. A saved search preserves entered criteria; its current search +results are not implicitly selected membership. + +The report binds the exact selection, source schema and snapshot, backup and +external inventory. Extraction requires the same report digest and selection. +An unresolved or incomplete report is not successful empty-project creation. + +## Stop through the existing owner + +The explicit stop operation belongs to the currently opened legacy controller. +It first fences new requests and drains existing request handlers. It verifies +that this instance owns all five known runtime claims, drains the four producer +workers and standalone image requests, and asks the existing Run owner to prove +local worker and external custodian drainage. A peer's runtime ownership, +unconfirmed descendant cessation, failed drain, unknown outcome or lost lock +prevents a stopped-source capability from being issued. + +An old backup receipt, `quiesced=True`, a process ID, an idle activity message or +a stored terminal state is not this proof. A newly opened application without +the original live owners can still preview, but reports +`cessation_proof_unavailable` until a trusted owner performs stop and capture. + +The controller issues an opaque, local stopped-source lease only after confirmed +drainage, peer exclusion, read-only snapshot pinning, a verified backup and an +external payload inventory. The lease owns its connection and directory +capabilities. It cannot be reconstructed from JSON or imported project facts. + +Stopping can legitimately persist cancellations or interruptions through the +existing runtime owners. The source-unchanged baseline is recorded **after** +that explicit stop and verified backup. Extraction itself must preserve that +baseline exactly. + +## Preserve original evidence + +Verify required unique payload bytes, including native BYTEA bundles, original +images/audio/scientific sources, release ZIPs, execution inputs and outputs, +runtime manifests and captured scripts. Resolve files only through current +explicit capabilities; historical absolute paths are inert evidence. + +Missing original bytes cannot be replaced by running a producer, re-exporting a +release, recreating a manifest's formatting or remeasuring a result. Declared +regenerable dependencies retain visible availability. Missing required unique +bytes block a complete extraction. + +Legacy storage has genuine history gaps. Saved selections/searches retain only +their current row, and image source revisions are not a complete source event +stream. A report must identify absent historical revisions, timestamps or +bindings. It must not invent earlier names, source revisions, audit events or +complete-history claims. An observed-history representation requires explicit +domain support; otherwise extraction remains blocked for that case. + +## Publish and verify the destination + +Use a new destination under an authorized root. Source, stopped backup, +destination and task-owned staging must not overlap. Existing destinations are +not overwritten or merged. Stage validated domain records and immutable payloads +without writable hard links, then publish atomically through the project store. +Project-owned scripts and notes remain ordinary inert UTF-8 files; copying them +does not execute them. + +The extraction receipt binds the source, selection/report, request identity, +destination project/checkpoint, identity mappings, artifact completeness and +independent cold reconstruction. The deciding test opens the new project with a +fresh physical PostgreSQL projection and checks retained identities, hashes, +history, closure and source preservation. Current permissions come from the new +installation, never the imported source's access tables. + +## Use the process-local transfer controller + +The transfer controller takes the already running legacy Dataset and a separate, +inert control workspace. Configure publication root aliases for the verified +backup and destination; the destination alias must also be a root of the control +workspace. Root paths are pinned directory capabilities, and backup and project +names are single relative directory names. Symlink roots, publication roots +inside the source catalog, overlapping targets and existing backup targets are +refused before Stop. + +The five fixed controller actions are `project_transfer.status`, `preview`, +`report_page`, `stop` and `extract`. These are controller methods; the application +transport exposes only its corresponding validated routes. An ordinary Open or +preview cannot stop a source or create a stopped-source lease. + +1. Authenticate as the current legacy owner with administration permission. Read + Status and submit the explicit snapshot, root selection and decisions to + Preview with that process binding. Preview remains read only. Its summary + gives the report digest and counts; Report Page exposes one of `included`, + `required`, `unassigned`, `excluded`, `blockers`, `warnings`, `artifacts` or + `working_files` in pages of at most 40 items. Keep the same report digest while + reviewing pages. +2. Request Stop with the reviewed live report digest, backup root/directory and + proposed destination root/directory. Stop validates both target locations, + drains the actual source owner and creates the private verified backup. A + successful Stop rotates the process binding and clears the live report. +3. Sign in explicitly using credentials issued independently by the control + workspace. Source credentials are not copied, rebound or accepted as control + credentials. The stopped source's authentication database may already be + closed. Refresh Status and Preview the same explicit selection again; the + stopped-source report now binds the verified backup and cessation capability. +4. Extract using that exact stopped report digest, configured destination, + display name and a stable request key. Publication first produces a durable + independent project. The controller then attempts an ordinary inert Open in + the control workspace. Inspect the separate `selection.selected` result; + committed publication is preserved if workspace selection fails. + +The controller binding is an opaque process-local correlation value. Neither a +request nor a stored report contains the stopped-source capability. Restarting +the process does not reconstruct that capability from JSON. A failed stop after +the native source fence keeps the source fenced and exposes the failure through +independently authenticated control Status. If existing handlers have not yet +drained, the application delays its ordinary-route authentication handoff until +they finish. A native authorization denial before installation of that fence +leaves the source phase live and requires a refreshed report. + +The bounded public receipt gives publication identity, source/report/input +digests, copied/missing artifact counts and independent verification. Artifact +declarations remain available through report pages. Its checkpoint and +verification describe the **current verified destination head** on an exact-key +retry, including legitimate later destination edits. The source, selection, +report and input digests remain immutable birth facts; retry does not extract +again. A changed body with the same key conflicts. + +Keep the exact key after a lost publication acknowledgement. A typed unknown +outcome may already carry `committed: true` and `selected: false`; controller +Status retains that partial receipt. Retry reconciles the actual destination. +If publication failed before rename, preserve the reported staging directory for +inspection. Do not substitute a new key or assume the destination is absent. + +On retry, reconcile the exact request key and input digest before attempting +another publication. Keep a published destination. If publication is uncertain, +retain task-owned staging and receipts until its outcome is checked; do not +delete a destination to make a retry appear clean. Report the exact known or +uncertain outcome and preserve the original stopped source. + +## Current owner API + +These are internal owner interfaces. They do not add a public HTTP, SDK, MCP or +CLI capability until those consumers have been qualified against the same +selection, completeness and outcome contracts. + +```python +# Use the already opened legacy controller and its authenticated current actor. +# parent_fd is a caller-owned directory capability outside the source catalog. +from project_transfer import ProjectTransfer, preview_source + +selection = { + 'project_snapshot': {'id': registered_id, 'manifest_sha256': manifest_hash}, + 'roots': {'records': [chosen_record_id], 'studies': [chosen_study_id]}, + 'decisions': [], +} +with preview_source(existing_dataset) as view: + review = ProjectTransfer(view).preview(selection) +# This first report can inspect closure but cannot claim cessation proof. + +with existing_dataset.stop_for_project_extraction(parent_fd, 'stopped-backup') as source: + transfer = ProjectTransfer(source) + report = transfer.preview(selection) + # Review can_extract/blockers, required dependencies and unassigned inventory. + receipt = transfer.extract(selection, report['report_sha256'], parent_fd, + 'new-project', 'New independent project', + 'caller-owned-extraction-key') +``` + +The selection accepts only the finite root arrays `records`, `responses`, +`preferences`, `selections`, `searches`, `graphs`, `recipe_drafts`, +`model_definitions`, `studies`, `runs`, `releases`, `exposures`, `model_materials`, +`recipe_materials`, `runtime_materials`, `producer_jobs` and `working_files`. +Omitted arrays are empty. Producer roots identify one known kind and job ID; +working files identify `script`/`note` plus an exact `scripts/...`/`notes/...` +path. Explicit decisions identify a known family and exact canonical ID, an +include/exclude choice and the entered reason. They do not authorize omitting a +required dependency. + +The stopped backup is private legacy custody and includes the original access +rows. Portable domain records exclude those rows. The canonical extraction +manifest retains only non-secret source digests, the normalized selection, +actual first-adoption observations for selected working files, fixed-family +population counts and copied/missing-regenerable artifact identities. + +A successful receipt has `committed: true` and `selected: false`: publication +creates a project without changing the application's selection. A lost final +acknowledgement returns an `unknown` error carrying its known committed identity +when that identity can be proven. Inspect that destination and repeat the exact +key/input. Before publication, failure retains `staging_directory`; it does not +claim the destination exists. A missing unique payload, absent legacy history, +unknown cessation, unsupported schema or required exclusion stays a blocker. + +Current focused evidence uses disposable PostgreSQL 17 fixtures and private +filesystem roots. It covers ordinary cold Open under a different installation, +current scoped inspection and revocation, actual handler drainage, peer +exclusion, exact source/archive preservation, malformed or missing source facts, +required closure, failed publication and lost-reply reconciliation. Historical +fixture custody is not a claim that an imported process was executed again or +that an old scientific result was remeasured. + +The extraction-only closer has a bounded deadline. If an existing owner does not +finish closing, the controller returns `source_busy`, keeps new requests fenced +and issues no stopped-source lease. Its single owned closer may finish later; +that does not turn a timed-out request into a successful extraction or revive +ordinary requests. A closure failure or unconfirmed descendant remains unknown. +The ordinary application's `close()` semantics are unchanged. + +The focused owner gate is: + +```sh +# Run from the repository root with a disposable PostgreSQL fixture DSN only. +PYTHONPATH="tests:.:${PYTHONPATH:-}" python -m unittest test_project_transfer -v +``` + +The current owner checkpoint passed **20 tests in 17.495 seconds** on the private +PostgreSQL 17.11 fixture. The cross-Study case retains two Studies, two Trials, +parent and baseline lineage, a settled reservation, a candidate, native Run +artifacts, model and recipe materials, a frozen release and shared source bytes. +A separate ordinary Open uses a different current installation and fresh +physical SQL projection, while constructor tripwires verify that Jobs and Runs +do not start. The historical case establishes custody and reconstruction only; +current-host preflight and actual execution have their own gates. diff --git a/experiment_lineage.py b/experiment_lineage.py index 210cf4f8..002ca822 100644 --- a/experiment_lineage.py +++ b/experiment_lineage.py @@ -25,6 +25,67 @@ def __init__(self, result): self.result = result +def validate_retained_intent(value, *, resolve): + """Shared intent policy; the caller supplies exact origin references.""" + value = checked_json(value, INTENT_LIMIT) + closed(value, ('ancestry', 'parent', 'baseline', 'cohort', 'hypothesis', 'change_summary', 'target')) + if value['ancestry'] not in ('unknown', 'root', 'derived'): + raise WorkbenchError('Choose unknown, root or derived ancestry.') + if (value['ancestry'] == 'derived') != (value['parent'] is not None): + raise WorkbenchError('Derived ancestry requires one exact parent; other ancestry has no parent.') + for key in ('parent', 'baseline'): + if value[key] is not None: + resolve(value[key]) + text(value['cohort'], 'Cohort', 120) + text(value['hypothesis'], 'Hypothesis', 2000) + text(value['change_summary'], 'Intended change', 2000) + target = value['target'] + closed(target, ('metric_id', 'metric_definition_sha256', 'direction', 'threshold', 'acceptance')) + text(target['metric_id'], 'Target metric', 80) + if target['metric_definition_sha256'] is not None and ( + not isinstance(target['metric_definition_sha256'], str) or + not SHA.fullmatch(target['metric_definition_sha256'])): + raise WorkbenchError('Target metric definition requires an exact SHA256 or explicit unknown.') + if target['direction'] not in ('minimize', 'maximize', 'target'): + raise WorkbenchError('Target direction is unsupported.') + if target['threshold'] is not None and ( + type(target['threshold']) not in (int, float) or not math.isfinite(target['threshold']) or + abs(target['threshold']) > 2**53-1): + raise WorkbenchError('Target threshold must be a finite safe number or explicit unknown.') + text(target['acceptance'], 'Acceptance intent', 2000) + return value + + +def validate_retained_lineage(value, *, trial, resolve): + value = checked_json(value, INTENT_LIMIT + 4096) + closed(value, ('format', 'trial', 'intent', 'actor_id', 'recorded_at', 'timing')) + if value['format'] != 'trial_lineage_v1' or value['trial']['trial_id'] != trial['id']: + raise WorkbenchError('Stored lineage identity differs.', 'unavailable', 409) + if resolve(value['trial']) != trial: + raise WorkbenchError('Stored lineage trial differs.', 'unavailable', 409) + intent = validate_retained_intent(value['intent'], resolve=resolve) + if value['timing'] not in ('before_admission', 'retrospective'): + raise WorkbenchError('Stored lineage relationships differ.', 'unavailable', 409) + identifier(value['actor_id'], 'Lineage author') + text(value['recorded_at'], 'Lineage recording time', 80) + for key in ('parent', 'baseline'): + if intent[key] is not None and intent[key]['trial_id'] == trial['id']: + raise WorkbenchError('A trial cannot derive from or compare against itself.', 'conflict', 409) + return value + + +def validate_retained_ancestry(child, parent, *, parent_of): + seen = {child} + for _ in range(ANCESTOR_LIMIT): + if parent is None: + return + if parent in seen: + raise WorkbenchError('Derivation would contain a cycle.', 'conflict', 409) + seen.add(parent) + parent = parent_of(parent) + raise WorkbenchError('Ancestry exceeds the bounded inspection depth.', 'unsupported', 409) + + class TrialLineage: def __init__(self, studies): self.studies = studies @@ -46,33 +107,7 @@ def ref(self, trial): 'material_sha256': trial['material']['sha256']} def _intent(self, value): - value = checked_json(value, INTENT_LIMIT) - closed(value, ('ancestry', 'parent', 'baseline', 'cohort', 'hypothesis', 'change_summary', 'target')) - if value['ancestry'] not in ('unknown', 'root', 'derived'): - raise WorkbenchError('Choose unknown, root or derived ancestry.') - if (value['ancestry'] == 'derived') != (value['parent'] is not None): - raise WorkbenchError('Derived ancestry requires one exact parent; other ancestry has no parent.') - for key in ('parent', 'baseline'): - if value[key] is not None: - self._ref(value[key]) - text(value['cohort'], 'Cohort', 120) - text(value['hypothesis'], 'Hypothesis', 2000) - text(value['change_summary'], 'Intended change', 2000) - target = value['target'] - closed(target, ('metric_id', 'metric_definition_sha256', 'direction', 'threshold', 'acceptance')) - text(target['metric_id'], 'Target metric', 80) - if target['metric_definition_sha256'] is not None and ( - not isinstance(target['metric_definition_sha256'], str) or - not SHA.fullmatch(target['metric_definition_sha256'])): - raise WorkbenchError('Target metric definition requires an exact SHA256 or explicit unknown.') - if target['direction'] not in ('minimize', 'maximize', 'target'): - raise WorkbenchError('Target direction is unsupported.') - if target['threshold'] is not None and ( - type(target['threshold']) not in (int, float) or not math.isfinite(target['threshold']) or - abs(target['threshold']) > 2**53-1): - raise WorkbenchError('Target threshold must be a finite safe number or explicit unknown.') - text(target['acceptance'], 'Acceptance intent', 2000) - return value + return validate_retained_intent(value, resolve=self._ref) def _version(self): # Both populations are append-only through their owning APIs. Live Run @@ -85,32 +120,23 @@ def _record(self, trial_id): row = self.db.execute('SELECT parent_trial_id,baseline_trial_id,document FROM model_trial_lineage ' 'WHERE trial_id=%s', (trial_id,)).fetchone() if row is None: - return None + adapter=self.studies._project() + return adapter.lineage_fact(trial_id) if adapter is not None else None from model_definitions import retained_json value = retained_json(row['document'], INTENT_LIMIT + 4096) - closed(value, ('format', 'trial', 'intent', 'actor_id', 'recorded_at', 'timing')) - if value['format'] != 'trial_lineage_v1' or value['trial']['trial_id'] != trial_id: - raise WorkbenchError('Stored lineage identity differs.', 'unavailable', 409) - self._ref(value['trial']) - intent = self._intent(value['intent']) + value = validate_retained_lineage(value, trial=self.studies.trial(trial_id), resolve=self._ref) + intent = value['intent'] if (row['parent_trial_id'] != (intent['parent'] or {}).get('trial_id') or - row['baseline_trial_id'] != (intent['baseline'] or {}).get('trial_id') or - value['timing'] not in ('before_admission', 'retrospective')): + row['baseline_trial_id'] != (intent['baseline'] or {}).get('trial_id')): raise WorkbenchError('Stored lineage relationships differ.', 'unavailable', 409) return value def _acyclic(self, child, parent): - seen = {child} - for _ in range(ANCESTOR_LIMIT): - if parent is None: - return - if parent in seen: - raise WorkbenchError('Derivation would contain a cycle.', 'conflict', 409) - seen.add(parent) + def parent_of(trial_id): row = self.db.execute('SELECT parent_trial_id FROM model_trial_lineage WHERE trial_id=%s', - (parent,)).fetchone() - parent = row[0] if row is not None else None - raise WorkbenchError('Ancestry exceeds the bounded inspection depth.', 'unsupported', 409) + (trial_id,)).fetchone() + return row[0] if row is not None else None + return validate_retained_ancestry(child, parent, parent_of=parent_of) def _insert(self, trial, intent): intent = self._intent(intent) @@ -131,6 +157,7 @@ def _insert(self, trial, intent): (intent['baseline'] or {}).get('trial_id'), encode(value))) self.access.audit('studies.lineage.record', child, {'material_sha256': trial['material']['sha256'], 'intent_sha256': digest(intent)}) + self.studies._mark(trial['study_id']) return value @authorized('edit', 'studies.record_lineage') diff --git a/experiments.py b/experiments.py index 725f29ac..ae7b619a 100644 --- a/experiments.py +++ b/experiments.py @@ -30,38 +30,230 @@ def request_key(value): return value +def validate_retained_policy(value): + """Selection meaning shared by active Studies and inert history.""" + closed(value,('format','metric_definition_sha256','checkpoint_rule','replicate_reduce', + 'required_capabilities','numerical_acceptance','tie_break')) + if (value['format']!='selection_policy_v1' or type(value['metric_definition_sha256']) is not str or + not SHA.fullmatch(value['metric_definition_sha256']) or value['checkpoint_rule'] not in ('selected','last','explicit') or + value['replicate_reduce'] not in ('individual','mean') or value['numerical_acceptance'] not in ('required','diagnostic') or + value['tie_break']!='trial_id' or type(value['required_capabilities']) is not list or + len(value['required_capabilities'])>64): + raise WorkbenchError('Unsupported selection policy.') + for item in value['required_capabilities']:text(item,'Required capability',64) + if len(set(value['required_capabilities']))!=len(value['required_capabilities']): + raise WorkbenchError('Duplicate required capability.') + return value + + +def validate_retained_study(value, *, catalog_id, membership, policy): + """Validate saved intent and immutable membership without admission authority.""" + value=checked_json(value,MAX_STUDY_DOCUMENT) + closed(value,('format','id','revision','name','admission_epoch','open','state','limits','policy', + 'membership_revision','trial_ids','stop_receipt_id')) + identifier(value['id']) + if (value['format']!='study_v1' or type(value['open']) is not bool or + value['state'] not in ('draft','open','stopping','stopped','unknown') or + value['open']!=(value['state']=='open')): + raise WorkbenchError('Stored study identity/lifecycle differs.','unavailable',409) + natural(value['revision'],'Study revision',1);natural(value['admission_epoch'],'Epoch') + natural(value['membership_revision'],'Membership revision',1);text(value['name'],'Study name') + budget(value['limits']);reference(value['policy'],'selection_policy',catalog_id) + validate_retained_policy(policy) + if digest(policy)!=value['policy']['sha256']: + raise WorkbenchError('Selection policy identity differs.','unavailable',409) + if type(value['trial_ids']) is not list or len(value['trial_ids'])>10000: + raise WorkbenchError('Stored study membership differs.','unavailable',409) + for trial_id in value['trial_ids']:identifier(trial_id) + if len(set(value['trial_ids']))!=len(value['trial_ids']): + raise WorkbenchError('Duplicate trial membership.','unavailable',409) + if value['stop_receipt_id'] is not None:identifier(value['stop_receipt_id']) + if membership!={'trial_ids':value['trial_ids']}: + raise WorkbenchError('Stored immutable membership differs.','unavailable',409) + return value + + +def validate_retained_trial(value, *, catalog_id): + value=checked_json(value) + closed(value,('format','id','study_id','material','replicate')) + identifier(value['id']);identifier(value['study_id']) + reference(value['material'],'recipe_material',catalog_id);natural(value['replicate'],'Replicate') + if value['format']!='trial_v1': + raise WorkbenchError('Stored trial identity differs.','unavailable',409) + return value + + +def validate_retained_reservation(value, *, trial, composition, run): + value=checked_json(value) + closed(value,('id','study_id','admission_epoch','request_key','actor_id','trial_id','parent_run_id','run_id', + 'request_sha256','material_sha256','envelope','state','settled')) + for key in ('id','study_id','actor_id','trial_id','run_id'):identifier(value[key]) + request_key(value['request_key']) + for key in ('request_sha256','material_sha256'): + if type(value[key]) is not str or not SHA.fullmatch(value[key]): + raise WorkbenchError('Reservation digest differs.','unavailable',409) + budget(value['envelope']);natural(value['admission_epoch'],'Reservation epoch') + if value['parent_run_id'] is not None:identifier(value['parent_run_id']) + if value['state'] not in ('reserved','running','settled','unknown'): + raise WorkbenchError('Reservation state differs.','unavailable',409) + if value['state']=='settled': + if value['settled']!=value['envelope']: + raise WorkbenchError('Conservative settled charge differs.','unavailable',409) + elif value['settled'] is not None: + raise WorkbenchError('Open reservation cannot contain a settled charge.','unavailable',409) + if (trial['id']!=value['trial_id'] or trial['study_id']!=value['study_id'] or + value['material_sha256']!=trial['material']['sha256'] or + value['envelope']!=composition['material']['resource_envelope']): + raise WorkbenchError('Reservation material/envelope differs.','unavailable',409) + if (run['id']!=value['run_id'] or run['actor_id']!=value['actor_id'] or + run['request_sha256']!=value['request_sha256'] or run['parent_run_id']!=value['parent_run_id'] or + run.get('approval_material_sha256')!=value['material_sha256']): + raise WorkbenchError('Reservation and Run receipt differ.','unavailable',409) + return value + + +def validate_retained_candidate(value, *, catalog_id, trial, policy, reservation): + value=checked_json(value) + closed(value,('format','id','study_id','trial_id','run_id','checkpoint_sha256','evaluation_sha256', + 'policy','decision','limitations')) + for key in ('id','study_id','trial_id','run_id'):identifier(value[key]) + if value['format']!='candidate_v1' or value['decision']!='diagnostic': + raise WorkbenchError('Stored candidate identity differs.','unavailable',409) + for key in ('checkpoint_sha256','evaluation_sha256'): + if type(value[key]) is not str or not SHA.fullmatch(value[key]): + raise WorkbenchError('Stored candidate evidence identity differs.','unavailable',409) + if type(value['limitations']) is not list or len(value['limitations'])>64: + raise WorkbenchError('Stored candidate evidence identity differs.','unavailable',409) + for item in value['limitations']:text(item,'Candidate limitation',500) + reference(value['policy'],'selection_policy',catalog_id);validate_retained_policy(policy) + if (digest(policy)!=value['policy']['sha256'] or trial['id']!=value['trial_id'] or + trial['study_id']!=value['study_id'] or reservation['trial_id']!=trial['id'] or + reservation['run_id']!=value['run_id']): + raise WorkbenchError('Candidate Run association differs.','unavailable',409) + return value + + +def select_retained_checkpoint(evidence, policy, requested): + if policy['replicate_reduce']!='individual': + raise WorkbenchError('Mean-replicate candidate selection is not qualified.','unsupported',409) + import model_evidence + definitions=evidence['metric_definitions'] + matches=[item for item in definitions if model_evidence.digest(item)==policy['metric_definition_sha256']] + if len(matches)!=1:raise WorkbenchError('Selection metric differs from the retained evaluator definition.','incompatible',409) + coverage=evidence['manifest']['coverage'] + available=set(coverage['required']+coverage['optional'])-{item['capability'] for item in coverage['omitted']} + if not set(policy['required_capabilities'])<=available: + raise WorkbenchError('Candidate lacks required evidence capabilities.','unsupported',409) + entries=evidence['checkpoint_index']['entries'] + rule=policy['checkpoint_rule'] + if rule=='explicit': + chosen=[item for item in entries if item['checkpoint']['sha256']==requested] + else: + chosen=[item for item in entries if item['selected']] if rule=='selected' else entries[-1:] + if requested is not None and (len(chosen)!=1 or chosen[0]['checkpoint']['sha256']!=requested): + raise WorkbenchError('Explicit checkpoint contradicts the selection policy.') + if len(chosen)!=1:raise WorkbenchError('Selection requires one retained checkpoint.') + checkpoint=chosen[0]['checkpoint'] + observed=[item for item in evidence['observations'] if item['checkpoint_sha256']==checkpoint['sha256'] + and item['metric_id']==matches[0]['id'] and item['state']=='observed'] + if len(observed)!=1:raise WorkbenchError('Selection requires a defined metric at this checkpoint.','unavailable',409) + # A successful classifier fit supplies diagnostics, never an invented + # model-quality threshold or physical acceptance result. + if policy['numerical_acceptance']=='required': + raise WorkbenchError('This evaluator has no qualified numerical acceptance criterion.','unsupported',409) + return checkpoint + + +def reservation_totals(reservations): + """Conservative obligation accounting shared by live and inert Studies.""" + totals={key:0 for key in DIMENSIONS} + for value in reservations: + charge=value['settled'] if value['state']=='settled' else value['envelope'] + if charge is None:raise WorkbenchError('Settled reservation lacks a charge.','unavailable',409) + for key in DIMENSIONS:totals[key]=natural(totals[key]+charge[key],'Study total') + return totals + + +def validate_candidate_evidence(evidence,trial): + if evidence.get('state')!='verified': + raise WorkbenchError('Candidate requires verified model evidence.','unsupported',409) + if evidence['manifest']['producer']['material_sha256']!=trial['material']['sha256']: + raise WorkbenchError('Candidate evidence differs from its trial material.','unavailable',409) + return evidence + + +def new_study_document(body, *, catalog_id, study_id, policy_id): + """Construct authored Study intent; no SQL, approval or lifecycle effects.""" + closed(body,('name','limits','policy'));body=checked_json(body) + policy=validate_retained_policy(body['policy']);limits=budget(body['limits']);text(body['name'],'Study name') + identifier(study_id);identifier(policy_id) + ref={'kind':'selection_policy','catalog_id':catalog_id,'id':policy_id,'revision':1,'sha256':digest(policy)} + return {'format':'study_v1','id':study_id,'revision':1,'name':body['name'],'admission_epoch':0, + 'open':False,'state':'draft','limits':limits,'policy':ref,'membership_revision':1,'trial_ids':[], + 'stop_receipt_id':None} + + +def edit_study_document(study, body, *, policy, charged, catalog_id): + closed(body,('revision','name','limits','policy'));body=checked_json(body) + natural(body['revision'],'Study revision',1);text(body['name'],'Study name');limits=budget(body['limits']) + if body['revision']!=study['revision']:raise WorkbenchError('Study changed. Reload.','stale',409) + ref=reference(body['policy'],'selection_policy',catalog_id);validate_retained_policy(policy) + if digest(policy)!=ref['sha256']:raise WorkbenchError('Selection policy identity differs.','unavailable',409) + if any(charged[key]>limits[key] for key in DIMENSIONS): + raise WorkbenchError('Limits are below existing obligations.','conflict',409) + value=copy.deepcopy(study);value.update(name=body['name'],limits=limits,policy=ref) + return value + + +def new_trial_document(study, material, replicate, *, trial_id): + natural(replicate,'Replicate');identifier(trial_id) + if len(study['trial_ids'])>=10000:raise WorkbenchError('Study membership is full.','unavailable',409) + return {'format':'trial_v1','id':trial_id,'study_id':study['id'],'material':copy.deepcopy(material),'replicate':replicate} + + +def new_candidate_document(study, trial, run, checkpoint, evaluation, *, candidate_id): + identifier(candidate_id) + if trial['study_id']!=study['id']:raise WorkbenchError('Trial belongs to another study.') + if run['state']!='completed':raise WorkbenchError('Candidate Run is not completed.','conflict',409) + if evaluation is None:raise WorkbenchError('Candidate evaluation artifact is unavailable.','unavailable',409) + return {'format':'candidate_v1','id':candidate_id,'study_id':study['id'], + 'trial_id':trial['id'],'run_id':run['id'],'checkpoint_sha256':checkpoint['sha256'], + 'evaluation_sha256':evaluation['sha256'],'policy':copy.deepcopy(study['policy']),'decision':'diagnostic', + 'limitations':['Validation diagnostics only; no quality threshold or physical acceptance is qualified.']} + + class Experiments: def __init__(self,dataset): self.dataset=dataset;self.db=dataset.db;self.lock=dataset.lock;self.access=dataset.access from experiment_lineage import TrialLineage self.lineage=TrialLineage(self) + def _project(self): + return getattr(self.dataset,'project_study_execution',None) + + def _mark(self,study_id): + adapter=self._project() + execution=getattr(self.dataset,'project_execution',None) + if execution is not None:execution.mark_study(study_id) + elif adapter is not None:adapter.mark_study(study_id) + + def _run_fact(self,run_id): + adapter=self._project() + return adapter.run_fact(run_id) if adapter is not None else self.dataset.runs._load(run_id) + def _study(self,study_id): identifier(study_id) row=self.db.execute('SELECT revision,admission_epoch,document FROM model_studies WHERE id=%s',(study_id,)).fetchone() if row is None:raise WorkbenchError('Study not found.','not_found',404) value=retained_json(row['document'],MAX_STUDY_DOCUMENT) - closed(value,('format','id','revision','name','admission_epoch','open','state','limits','policy', - 'membership_revision','trial_ids','stop_receipt_id')) - if (value['format']!='study_v1' or value['id']!=study_id or value['revision']!=row['revision'] or - value['admission_epoch']!=row['admission_epoch'] or type(value['open']) is not bool or - value['state'] not in ('draft','open','stopping','stopped','unknown') or - value['open']!=(value['state']=='open')): + if (value.get('id')!=study_id or value.get('revision')!=row['revision'] or + value.get('admission_epoch')!=row['admission_epoch']): raise WorkbenchError('Stored study identity/lifecycle differs.','unavailable',409) - natural(value['revision'],'Study revision',1);natural(value['admission_epoch'],'Epoch') - natural(value['membership_revision'],'Membership revision',1);text(value['name'],'Study name') - budget(value['limits']);reference(value['policy'],'selection_policy',self.db.dataset_id) - self.policy(value['policy']) - if type(value['trial_ids']) is not list or len(value['trial_ids'])>10000: - raise WorkbenchError('Stored study membership differs.','unavailable',409) - for trial_id in value['trial_ids']:identifier(trial_id) - if len(set(value['trial_ids']))!=len(value['trial_ids']):raise WorkbenchError('Duplicate trial membership.','unavailable',409) - if value['stop_receipt_id'] is not None:identifier(value['stop_receipt_id']) membership=self.db.execute('SELECT document FROM model_study_memberships WHERE study_id=%s AND revision=%s', - (study_id,value['membership_revision'])).fetchone() - if membership is None or retained_json(membership[0],MAX_STUDY_DOCUMENT)!={'trial_ids':value['trial_ids']}: - raise WorkbenchError('Stored immutable membership differs.','unavailable',409) - return value + (study_id,value.get('membership_revision'))).fetchone() + return validate_retained_study(value,catalog_id=self.db.dataset_id, + membership=retained_json(membership[0],MAX_STUDY_DOCUMENT) if membership else None, + policy=self.policy(value['policy'])) def _write(self,value,action,details=None): value['revision']=natural(value['revision']+1,'Study revision',1) @@ -69,6 +261,7 @@ def _write(self,value,action,details=None): self.db.execute('UPDATE model_studies SET revision=%s,admission_epoch=%s,document=%s WHERE id=%s', (value['revision'],value['admission_epoch'],encode(value),value['id'])) self.access.audit(action,value['id'],dict(details or {},revision=value['revision'],epoch=value['admission_epoch'])) + self._mark(value['id']) return value def _expected(self,value,body): @@ -87,18 +280,7 @@ def policy(self,ref): @staticmethod def _policy(value): - closed(value,('format','metric_definition_sha256','checkpoint_rule','replicate_reduce', - 'required_capabilities','numerical_acceptance','tie_break')) - if (value['format']!='selection_policy_v1' or type(value['metric_definition_sha256']) is not str or - not SHA.fullmatch(value['metric_definition_sha256']) or value['checkpoint_rule'] not in ('selected','last','explicit') or - value['replicate_reduce'] not in ('individual','mean') or value['numerical_acceptance'] not in ('required','diagnostic') or - value['tie_break']!='trial_id' or type(value['required_capabilities']) is not list or - len(value['required_capabilities'])>64): - raise WorkbenchError('Unsupported selection policy.') - for item in value['required_capabilities']:text(item,'Required capability',64) - if len(set(value['required_capabilities']))!=len(value['required_capabilities']): - raise WorkbenchError('Duplicate required capability.') - return value + return validate_retained_policy(value) @authorized('edit','studies.create',owner_only=True) def create(self,body): @@ -110,13 +292,11 @@ def create(self,body): prior=self.db.execute('SELECT id,revision FROM model_selection_policies WHERE sha256=%s',(policy_sha,)).fetchone() policy_id=prior['id'] if prior else uuid.uuid4().hex if not prior:self.db.execute('INSERT INTO model_selection_policies VALUES(%s,%s,%s,%s)',(policy_id,1,policy_sha,encode(policy))) - ref={'kind':'selection_policy','catalog_id':self.db.dataset_id,'id':policy_id,'revision':1,'sha256':policy_sha} - value={'format':'study_v1','id':study_id,'revision':1,'name':body['name'],'admission_epoch':0, - 'open':False,'state':'draft','limits':limits,'policy':ref,'membership_revision':1,'trial_ids':[], - 'stop_receipt_id':None} + value=new_study_document(body,catalog_id=self.db.dataset_id,study_id=study_id,policy_id=policy_id) self.db.execute('INSERT INTO model_studies VALUES(%s,%s,%s,%s)',(study_id,1,0,encode(value))) self.db.execute('INSERT INTO model_study_memberships VALUES(%s,%s,%s)',(study_id,1,encode({'trial_ids':[]}))) self.access.audit('studies.create',study_id,{'policy_sha256':policy_sha,'limits':limits}) + self._mark(study_id) return value @authorized('inspect','studies.trial') @@ -124,9 +304,8 @@ def trial(self,trial_id): identifier(trial_id) row=self.db.execute('SELECT study_id,material_sha256,replicate,document FROM model_trials WHERE id=%s',(trial_id,)).fetchone() if row is None:raise WorkbenchError('Trial not found.','not_found',404) - value=retained_json(row['document']);closed(value,('format','id','study_id','material','replicate')) - reference(value['material'],'recipe_material',self.db.dataset_id);natural(value['replicate'],'Replicate') - if (value['format']!='trial_v1' or value['id']!=trial_id or value['study_id']!=row['study_id'] or + value=validate_retained_trial(retained_json(row['document']),catalog_id=self.db.dataset_id) + if (value['id']!=trial_id or value['study_id']!=row['study_id'] or value['replicate']!=row['replicate'] or value['material']['sha256']!=row['material_sha256']): raise WorkbenchError('Stored trial identity differs.','unavailable',409) return value @@ -159,7 +338,7 @@ def prepare_trial(self,study_id,body,*,_publish=None,_graph_guard=None, prior=self.db.execute('SELECT id FROM model_trials WHERE study_id=%s AND material_sha256=%s AND replicate=%s', (study_id,material['ref']['sha256'],body['replicate'])).fetchone() if prior:raise WorkbenchError('Material/replicate already belongs to this study.','conflict',409) - value={'format':'trial_v1','id':uuid.uuid4().hex,'study_id':study_id,'material':material['ref'],'replicate':body['replicate']} + value=new_trial_document(study,material['ref'],body['replicate'],trial_id=uuid.uuid4().hex) self.db.execute('INSERT INTO model_trials VALUES(%s,%s,%s,%s,%s)', (value['id'],study_id,material['ref']['sha256'],value['replicate'],encode(value))) study['trial_ids'].append(value['id']);study['membership_revision']+=1 @@ -185,44 +364,34 @@ def _receipt(self,actor,operation,key,sha): def _save_receipt(self,actor,operation,key,sha,value): value=checked_json(value,MAX_STUDY_DOCUMENT) self.db.execute('INSERT INTO model_operation_receipts VALUES(%s,%s,%s,%s,%s)',(actor,operation,key,sha,encode(value))) + adapter=self._project() + if adapter is not None:adapter.mark_operation(operation,value) def _reservation(self,row): value=retained_json(row['document']) - closed(value,('id','study_id','admission_epoch','request_key','actor_id','trial_id','parent_run_id','run_id', - 'request_sha256','material_sha256','envelope','state','settled')) for key in ('id','study_id','actor_id','request_key','request_sha256','trial_id','run_id'): - if value[key]!=row[key]:raise WorkbenchError('Reservation identity differs.','unavailable',409) - budget(value['envelope']);natural(value['admission_epoch'],'Reservation epoch') - if value['state'] not in ('reserved','running','settled','unknown'): - raise WorkbenchError('Reservation state differs.','unavailable',409) - if value['state']=='settled': - if value['settled']!=value['envelope']: - raise WorkbenchError('Conservative settled charge differs.','unavailable',409) - elif value['settled'] is not None: - raise WorkbenchError('Open reservation cannot contain a settled charge.','unavailable',409) + if value.get(key)!=row[key]:raise WorkbenchError('Reservation identity differs.','unavailable',409) trial=self.trial(value['trial_id']) - material=self.dataset.pipelines.recipe_material(trial['material']) - if (trial['study_id']!=value['study_id'] or value['material_sha256']!=trial['material']['sha256'] or - value['envelope']!=material['material']['resource_envelope']): - raise WorkbenchError('Reservation material/envelope differs.','unavailable',409) - run=self.dataset.runs._load(value['run_id']) - if (run['actor_id']!=value['actor_id'] or run['request_sha256']!=value['request_sha256'] or - run['parent_run_id']!=value['parent_run_id'] or - run.get('approval_material_sha256')!=value['material_sha256']): - raise WorkbenchError('Reservation and Run receipt differ.','unavailable',409) - return value - - def _reservations(self,study_id): + adapter=self._project() + composition=(adapter.materials.recipe(trial['material']) if adapter is not None + else self.dataset.pipelines.recipe_material(trial['material'])) + return validate_retained_reservation(value,trial=trial, + composition=composition, + run=self._run_fact(value['run_id'])) + + def _local_reservations(self,study_id): return [self._reservation(row) for row in self.db.execute( 'SELECT * FROM model_attempt_reservations WHERE study_id=%s ORDER BY id',(study_id,))] + def _reservations(self,study_id): + local=self._local_reservations(study_id);adapter=self._project() + if adapter is None:return local + values={value['run_id']:value for value in adapter.historical_reservations(study_id)} + values.update((value['run_id'],value) for value in local) + return sorted(values.values(),key=lambda value:value['id']) + def _totals(self,reservations): - totals={key:0 for key in DIMENSIONS} - for value in reservations: - charge=value['settled'] if value['state']=='settled' else value['envelope'] - if charge is None:raise WorkbenchError('Settled reservation lacks a charge.','unavailable',409) - for key in DIMENSIONS:totals[key]=natural(totals[key]+charge[key],'Study total') - return totals + return reservation_totals(reservations) @authorized('inspect','studies.get') def get(self,study_id,*,offset=0,limit=100,revision=None): @@ -257,15 +426,16 @@ def save(self,study_id,body): self.access.require('edit','studies.save',study_id,owner_only=True) study=self._study(study_id);self._expected(study,body);self.policy(body['policy']) totals=self._totals(self._reservations(study_id)) - if any(totals[key]>limits[key] for key in DIMENSIONS): - raise WorkbenchError('Limits are below existing obligations.','conflict',409) - study.update(name=body['name'],limits=limits,policy=body['policy']) + study=edit_study_document(study,body,policy=self.policy(body['policy']),charged=totals,catalog_id=self.db.dataset_id) return self._write(study,'studies.save') def _children_ceased(self,reservations): """Use owned Run custody; no PID signalling or optimistic terminal inference.""" unknown=False;active=False + adapter=self._project() + local_ids=None if adapter is None else adapter.local_run_ids() for reservation in reservations: + if local_ids is not None and reservation['run_id'] not in local_ids:continue run=self.dataset.runs._load(reservation['run_id']) if run['state']=='unknown':unknown=True elif run['state'] in ACTIVE:active=True @@ -291,13 +461,15 @@ def continue_study(self,study_id,body): if receipt:return receipt study=self._study(study_id);self._expected(study,body) reservations=self._reservations(study_id);ceased,unknown=self._children_ceased(reservations) - if (study['open'] or study['admission_epoch']!=body['admission_epoch'] or not ceased or unknown or - any(item['state']=='unknown' for item in reservations)): + adapter=self._project() + already_open=study['open'] and (adapter is None or adapter.epoch_current(study)) + if (already_open or study['admission_epoch']!=body['admission_epoch'] or not ceased or unknown): raise WorkbenchError('Study cannot open before known child cessation and exact epoch.','unavailable',409) totals=self._totals(reservations) if any(totals[key]>=study['limits'][key] for key in DIMENSIONS): raise WorkbenchError('Study has no remaining budget.','unavailable',409) - if self.dataset.runs._external_blocked:raise WorkbenchError('Run custody is unknown.','unavailable',409) + blocked=(adapter.external_blocked() if adapter is not None else self.dataset.runs._external_blocked) + if blocked:raise WorkbenchError('Run custody is unknown.','unavailable',409) study.update(open=True,state='open',admission_epoch=natural(study['admission_epoch']+1,'Admission epoch')) value=self._write(study,'studies.continue') self._save_receipt(actor,'studies.continue',body['request_key'],sha,value) @@ -321,8 +493,11 @@ def approve(self,study_id,body): with self.lock,self.db.transaction(): self.access.require('approve-execution','studies.approve',study_id,owner_only=True) grant=self.access.grant_execution(trial['material']['sha256'],body['consumer_actor_id'],body.get('ttl_seconds',600)) - self.access.audit('studies.approve',study_id,{'trial_id':trial['id'],'material_sha256':trial['material']['sha256'], - 'approval_id':grant['id']}) + details={'trial_id':trial['id'],'material_sha256':trial['material']['sha256'],'approval_id':grant['id']} + if self._project() is not None: + details.update({key:grant[key] for key in ('consumer_actor_id','created_at','expires_at')}) + self.access.audit('studies.approve',study_id,details) + self._mark(study_id) return grant @authorized('inspect','studies.admit') @@ -362,17 +537,19 @@ def admit(self,study_id,body,*,selection=None): if receipt: self.db.on_commit(lambda:shutil.rmtree(staging,ignore_errors=True));return receipt study=self._study(study_id);self._expected(study,body) + adapter=self._project() + if adapter is not None:adapter.require_current_epoch(study) if not study['open'] or study['admission_epoch']!=body['admission_epoch']: raise WorkbenchError('Study admission epoch is closed.','stale',409) reservations=self._reservations(study_id);ceased,unknown=self._children_ceased(reservations) - if not ceased or unknown or any(item['state']=='unknown' for item in reservations): + if not ceased or unknown: raise WorkbenchError('Study already has an active or unknown child.','unavailable',409) totals=self._totals(reservations) if any(natural(totals[key]+envelope[key],'Requested total')>study['limits'][key] for key in DIMENSIONS): raise WorkbenchError('Finite study budget is exhausted.','unavailable',409) parent=None if body.get('parent_run_id') is not None: - identifier(body['parent_run_id']);parent=self.dataset.runs._load(body['parent_run_id']) + identifier(body['parent_run_id']);parent=self._run_fact(body['parent_run_id']) if not any(item['run_id']==parent['id'] and item['trial_id']==trial['id'] for item in reservations): raise WorkbenchError('Retry parent does not belong to this trial.') if parent['state'] not in ('failed','cancelled','interrupted'): @@ -411,17 +588,19 @@ def admit(self,study_id,body,*,selection=None): def _admission_receipt(self,study_id,actor,key,sha): row=self.db.execute('SELECT * FROM model_attempt_reservations WHERE study_id=%s AND request_key=%s', (study_id,key)).fetchone() - if row is None:return None + if row is None: + adapter=self._project() + if adapter is not None:adapter.reject_historical_request(study_id,actor,key,sha) + return None if row['actor_id']!=actor or row['request_sha256']!=sha: raise WorkbenchError('Admission key is bound to different actor/inputs.','conflict',409) reservation=self._reservation(row) return {'reservation':reservation,'run':self.dataset.runs.get(reservation['run_id'])} - @authorized('inspect','studies.reconcile') - def reconcile(self,study_id): - """Explicit accounting reconciliation; never wake or replay a Run.""" + def _reconcile_local(self,study_id): + """Observe only this runtime's reservation rows; never replay history.""" with self.lock,self.db.transaction(): - study=self._study(study_id);reservations=self._reservations(study_id);changed=False + study=self._study(study_id);reservations=self._local_reservations(study_id);changed=False for value in reservations: run=self.dataset.runs._load(value['run_id']) state='unknown' if run['state']=='unknown' else 'running' if run['state'] in ACTIVE else 'settled' @@ -442,8 +621,12 @@ def reconcile(self,study_id): if changed:self._write(study,'studies.reconcile') return self.get(study_id) - @authorized('cancel','studies.stop') - def stop(self,study_id,body): + @authorized('inspect','studies.reconcile') + def reconcile(self,study_id): + return self._reconcile_local(study_id) + + def _close_admission(self,study_id,body): + """Close one exact epoch, retaining the original bounded stop receipt.""" closed(body,('request_key','revision'));request_key(body['request_key']) actor=self.access._context().actor_id;sha=digest({'study_id':study_id,'body':body}) with self.lock,self.db.transaction(): @@ -455,12 +638,18 @@ def stop(self,study_id,body): stop_receipt_id=uuid.uuid4().hex) self._write(study,'studies.stop') pending=[] - for item in self._reservations(study_id): + for item in self._local_reservations(study_id): if not self._ceased(self.dataset.runs._load(item['run_id'])):pending.append(item['run_id']) receipt={'study_id':study_id,'stop_receipt_id':study['stop_receipt_id'],'admission_epoch':study['admission_epoch'], 'run_ids':pending} self._save_receipt(actor,'studies.stop',body['request_key'],sha,receipt) - # Epoch closure has acknowledged commit before any child cancellation. + return receipt + + @authorized('cancel','studies.stop') + def stop(self,study_id,body): + receipt=self._close_admission(study_id,body) + # A legacy standalone owner commits its epoch before signalling. The + # project adapter publishes this same closure before its cancel phase. for run_id in receipt['run_ids']:self.dataset.runs.cancel(run_id) return {'receipt':receipt,'status':self.reconcile(study_id)} @@ -481,9 +670,7 @@ def select_candidate(self,study_id,body): raise WorkbenchError('Run does not belong to the selected trial.') # Runs verifies retained bytes and task semantics outside a write transaction. evidence=self.dataset.runs.model_evidence(body['run_id'],{}) - if evidence['state']!='verified':raise WorkbenchError('Candidate requires verified model evidence.','unsupported',409) - if evidence['manifest']['producer']['material_sha256']!=trial['material']['sha256']: - raise WorkbenchError('Candidate evidence differs from its trial material.','unavailable',409) + validate_candidate_evidence(evidence,trial) checkpoint=self._candidate_checkpoint(evidence,policy,body.get('checkpoint_sha256')) with self.lock,self.db.transaction(): self.access.require('edit','studies.select_candidate',study_id) @@ -494,10 +681,7 @@ def select_candidate(self,study_id,body): if run['state']!='completed':raise WorkbenchError('Candidate Run is not completed.','conflict',409) metrics=self._candidate_evaluation(run) if metrics is None:raise WorkbenchError('Candidate evaluation artifact is unavailable.','unavailable',409) - candidate={'format':'candidate_v1','id':uuid.uuid4().hex,'study_id':study_id, - 'trial_id':trial['id'],'run_id':run['id'],'checkpoint_sha256':checkpoint['sha256'], - 'evaluation_sha256':metrics['sha256'],'policy':study['policy'],'decision':'diagnostic', - 'limitations':['Validation diagnostics only; no quality threshold or physical acceptance is qualified.']} + candidate=new_candidate_document(study,trial,run,checkpoint,metrics,candidate_id=uuid.uuid4().hex) self.db.execute('INSERT INTO model_candidates VALUES(%s,%s,%s,%s,%s)', (candidate['id'],study_id,trial['id'],run['id'],encode(candidate))) self.access.audit('studies.select_candidate',candidate['id'],{'study_id':study_id,'run_id':run['id'], @@ -514,34 +698,7 @@ def _candidate_evaluation(self,run): return next((item for item in run['artifacts'] if item['name']==name),None) def _candidate_checkpoint(self,evidence,policy,requested): - if policy['replicate_reduce']!='individual': - raise WorkbenchError('Mean-replicate candidate selection is not qualified.','unsupported',409) - import model_evidence - definitions=evidence['metric_definitions'] - matches=[item for item in definitions if model_evidence.digest(item)==policy['metric_definition_sha256']] - if len(matches)!=1:raise WorkbenchError('Selection metric differs from the retained evaluator definition.','incompatible',409) - coverage=evidence['manifest']['coverage'] - available=set(coverage['required']+coverage['optional'])-{item['capability'] for item in coverage['omitted']} - if not set(policy['required_capabilities'])<=available: - raise WorkbenchError('Candidate lacks required evidence capabilities.','unsupported',409) - entries=evidence['checkpoint_index']['entries'] - rule=policy['checkpoint_rule'] - if rule=='explicit': - chosen=[item for item in entries if item['checkpoint']['sha256']==requested] - else: - chosen=[item for item in entries if item['selected']] if rule=='selected' else entries[-1:] - if requested is not None and (len(chosen)!=1 or chosen[0]['checkpoint']['sha256']!=requested): - raise WorkbenchError('Explicit checkpoint contradicts the selection policy.') - if len(chosen)!=1:raise WorkbenchError('Selection requires one retained checkpoint.') - checkpoint=chosen[0]['checkpoint'] - observed=[item for item in evidence['observations'] if item['checkpoint_sha256']==checkpoint['sha256'] - and item['metric_id']==matches[0]['id'] and item['state']=='observed'] - if len(observed)!=1:raise WorkbenchError('Selection requires a defined metric at this checkpoint.','unavailable',409) - # A successful classifier fit supplies diagnostics, never an invented - # model-quality threshold or physical acceptance result. - if policy['numerical_acceptance']=='required': - raise WorkbenchError('This evaluator has no qualified numerical acceptance criterion.','unsupported',409) - return checkpoint + return select_retained_checkpoint(evidence,policy,requested) def _candidate_result(self,value,checkpoint): return {'ref':{'kind':'candidate','catalog_id':self.db.dataset_id,'id':value['id'],'sha256':digest(value)}, @@ -568,30 +725,22 @@ def candidates(self,run_id,*,cursor=None,limit=20): @authorized('inspect','studies.candidate') def candidate(self,candidate_id): identifier(candidate_id) + adapter=self._project() + if adapter is not None:return adapter.candidate(candidate_id) with self.lock: row=self.db.execute('SELECT * FROM model_candidates WHERE id=%s',(candidate_id,)).fetchone() if row is None:raise WorkbenchError('Candidate not found.','not_found',404) value=retained_json(row['document']) - closed(value,('format','id','study_id','trial_id','run_id','checkpoint_sha256','evaluation_sha256', - 'policy','decision','limitations')) - if (value['format']!='candidate_v1' or any(value[key]!=row[key] for key in ('id','study_id','trial_id','run_id')) - or value['decision']!='diagnostic'): + if any(value.get(key)!=row[key] for key in ('id','study_id','trial_id','run_id')): raise WorkbenchError('Stored candidate identity differs.','unavailable',409) - if (type(value['checkpoint_sha256']) is not str or not SHA.fullmatch(value['checkpoint_sha256']) - or type(value['evaluation_sha256']) is not str or not SHA.fullmatch(value['evaluation_sha256']) - or type(value['limitations']) is not list or len(value['limitations'])>64): - raise WorkbenchError('Stored candidate evidence identity differs.','unavailable',409) - for item in value['limitations']:text(item,'Candidate limitation',500) - reference(value['policy'],'selection_policy',self.db.dataset_id);policy=self.policy(value['policy']) - trial=self.trial(value['trial_id']) - if trial['study_id']!=value['study_id']:raise WorkbenchError('Candidate trial differs.','unavailable',409) + policy=self.policy(value['policy']);trial=self.trial(value['trial_id']) reservation=self.db.execute('SELECT * FROM model_attempt_reservations WHERE run_id=%s',(value['run_id'],)).fetchone() - if reservation is None or self._reservation(reservation)['trial_id']!=trial['id']: + if reservation is None: raise WorkbenchError('Candidate Run association differs.','unavailable',409) + value=validate_retained_candidate(value,catalog_id=self.db.dataset_id,trial=trial, + policy=policy,reservation=self._reservation(reservation)) evidence=self.dataset.runs.model_evidence(value['run_id'],{'checkpoint_sha256':value['checkpoint_sha256']}) - if evidence['state']!='verified':raise WorkbenchError('Candidate evidence is unavailable.','unavailable',409) - if evidence['manifest']['producer']['material_sha256']!=trial['material']['sha256']: - raise WorkbenchError('Candidate evidence material differs.','unavailable',409) + validate_candidate_evidence(evidence,trial) checkpoint=self._candidate_checkpoint(evidence,policy,value['checkpoint_sha256']) run=self.dataset.runs.get(value['run_id']) evaluation=self._candidate_evaluation(run) diff --git a/extension_bindings.py b/extension_bindings.py index f042fb60..1f6eab5e 100644 --- a/extension_bindings.py +++ b/extension_bindings.py @@ -50,7 +50,7 @@ def resolve(projects, project_id, expected_project_manifest_sha256, selections): return result -def _resolve(saved, manifest_raw, selections, read_file): +def _resolve(saved, manifest_raw, selections, read_file, *, historical=False): project_id = saved['manifest']['id'] expected_project_manifest_sha256 = contract.digest(manifest_raw) requested = [_reference(value) for value in selections] @@ -100,7 +100,7 @@ def include(key, depth=0): if depth > contract.MAX_DEPTH or len(closure) + len(visiting) >= MAX_BINDINGS: raise WorkbenchError('Contribution dependency closure exceeds its bounds.', 'too_large', 400) available = providers.get(key, ()) - choices = [choice for choice in available if choice[0]['host']['state'] == 'compatible'] + choices = list(available) if historical else [choice for choice in available if choice[0]['host']['state'] == 'compatible'] if available and not choices: raise WorkbenchError('Contribution provider requires unsupported host contracts.', 'unsupported', 400) if len(choices) != 1: @@ -331,7 +331,7 @@ def validate_configuration(binding, recipe, parameters, *, specification=None, t def validate_captured(binding, blobs): - """Rederive a binding from exact retained bytes, without a live catalog/root.""" + """Rederive historical integrity; live resolve alone qualifies current hosts.""" from projects import manifest, decode try: if not isinstance(binding, dict) or set(binding) != {'format','project','schema_sha256','selections','contributions','inventory'}: @@ -369,7 +369,7 @@ def read(path, limit): raise WorkbenchError('Retained provider file exceeds its bound.', 'too_large', 400) return data result = _resolve({'manifest':value,'manifest_path':project['manifest_path']}, raw, - binding['selections'], read) + binding['selections'], read, historical=True) if encode(result['binding']) != encode(binding) or result['blobs'] != blobs: raise ValueError() return binding diff --git a/extension_evidence.py b/extension_evidence.py index e176f71e..291a644e 100644 --- a/extension_evidence.py +++ b/extension_evidence.py @@ -407,6 +407,20 @@ def read_regular(directory, filename, maximum): @boundary @retained_directory def verify(directory, run, admitted_context, *, captured=None, checkpoint=None): + # Preserve the directory interface's extra-file rejection; content-addressed + # readers instead expose only the exact authoritative Run inventory. + names={item['name'] for item in run['artifacts']} + for output in run['outputs']: + if output['path'] not in names: + try:os.stat(output['path'],dir_fd=directory,follow_symlinks=False) + except FileNotFoundError:continue + raise WorkbenchError('Unretained evidence output exists.','unavailable',409) + return verify_blobs(run,admitted_context,read_blob=lambda name,maximum:read_regular(directory,name,maximum), + captured=captured,checkpoint=checkpoint) + + +@boundary +def verify_blobs(run, admitted_context, *, read_blob, captured=None, checkpoint=None): """Verify structural closure after custody. Caller rederives authoritative context. The returned private array bytes are only for the bounded query below; they @@ -443,13 +457,10 @@ def verify(directory, run, admitted_context, *, captured=None, checkpoint=None): payloads={};total=0 for filename,rule in outputs.items(): guard() - try:raw=read_regular(directory,filename,min(rule['max_bytes'],limits['shard_bytes'])) - except WorkbenchError: - if rule['required']:raise - try:os.stat(filename,dir_fd=directory,follow_symlinks=False) - except FileNotFoundError:pass - else:raise + if filename not in retained: + require(not rule['required'],'Required retained evidence is missing.','unavailable') continue + raw=read_blob(filename,min(rule['max_bytes'],limits['shard_bytes'])) guard() item=retained.get(filename) require(item is not None and item['bytes']==len(raw) and item['sha256']==hashlib.sha256(raw).hexdigest(), diff --git a/grounded_candidates.py b/grounded_candidates.py index c50d1843..e4694439 100644 --- a/grounded_candidates.py +++ b/grounded_candidates.py @@ -74,7 +74,7 @@ def complete(job, stop): return bytes(data) -def decode(data, source, count, typed_request_id=None): +def decode(data, source, count, typed_request_id=None, *, values_only=False): """All-or-nothing candidate validation; this is not a semantic verifier.""" try: if typed_request_id is not None: @@ -92,9 +92,19 @@ def decode(data, source, count, typed_request_id=None): raise ValueError() except (ValueError, KeyError, IndexError, TypeError, AttributeError): raise WorkbenchError('Model must return the complete requested candidate JSON; truncated or malformed output was not admitted.') from None - candidates, seen = [], {source['text']} - for proposal in result['candidates']: - if not isinstance(proposal, dict) or set(proposal) != {'text', 'label', 'evidence'}: + if any(type(value) is not dict or set(value) != {'text', 'label', 'evidence'} for value in result['candidates']): + raise WorkbenchError('Candidate fields must be text, label and evidence.') + values = validate_candidate_values(result['candidates'], source) + if values_only: + return values + return [dict(value, id=uuid.uuid4().hex, status='pending_review', record_id=None, review_note='') for value in values] + + +def validate_candidate_values(proposals, source, *, existing_texts=None): + """Pure source/class/quote policy, shared by wire admission and history.""" + candidates, seen = [], set(existing_texts) if existing_texts is not None else {source['text']} + for proposal in proposals: + if not isinstance(proposal, dict) or not {'text', 'label', 'evidence'} <= set(proposal): raise WorkbenchError('Candidate fields must be text, label and evidence.') text_value(proposal['text'], 'Candidate text', 4000) text = unicodedata.normalize('NFC', proposal['text'].replace('\r\n', '\n').replace('\r', '\n')) @@ -114,11 +124,25 @@ def decode(data, source, count, typed_request_id=None): raise WorkbenchError('Evidence offsets must index captured source Unicode code points.') if span['quote'] != source['text'][start:end]: raise WorkbenchError('Evidence quote does not match the captured source.') - candidates.append({'id': uuid.uuid4().hex, 'text': text, 'label': proposal['label'], 'evidence': evidence, - 'status': 'pending_review', 'record_id': None, 'review_note': ''}) + candidates.append({'text': text, 'label': proposal['label'], 'evidence': evidence}) return candidates +def validate_retained_result(job): + """Validate available response bytes without contacting a provider.""" + encoded = job.get('raw_response_base64') + if encoded is None: + return + raw = base64.b64decode(encoded, validate=True) + if len(raw) > MAX_OUTPUT or hashlib.sha256(raw).hexdigest() != job['response_sha256']: + raise WorkbenchError('Retained grounded response identity differs.') + if job['status'] == 'completed': + values = decode(raw, job['source'], job['config']['count'], job['id'] if pumas.typed(job['config']) else None, values_only=True) + fields = ('text', 'label', 'evidence') + if [{k:v[k] for k in fields} for v in values] != [{k:v[k] for k in fields} for v in job['candidates']]: + raise WorkbenchError('Retained grounded candidates differ from the original response.') + + class Proposals: def __init__(self, workbench): self.workbench = workbench @@ -133,7 +157,7 @@ def __init__(self, workbench): for row in self.db.execute('SELECT data FROM grounded_jobs').fetchall(): job = json.loads(row[0]) if job['status'] in ACTIVE: - if pumas.typed(job['config']): job['provider_outcome'] = 'unknown' + if pumas.typed(job['config']) or getattr(self.workbench.dataset,'project_execution',None) is not None: job['provider_outcome'] = 'unknown' job.update(status='interrupted', error='Server stopped; no automatic retry. Start a new request after inspecting this attempt.') self._save(job) self.access.audit('grounded.reconcile', job['id'], {'status': job['status']}) @@ -144,6 +168,10 @@ def _save(self, job): job['updated_at'] = timestamp() self.db.execute('INSERT INTO grounded_jobs (id, data) VALUES (%s,%s) ON CONFLICT(id) DO UPDATE SET data=excluded.data', (job['id'], encode_retained(job))) + execution = getattr(self.workbench.dataset, 'project_execution', None) + if execution is not None: + execution.mark_grounded(job['id']) + def _get(self, job_id): if not isinstance(job_id, str) or not IDENTIFIER.fullmatch(job_id): raise WorkbenchError('Invalid proposal request ID.') @@ -185,6 +213,18 @@ def _source(self, body): @authorized('execute', 'grounded.start', owner_only=True) def start(self, body): + with self.lock: + if self.db.in_transaction: + raise WorkbenchError('Grounded dispatch requires its own committed transaction.', 'conflict', 409) + with self.db.transaction(): + job, launch = self._prepare_start(body) + if launch: + self._launch(job) + return job + + def _prepare_start(self, body): + if not self.db.in_transaction: + raise RuntimeError('Producer preparation requires an owned transaction.') fields = {'source_id', 'revision', 'source_revision', 'server_url', 'model', 'instruction', 'count', 'seed'} if not isinstance(body, dict) or set(body) not in (fields, fields | {'protocol', 'profile'}): raise WorkbenchError('Unsupported or missing grounded-generation fields.') @@ -195,37 +235,35 @@ def start(self, body): count, seed = body['count'], body['seed'] if type(count) is not int or not 1 <= count <= 10 or (not mode and (type(seed) is not int or not 0 <= seed <= 2**32 - 1)): raise WorkbenchError('Use 1–10 candidates and a nonnegative 32-bit seed.') - with self.lock: - if self.db.in_transaction: - raise WorkbenchError('Grounded dispatch requires its own committed transaction.', 'conflict', 409) + if not self.runtime_owner: + raise WorkbenchError('Grounded workers belong to another open application. Use that application or reopen after it closes.', 'unavailable', 409) + if self.worker is not None and self.worker.is_alive(): + raise WorkbenchError('A grounded request is active. Cancel or wait before starting another.', 'conflict', 409) + source = self._source(body) + job = {'schema_version': 1, 'id': uuid.uuid4().hex, 'revision': 0, 'status': 'preparing', 'error': '', 'created_at': timestamp(), + 'source': source, 'config': {'server_url': base, 'model': model, 'instruction': instruction, 'count': count, 'seed': seed}, + 'system_prompt': SYSTEM_PROMPT, 'prompt_version': PROMPT_VERSION, 'prompt_sha256': hashlib.sha256(SYSTEM_PROMPT.encode()).hexdigest(), + 'candidates': [], 'response_sha256': None, 'raw_response': None, 'raw_response_base64': None, + 'verification': 'Schema and exact source quotes only; class/factual preservation requires human review.'} + job['config'].update(mode) + job['canonical_request_sha256'] = hashlib.sha256(encode(request_payload(job)).encode()).hexdigest() + job['request_digest'] = hashlib.sha256(encode({'config': job['config'], 'source': job['source'], 'payload': request_payload(job)}).encode()).hexdigest() + job['admission_id'] = self.access.admit_job('grounded', job['id'], job['request_digest'], + {'grounded.finish': [job['id']]}) + self.access.audit('grounded.start', job['id'], {'request_digest': job['request_digest']}) + self._save(job) + return job, True + + def _launch(self, job): + self.stop.clear(); self.active_id = job['id'] + self.worker = threading.Thread(target=self._run, args=(job['id'],), daemon=True) + try: + self.worker.start() + except RuntimeError: + self.worker, self.active_id = None, None with self.db.transaction(): - if not self.runtime_owner: - raise WorkbenchError('Grounded workers belong to another open application. Use that application or reopen after it closes.', 'unavailable', 409) - if self.worker is not None and self.worker.is_alive(): - raise WorkbenchError('A grounded request is active. Cancel or wait before starting another.', 'conflict', 409) - source = self._source(body) - job = {'schema_version': 1, 'id': uuid.uuid4().hex, 'revision': 0, 'status': 'preparing', 'error': '', 'created_at': timestamp(), - 'source': source, 'config': {'server_url': base, 'model': model, 'instruction': instruction, 'count': count, 'seed': seed}, - 'system_prompt': SYSTEM_PROMPT, 'prompt_version': PROMPT_VERSION, 'prompt_sha256': hashlib.sha256(SYSTEM_PROMPT.encode()).hexdigest(), - 'candidates': [], 'response_sha256': None, 'raw_response': None, 'raw_response_base64': None, - 'verification': 'Schema and exact source quotes only; class/factual preservation requires human review.'} - job['config'].update(mode) - job['canonical_request_sha256'] = hashlib.sha256(encode(request_payload(job)).encode()).hexdigest() - job['request_digest'] = hashlib.sha256(encode({'config': job['config'], 'source': job['source'], 'payload': request_payload(job)}).encode()).hexdigest() - job['admission_id'] = self.access.admit_job('grounded', job['id'], job['request_digest'], - {'grounded.finish': [job['id']]}) - self.access.audit('grounded.start', job['id'], {'request_digest': job['request_digest']}) + job.update(status='failed', error='Could not start the grounded worker; no inference ran. Start a new request explicitly.') self._save(job) - self.stop.clear(); self.active_id = job['id'] - self.worker = threading.Thread(target=self._run, args=(job['id'],), daemon=True) - try: - self.worker.start() - except RuntimeError: - self.worker, self.active_id = None, None - with self.db.transaction(): - job.update(status='failed', error='Could not start the grounded worker; no inference ran. Start a new request explicitly.') - self._save(job) - return job def _run(self, job_id): with self.lock: @@ -258,10 +296,10 @@ def _run_admitted(self, job): if self.stop.is_set(): raise WorkbenchError('Generation cancelled.', 'cancelled') job.update(status='generating', listed_models=available) - if pumas.typed(job['config']): job['provider_outcome'] = 'unknown' + if pumas.typed(job['config']) or getattr(self.workbench.dataset,'project_execution',None) is not None: job['provider_outcome'] = 'unknown' self._save(job) data = complete(job, self.stop) - if pumas.typed(job['config']): job['provider_outcome'] = 'result_received' + if pumas.typed(job['config']) or getattr(self.workbench.dataset,'project_execution',None) is not None: job['provider_outcome'] = 'result_received' job['response_sha256'] = hashlib.sha256(data).hexdigest() job['raw_response_base64'] = base64.b64encode(data).decode('ascii') # Exact bytes are authoritative; the optional UTF-8 view is never lossy. @@ -277,6 +315,7 @@ def _run_admitted(self, job): raise WorkbenchError('Generation cancelled.', 'cancelled') self._source({'source_id': job['source']['id'], 'revision': job['source']['revision'], 'source_revision': job['source']['source_revision']}) job.update(status='completed', candidates=candidates) + validate_retained_result(job) self._save(job) self.access.audit('grounded.finish', job_id, {'status': job['status'], 'response_sha256': job.get('response_sha256')}) except Exception as error: @@ -292,14 +331,30 @@ def _run_admitted(self, job): @authorized('cancel', 'grounded.cancel') def cancel(self, job_id): with self.lock, self.db.transaction(): - job = self._get(job_id) - if job_id != self.active_id or job['status'] not in ACTIVE: - return {'cancelled': False, 'job': job} - self.stop.set() - job.update(status='stopping', error='Cancellation requested; late output will not be admitted.') - self._save(job) - self.access.audit('grounded.cancel', job['id']) - return {'cancelled': True, 'job': job} + result, pending = self._prepare_cancel(job_id) + if pending is not None: + self._signal_cancel(pending) + return result + + def _prepare_cancel(self, job_id): + """Persist intent only; project dispatch signals after canonical ACK.""" + if not self.db.in_transaction: + raise RuntimeError('Producer cancellation preparation requires an owned transaction.') + job = self._get(job_id) + if job['id'] != self.active_id or job['status'] not in ACTIVE: + return {'cancelled': False, 'job': job}, None + pending = (self.worker, job_id) + job.update(status='stopping', error='Cancellation requested; late output will not be admitted.') + self._save(job) + self.access.audit('grounded.cancel', job['id']) + return {'cancelled': True, 'job': job}, pending + + def _signal_cancel(self, pending): + # A retained worker object is local custody, never a saved PID or ID. + if self.worker is not pending[0]: + return False + self.stop.set() + return True @authorized('edit', 'grounded.review') def review(self, job_id, body): diff --git a/image_sources.py b/image_sources.py new file mode 100644 index 00000000..174a7996 --- /dev/null +++ b/image_sources.py @@ -0,0 +1,331 @@ +"""Image acquisition and source policy, shared by legacy and inert project contexts. + +Constructing this owner creates no workers, jobs, scripts or runtime recovery. +The caller supplies current authority, Workbench enrollment and inert or live +producer-entry ownership. Canonical project publication remains the session's job. +""" +import base64 +from datetime import datetime, timezone +import hashlib +import io +import json +import math +import shutil +import uuid + +import ai +from access import authorized +from metadata import CommitOutcomeUnknown +from PIL import Image, ImageOps +import workbench + +CORNERS = ('top_left', 'top_right', 'bottom_right', 'bottom_left') + +VISIBILITY = ('visible', 'occluded', 'out_of_frame') + +SPLITS = ('unassigned', 'train', 'validation', 'test') + +class Conflict(ValueError): + pass + +def now(): + return datetime.now(timezone.utc).isoformat() + +def metadata(body): + result = {} + for key in ('book_id', 'session_id'): + value = body.get(key, '') + if not isinstance(value, str) or len(value) > 120: + raise ValueError(key + ' must be text, up to 120 characters.') + result[key] = value.strip() + if not result['session_id']: + raise ValueError('Enter a recording session ID.') + result['split'] = body.get('split', 'unassigned') + if result['split'] not in SPLITS: + raise ValueError('Invalid dataset split.') + return result + + +def decode_source_image(raw): + """The acquisition owner's bounded EXIF/RGB interpretation, without writes.""" + if type(raw) is not bytes or not raw or len(raw) > 25 * 1024 * 1024: + raise ValueError('Choose an image smaller than 25 MB.') + try: + with Image.open(io.BytesIO(raw)) as source: + if source.width * source.height > 40_000_000: + raise ValueError('Images must be at most 40 megapixels.') + source.load() + return ImageOps.exif_transpose(source).convert('RGB') + except (OSError, Image.DecompressionBombError) as error: + raise ValueError('Unsupported or damaged image. Use JPEG, PNG, or WebP.') from error + + +def validate_project_source(original_bytes, oriented_bytes, source_snapshot, pixel_sha256): + """Prove retained geometry/pixels through the acquisition owner's policy. + + Reading and comparing captured bytes creates no thumbnail or replacement + source. Missing payloads are handled separately by the projection owner. + """ + source = decode_source_image(original_bytes) + try: + if hashlib.sha256(original_bytes).hexdigest() != source_snapshot['sha256']: + raise ValueError('Original image bytes differ from their source identity.') + with Image.open(io.BytesIO(oriented_bytes)) as captured: + if captured.format != 'PNG' or captured.width * captured.height > 40_000_000: + raise ValueError('Retained oriented image must be the bounded captured PNG.') + captured.load() + oriented = captured.convert('RGB') + try: + expected = (source_snapshot['width'], source_snapshot['height']) + if source.size != expected or oriented.size != expected or source.tobytes() != oriented.tobytes(): + raise ValueError('Retained image geometry or orientation differs from its original source.') + if hashlib.sha256(str(oriented.size).encode() + oriented.tobytes()).hexdigest() != pixel_sha256: + raise ValueError('Retained image pixels differ from their Workbench identity.') + finally: + oriented.close() + finally: + source.close() + +def validate_annotation(body): + if not isinstance(body, dict): + raise ValueError('Expected an annotation object.') + suggested_by = body.get('suggested_by') + extra = {} + if suggested_by is not None: + if not isinstance(suggested_by, dict) or suggested_by.get('provider') not in ai.PROVIDERS: + raise ValueError('Invalid AI suggestion provenance.') + model = ai.model_id(suggested_by.get('model')) + timestamp = suggested_by.get('suggested_at') + if not isinstance(timestamp, str) or len(timestamp) > 60: + raise ValueError('Invalid AI suggestion timestamp.') + try: + datetime.fromisoformat(timestamp) + except ValueError: + raise ValueError('Invalid AI suggestion timestamp.') from None + extra['suggested_by'] = dict(provider=suggested_by['provider'], model=model, suggested_at=timestamp) + reference = body.get('corner_reference', 'book') + if reference not in ('book', 'image'): + raise ValueError('Choose book or image orientation for corner labels.') + present, suitable = body.get('book_present'), body.get('crop_suitable') + if type(present) is not bool or type(suitable) is not bool: + raise ValueError('Choose book presence and crop suitability.') + if not present: + if suitable: + raise ValueError('A scene without a book cannot be suitable for cropping.') + return dict(book_present=False, crop_suitable=False, corners=[], corner_reference=reference, **extra) + corners = body.get('corners') + if not isinstance(corners, list) or len(corners) != 4: + raise ValueError('Label all four corners.') + result = [] + for name, corner in zip(CORNERS, corners): + if not isinstance(corner, dict) or corner.get('name') != name or corner.get('visibility') not in VISIBILITY: + raise ValueError('Corners must be ordered top-left, top-right, bottom-right, bottom-left.') + visible = corner['visibility'] == 'visible' + x, y = corner.get('x'), corner.get('y') + if visible: + if any(type(v) not in (float, int) or not math.isfinite(v) or not 0 <= v <= 1 for v in (x, y)): + raise ValueError('Place every visible corner inside the image.') + elif x is not None or y is not None: + raise ValueError('Hidden or off-screen corners must not have guessed coordinates.') + result.append(dict(name=name, visibility=corner['visibility'], x=x, y=y)) + if suitable and any(c['visibility'] != 'visible' for c in result): + raise ValueError('A complete crop requires four visible corners.') + if all(c['visibility'] == 'visible' for c in result): + points = [(c['x'], c['y']) for c in result] + crosses = [] + for i in range(4): + a, b, c = points[i], points[(i+1) % 4], points[(i+2) % 4] + crosses.append((b[0]-a[0])*(c[1]-b[1])-(b[1]-a[1])*(c[0]-b[0])) + if any(c <= 0.00001 for c in crosses): + raise ValueError('Corners must form a clockwise, non-crossing outline.') + if reference == 'image' and (points[0][1] + points[1][1] >= points[2][1] + points[3][1] or points[0][0] + points[3][0] >= points[1][0] + points[2][0]): + raise ValueError('Start at the image top-left and label clockwise.') + return dict(book_present=present, crop_suitable=suitable, corners=result, corner_reference=reference, **extra) + + +class ImageSources: + def __init__(self, context): + self._context = context + self.path, self.db = context.path, context.db + self.lock, self.access = context.lock, context.access + (self.path / 'images').mkdir(exist_ok=True) + + def __getattr__(self, name): + context = self.__dict__.get('_context') + if context is not None and name in ('workbench', 'generation_jobs'): + return getattr(context, name) + raise AttributeError(name) + + @authorized('inspect', 'dataset.rows') + def rows(self): + with self.lock: + rows = [dict(r) for r in self.db.execute('SELECT * FROM samples ORDER BY created_at, id')] + provenance = {} + for record in self.db.execute('SELECT data FROM generation_entries'): + entry = json.loads(record[0]) + if entry.get('sample_id'): + provenance[entry['sample_id']] = entry + for row in rows: + if row['id'] in provenance: + entry = provenance[row['id']] + row['generation'] = {key: entry[key] for key in ('job_id', 'ordinal', 'prompt', 'metadata', 'model', 'width', 'height', 'requested_seed') if key in entry} + row['annotation'] = json.loads(row['annotation']) if row['annotation'] else None + if row['annotation'] is not None: + row['annotation'].setdefault('corner_reference', 'image') + return rows + + @authorized('inspect', 'dataset.sample') + def sample(self, sample_id): + return next((row for row in self.rows() if row['id'] == sample_id), None) + + def _resolve_split(self, meta, exclude=''): + key = 'book_id' if meta['book_id'] else 'session_id' + where = key + '=%s' + (" AND book_id=''" if key == 'session_id' else '') + args = (meta[key], exclude) + assigned = {r[0] for r in self.db.execute( + 'SELECT DISTINCT split FROM samples WHERE ' + where + " AND id<>%s AND split<>'unassigned'", args)} + desired = meta['split'] + if assigned and desired != 'unassigned' and desired not in assigned: + raise Conflict('This book/session already belongs to ' + next(iter(assigned)) + '. Keep the same split.') + chosen = next(iter(assigned), desired) + # Existing unassigned members move together. Each change invalidates stale editors. + if chosen != 'unassigned': + self.db.execute("UPDATE samples SET split=%s, revision=revision+1 WHERE " + where + + " AND id<>%s AND split='unassigned'", (chosen, *args)) + return chosen + + def add(self, body, generation=None, *, enrollment=None): + if generation is None: + self.access.require('edit', 'dataset.add') + else: + entry, job, generated_metadata = generation + self.access.require_job('synthetic', job['id'], job.get('admission_id'), + self.generation_jobs._request_digest(job)) + self.access.require('edit', 'dataset.add_generated', job['id']) + meta = metadata(body) + try: + raw = base64.b64decode(body.get('image', ''), validate=True) + except (ValueError, TypeError) as error: + raise ValueError('Invalid image data.') from error + image = decode_source_image(raw) + digest = hashlib.sha256(raw).hexdigest() + name = body.get('filename', 'camera.jpg') + if not isinstance(name, str): + raise ValueError('Invalid filename.') + name = name.replace('\\', '/').rsplit('/', 1)[-1][:200] + sample_id, timestamp = uuid.uuid4().hex, now() + with self.lock: + if self.db.execute('SELECT id FROM samples WHERE sha256=%s', (digest,)).fetchone(): + raise Conflict('This exact image is already in the dataset.') + folder = self.path / 'images' / sample_id + folder.mkdir() + try: + (folder / 'source').write_bytes(raw) + image.save(folder / 'image.png', 'PNG') + thumbnail = image.copy() + thumbnail.thumbnail((160, 120)) + thumbnail.save(folder / 'thumb.jpg', 'JPEG', quality=85) + with self.db.transaction(): + self.db.on_rollback(lambda: shutil.rmtree(folder, ignore_errors=True)) + split = self._resolve_split(meta) + self.db.execute('INSERT INTO samples VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)', + (sample_id, digest, name, image.width, image.height, meta['book_id'], + meta['session_id'], split, timestamp, timestamp, 1, None)) + if generation is not None: + self.generation_jobs.record_output(sample_id, *generation) + if enrollment is not None: + # Imported Workbench source metadata shares acquisition's + # commit and original-file cleanup on admission failure. + self.workbench._enroll_import(sample_id, *enrollment) + self.access.audit('dataset.add_generated' if generation else 'dataset.add', + job['id'] if generation else sample_id, + {'sample_id': sample_id, 'revision': 1, 'source_sha256': digest}) + if generation is not None: + # Return only this admitted publication, without catalog-wide reads. + generated = dict(self.db.execute('SELECT * FROM samples WHERE id=%s', (sample_id,)).fetchone()) + generated['generation'] = {key: entry[key] for key in + ('job_id', 'ordinal', 'prompt', 'metadata', 'model', 'width', 'height', 'requested_seed') if key in entry} + except CommitOutcomeUnknown: + raise + except Exception: + shutil.rmtree(folder, ignore_errors=True) + raise + return generated if generation is not None else self.sample(sample_id) + + def _recover_deletions(self): + staging = self.path / '.deleting-images' + if not staging.exists(): + return + for folder in staging.iterdir(): + if not folder.is_dir() or len(folder.name) != 32 or any(c not in '0123456789abcdef' for c in folder.name): + continue + if self.db.execute('SELECT 1 FROM samples WHERE id=%s', (folder.name,)).fetchone(): + folder.rename(self.path / 'images' / folder.name) + else: + shutil.rmtree(folder) + + @authorized('edit', 'dataset.delete') + def delete(self, sample_id, body): + with self.lock: + row = self.db.execute('SELECT revision FROM samples WHERE id=%s', (sample_id,)).fetchone() + if not row: + raise ValueError('Image not found. It may already have been deleted.') + if type(body.get('revision')) is not int or body['revision'] != row['revision']: + raise Conflict('This image changed in another tab. Reload it before deleting.') + # Enrol before moving bytes; retain lineage in the deletion transaction. + with self.db.transaction(): + self.workbench._sync_images() + folder = self.path / 'images' / sample_id + staging = self.path / '.deleting-images' + staging.mkdir(exist_ok=True) + staged = staging / sample_id + # Rename first, then commit the row deletion. Startup restores staged + # files if a crash occurred before commit, or removes them afterwards. + folder.rename(staged) + cleanup_warning = [] + def restore_files(): + if staged.exists(): + staged.rename(folder) + def remove_files(): + try: + shutil.rmtree(staged) + except OSError: + cleanup_warning.append('Image removed from the dataset. Remaining file cleanup will be retried when Tuldok restarts.') + try: + with self.db.transaction(): + self.db.on_rollback(restore_files) + self.db.on_commit(remove_files) + for record in self.db.execute('SELECT data FROM generation_entries').fetchall(): + entry = json.loads(record[0]) + if entry.get('sample_id') == sample_id: + entry.update(status='deleted', sample_id=None, deleted_at=now()) + self.generation_jobs._entry(entry) + self.workbench.preserve_deleted_source(sample_id) + self.db.execute('DELETE FROM samples WHERE id=%s', (sample_id,)) + self.access.audit('dataset.delete', sample_id) + except CommitOutcomeUnknown: + raise + except Exception: + restore_files() + raise + return dict(self.generation_jobs.snapshot(), samples=self.rows(), deleted=sample_id, + warning=cleanup_warning[0] if cleanup_warning else '') + + @authorized('approve-review', 'dataset.save') + def save(self, sample_id, body): + annotation = validate_annotation(body.get('annotation', {})) + meta = metadata(body) + if annotation['book_present'] and not meta['book_id']: + raise ValueError('Enter a book ID for images containing a book.') + with self.lock, self.db.transaction(): + row = self.db.execute('SELECT revision FROM samples WHERE id=%s', (sample_id,)).fetchone() + if not row: + raise ValueError('Image not found.') + if body.get('revision') != row['revision']: + raise Conflict('This image changed in another tab. Reload it before saving.') + split = self._resolve_split(meta, sample_id) + self.db.execute('UPDATE samples SET annotation=%s,book_id=%s,session_id=%s,split=%s,updated_at=%s,revision=revision+1 WHERE id=%s', + (json.dumps(annotation), meta['book_id'], meta['session_id'], split, now(), sample_id)) + self.access.audit('dataset.save', sample_id, {'before_revision': body['revision'], + 'revision': body['revision'] + 1, 'annotation_sha256': hashlib.sha256(workbench.encode(annotation).encode()).hexdigest()}) + return self.sample(sample_id) diff --git a/immutable_assets.py b/immutable_assets.py index 8faf990f..c84aacf0 100644 --- a/immutable_assets.py +++ b/immutable_assets.py @@ -3,6 +3,7 @@ from contextlib import contextmanager, nullcontext import hashlib import json +import math import os import uuid @@ -11,6 +12,26 @@ MAX_SELECTED_ASSET_BYTES = 40 * 1024 * 1024 +def validate_project_metadata_fields(value, fields, maximum): + """Common transport checks; selected asset owners supply native semantics.""" + if type(value) is not dict or set(value) != set(fields) | {'bundle_bytes'}: + raise WorkbenchError('Unexpected retained immutable metadata fields.') + if type(value['bundle_bytes']) is not int or not 0 < value['bundle_bytes'] <= maximum: + raise WorkbenchError('Invalid retained immutable byte count.') + if strings(value['protected_groups'],'Immutable groups') != value['protected_groups'] or len(value['protected_groups']) != 2: + raise WorkbenchError('Invalid retained immutable protected groups.') + text_value(value['scope'],'Immutable source scope',1000) + + +def validate_project_bounds(value): + if type(value) is not dict or set(value) != {'min','max'}: + raise WorkbenchError('Invalid retained spatial bounds.') + if any(type(value[k]) is not list or len(value[k]) != 3 or any(type(n) not in (int,float) or not math.isfinite(n) for n in value[k]) for k in ('min','max')): + raise WorkbenchError('Spatial bounds require finite XYZ.') + if any(a>b for a,b in zip(value['min'],value['max'])): + raise WorkbenchError('Spatial bounds are reversed.') + + def check_selection(records): total = sum(row[row['kind']]['bundle_bytes'] for row in records if row['kind'] in ('sequence', 'mesh', 'pointcloud', 'volume') and row.get(row['kind'])) @@ -115,11 +136,20 @@ def check_selection(self, records): @authorized('inspect', 'immutable_assets.metadata') def metadata(self, record_id): + source = getattr(self.workbench.dataset, 'project_sources', {}).get(record_id) + if source is not None and source['kind'] == self.kind: + return dict(source['metadata']) row = self.workbench.db.execute(f'SELECT metadata_json,bundle_bytes FROM {self.table} WHERE id=%s', (record_id,)).fetchone() return None if row is None else dict(json.loads(row[0]), bundle_bytes=row[1]) @authorized('inspect', 'immutable_assets.asset', target=lambda record: record.get('id') if isinstance(record, dict) else None) def asset(self, record): + resolver = getattr(self.workbench.dataset, 'project_asset', None) + if resolver is not None and record['id'] in self.workbench.dataset.project_sources: + bundle = resolver(record['id'], self.kind) + if not 0 < len(bundle) <= self.maximum or hashlib.sha256(bundle).hexdigest() != record['content_hash']: + raise WorkbenchError('Project asset identity differs.', 'invalid', 409) + return bundle, 'application/zip' row = self.workbench.db.execute(f'SELECT bundle,bundle_bytes FROM {self.table} WHERE id=%s', (record['id'],)).fetchone() if row is None: raise WorkbenchError(f'{self.kind.title()} source unavailable.', 'unavailable', 404) diff --git a/mcp_stdio.py b/mcp_stdio.py index d3bdf6f6..9cf12db5 100644 --- a/mcp_stdio.py +++ b/mcp_stdio.py @@ -98,8 +98,11 @@ def error_response(request_id, code, message): 'error': {'code': code, 'message': message}} -def tool_error(code, message, status=502): - return {'status': status, 'result': {'code': code, 'error': message}} +def tool_error(code, message, status=502, *, receipt=None): + result = {'code': code, 'error': message} + if receipt is not None: + result['receipt'] = receipt + return {'status': status, 'result': result} def encode_response(value): @@ -186,7 +189,7 @@ def replied(self, value): async def serve(client, input_fd=0, output_fd=1, observer=None): - """Run the public SDK over bounded stdio; caller bootstraps AgentClient first.""" + """Run the public SDK over bounded stdio; caller bootstraps its explicit profile.""" import anyio from mcp import types from mcp.server.lowlevel import Server @@ -194,7 +197,6 @@ async def serve(client, input_fd=0, output_fd=1, observer=None): from mcp.shared.message import SessionMessage import agent_api - agent_api.validate_registry() definitions = (client.tool_definitions() if hasattr(client, 'tool_definitions') else agent_api.tool_definitions()) selected = client.selected_contracts() if hasattr(client, 'selected_contracts') else {} @@ -234,14 +236,19 @@ async def call_tool(name, arguments): try: agent_api.validate_arguments(name, arguments, contract_version(name)) observe('dispatch', pending_observation) - envelope = await anyio.to_thread.run_sync(client.call, name, arguments) + # The explicit owner clients admit canonical operation IDs only; + # MCP names are transport aliases, validated by the fixed profile. + dispatch_name = (agent_api.operation(name)['operation_id'] + if isinstance(client, (agent_api.ProjectFilesOwnerClient, + agent_api.ProjectTransferOwnerClient)) else name) + envelope = await anyio.to_thread.run_sync(client.call, dispatch_name, arguments) pending_http_status = envelope.get('status') if isinstance(envelope, dict) else None agent_api.validate_output(name, envelope, contract_version(name)) pending_outcome = ('permission-refusal' if envelope['status'] in (401, 403) else 'domain-error' if envelope['status'] >= 400 else 'success') except agent_api.AgentAPIError as exc: # Owner supplies bounded, public API errors; no raw transport exception. - envelope = tool_error(exc.code, str(exc), exc.status) + envelope = tool_error(exc.code, str(exc), exc.status, receipt=exc.receipt) pending_outcome = ('transport-error' if exc.code in ('transport', 'response_too_large') else 'registry-refusal' if exc.code == 'registry_mismatch' else 'permission-refusal' if exc.status in (401, 403) else 'call-error') @@ -403,7 +410,12 @@ async def writer(): def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--config', required=True, help='Private AgentClient JSON configuration file') + parser.add_argument('--config', required=True, help='Private SDK JSON configuration file') + owner_profile = parser.add_mutually_exclusive_group() + owner_profile.add_argument('--project-files-owner', action='store_true', + help='Explicit current owner credential; expose only finite project-file operations') + owner_profile.add_argument('--project-transfer-owner', action='store_true', + help='Explicit current host owner; expose only the five project-transfer operations') parser.add_argument('--observe-v2-database', help='Explicit operator opt-in to a fresh private v2 interface store') parser.add_argument('--observe-database', help='Opt in to operator-only SQLite in an existing private directory') parser.add_argument('--observe-max-calls') @@ -416,7 +428,9 @@ def main(argv=None): import mcp # noqa: F401 - explicit optional consumer boundary import jsonschema # noqa: F401 import agent_api - client = agent_api.AgentClient.from_config(args.config) + client_type = (agent_api.ProjectFilesOwnerClient if args.project_files_owner else + agent_api.ProjectTransferOwnerClient if args.project_transfer_owner else agent_api.AgentClient) + client = client_type.from_config(args.config) client.bootstrap() if args.observe_database is not None or args.observe_v2_database is not None: try: diff --git a/meshes.py b/meshes.py index a16eef8c..2171ecec 100644 --- a/meshes.py +++ b/meshes.py @@ -63,19 +63,26 @@ def exact(value, keys, name): def manifest(raw, geometry): if not 0 < len(raw) <= MANIFEST_LIMIT: invalid('sidecar byte cap exceeded.') - value = parse_json(raw) + value = manifest_declaration(parse_json(raw)) + if value['geometry_bytes'] != len(geometry): + invalid('geometry filename or byte count mismatch.') + if hashlib.sha256(geometry).hexdigest() != value['geometry_sha256']: + invalid('geometry SHA256 mismatch.') + return value + + +def manifest_declaration(value): + """Declared source semantics, independently of payload availability.""" exact(value, ('format', 'geometry_file', 'geometry_bytes', 'geometry_sha256', 'units', 'coordinate_system', 'provenance'), 'sidecar') if type(value['format']) is not str: invalid('format must be a string.') if value['format'] != 'tuldok_mesh_v1': raise WorkbenchError('Unsupported static mesh sidecar version.', 'unsupported') - if value['geometry_file'] != 'mesh.ply' or type(value['geometry_bytes']) is not int or value['geometry_bytes'] != len(geometry): + if value['geometry_file'] != 'mesh.ply' or type(value['geometry_bytes']) is not int or not 0 < value['geometry_bytes'] <= PLY_LIMIT: invalid('geometry filename or byte count mismatch.') digest = value['geometry_sha256'] if type(digest) is not str or not HASH.fullmatch(digest): invalid('geometry SHA256 must be lowercase hex.') - if hashlib.sha256(geometry).hexdigest() != digest: - invalid('geometry SHA256 mismatch.') if value['units'] not in ('m', 'cm', 'mm'): invalid('units must be m, cm or mm.') frame = value['coordinate_system'] @@ -269,6 +276,23 @@ def validated(self, record, bundle): except (WorkbenchError, zipfile.BadZipFile, KeyError, RuntimeError, UnicodeError) as error: raise WorkbenchError('Mesh source or metadata changed outside Tuldok.', 'conflict', 409) from error + def validate_project_source(self, bundle, metadata): + self.validated({'mesh': metadata}, bundle) + + + @staticmethod + def validate_project_metadata(value): + from immutable_assets import validate_project_metadata_fields, validate_project_bounds + validate_project_metadata_fields(value, {'manifest','manifest_sha256','properties','vertex_count','triangle_count','bounds','provided_normals','protected_groups','scope'}, BUNDLE_LIMIT) + manifest_declaration(value['manifest']); validate_project_bounds(value['bounds']) + if type(value['manifest_sha256']) is not str or not HASH.fullmatch(value['manifest_sha256']):invalid('invalid retained manifest SHA256.') + for key,maximum in (('vertex_count',VERTEX_LIMIT),('triangle_count',FACE_LIMIT)): + if type(value[key]) is not int or not 1<=value[key]<=maximum:invalid('invalid retained mesh count.') + properties=value['properties'] + if type(properties) is not list or any(type(p) is not dict or set(p)!={'name','dtype'} or p['dtype'] not in ('float','double') for p in properties):invalid('invalid retained mesh properties.') + names=[p['name'] for p in properties] + if names not in (['x','y','z'],['x','y','z','nx','ny','nz']) or type(value['provided_normals']) is not bool or value['provided_normals']!=(len(names)==6):invalid('retained mesh normal declaration differs.') + @authorized('inspect', 'meshes.asset', target=lambda record: record.get('id') if isinstance(record, dict) else None) def asset(self, record): bundle, mime = super().asset(record) diff --git a/metadata.py b/metadata.py index 1aa9648c..9d51a830 100644 --- a/metadata.py +++ b/metadata.py @@ -13,6 +13,7 @@ import json import os from pathlib import Path +import re import stat import threading import time @@ -190,12 +191,15 @@ def bounded(self, deadline, cancelled=None): class Metadata: def __init__(self, path, dsn=None, *, create=True, dataset_id=None, - schema_version=SCHEMA_VERSION, initial_file_modes=None): + schema_version=SCHEMA_VERSION, initial_file_modes=None, projection_id=None): # Version selection belongs to offline transfer/upgrade tools. Runtime # callers omit it and require the current schema, never auto-upgrade. if schema_version is not None and (type(schema_version) is not int or schema_version not in SUPPORTED_SCHEMA_VERSIONS): raise StorageError('Unsupported PostgreSQL catalog version.') + if projection_id is not None and (type(projection_id) is not str or + not re.fullmatch('[a-f0-9]{32}', projection_id)): + raise StorageError('Invalid local project projection identity.') path = Path(path).resolve() if create: path.mkdir(parents=True, exist_ok=True) @@ -229,10 +233,11 @@ def __init__(self, path, dsn=None, *, create=True, dataset_id=None, os.fsync(guard.fileno()) fcntl.flock(guard, fcntl.LOCK_EX) self._initialize(path, dsn, create=create, dataset_id=dataset_id, - schema_version=schema_version, initial_file_modes=initial_file_modes) + schema_version=schema_version, initial_file_modes=initial_file_modes, + projection_id=projection_id) def _initialize(self, path, dsn=None, *, create=True, dataset_id=None, - schema_version=SCHEMA_VERSION, initial_file_modes=None): + schema_version=SCHEMA_VERSION, initial_file_modes=None, projection_id=None): self.path = Path(path).resolve() self.schema_version = SCHEMA_VERSION if schema_version is None else schema_version self.dsn = connection_info(dsn) @@ -240,6 +245,8 @@ def _initialize(self, path, dsn=None, *, create=True, dataset_id=None, pending = self.path / '.tuldok-initializing.json' self._rollback = [] self._commit = [] + self._project_before_commit = None + self._project_outcome = None self._runtime_claims = {} if initial_file_modes is not None and (os.path.lexists(manifest) or ('.metadata-init.lock' in initial_file_modes and os.path.lexists(pending))): @@ -248,7 +255,9 @@ def _initialize(self, path, dsn=None, *, create=True, dataset_id=None, try: value = json.loads(manifest.read_text()) identity = value['dataset_id'] - if value != {'version': 1, 'dataset_id': identity} or uuid.UUID(hex=identity).hex != identity: + expected = {'version': 1, 'dataset_id': identity} if projection_id is None else { + 'version': 2, 'dataset_id': identity, 'projection_id': projection_id} + if value != expected or uuid.UUID(hex=identity).hex != identity: raise ValueError() if dataset_id is not None and dataset_id != identity: raise ValueError() @@ -263,7 +272,11 @@ def _initialize(self, path, dsn=None, *, create=True, dataset_id=None, try: reservation = json.loads(pending.read_text()) identity = reservation['dataset_id'] - if reservation != {'version': 1, 'dataset_id': identity, 'definition_sha256': definition_hash(self.schema_version)}: + expected = {'version': 1, 'dataset_id': identity, + 'definition_sha256': definition_hash(self.schema_version)} + if projection_id is not None: + expected.update(version=2, projection_id=projection_id) + if reservation != expected: raise ValueError() if dataset_id is not None and identity != dataset_id: raise ValueError() @@ -274,7 +287,12 @@ def _initialize(self, path, dsn=None, *, create=True, dataset_id=None, if not isinstance(identity, str) or uuid.UUID(hex=identity).hex != identity: raise StorageError('Invalid dataset identity.') self.dataset_id = identity - self.schema = 'catalog_' + identity + # Portable logical identity participates in domain hashes. A project + # checkout's disposable physical projection must never alias another + # checkout or machine merely because it has the same project UUID. + self.projection_id = projection_id + self.schema = ('catalog_' + identity if projection_id is None else + 'projection_' + projection_id) self._lock_key = _key(self.schema) try: self.connection = psycopg.connect(self.dsn, autocommit=True, row_factory=row_factory) @@ -292,7 +310,11 @@ def _initialize(self, path, dsn=None, *, create=True, dataset_id=None, raise StorageError('Destination catalog already exists; refuse adopting it without its dataset manifest.') if not manifest.exists() and not pending.exists(): with pending.open('x') as handle: - json.dump({'version': 1, 'dataset_id': identity, 'definition_sha256': definition_hash(self.schema_version)}, handle) + reservation = {'version': 1, 'dataset_id': identity, + 'definition_sha256': definition_hash(self.schema_version)} + if projection_id is not None: + reservation.update(version=2, projection_id=projection_id) + json.dump(reservation, handle) handle.flush() os.fsync(handle.fileno()) _sync_directory(self.path) @@ -328,9 +350,13 @@ def _initialize(self, path, dsn=None, *, create=True, dataset_id=None, for table in self.tables): raise StorageError('Interrupted catalog initialization contains unexpected application rows.') if initial_file_modes is None: - _publish_manifest(manifest, {'version': 1, 'dataset_id': identity}) + value = {'version': 1, 'dataset_id': identity} if projection_id is None else { + 'version': 2, 'dataset_id': identity, 'projection_id': projection_id} + _publish_manifest(manifest, value) else: - _publish_manifest(manifest, {'version': 1, 'dataset_id': identity}, + value = {'version': 1, 'dataset_id': identity} if projection_id is None else { + 'version': 2, 'dataset_id': identity, 'projection_id': projection_id} + _publish_manifest(manifest, value, initial_mode=initial_file_modes[MANIFEST]) if pending.exists(): pending.unlink() @@ -395,40 +421,66 @@ def transaction(self, *, force_rollback=False): self._rollback.append(callbacks) committed = [] self._commit.append(committed) + outermost = len(self._rollback) == 1 body_failed = False try: - with self.connection.transaction(force_rollback=force_rollback): - try: - yield self - except BaseException: - body_failed = True - raise + try: + with self.connection.transaction(force_rollback=force_rollback): + try: + yield self + if outermost and not force_rollback and self._project_before_commit is not None: + self._project_before_commit() + except BaseException: + body_failed = True + raise + finally: + # Outcome hooks and wakes observe the completed SQL scope. + # In particular an on_commit wake must not re-enroll itself + # into the callback list that is currently being drained. + self._rollback.pop() + self._commit.pop() except BaseException as error: - # A server-confirmed constraint/serialization failure at COMMIT - # rolls back. A lost COMMIT response does not prove rollback. confirmed_failure = getattr(error, 'sqlstate', '') or '' if body_failed or force_rollback or confirmed_failure[:2] in ('23', '40'): + if outermost and self._project_outcome is not None: + self._project_outcome('rolled_back') for callback in reversed(callbacks): callback() raise - # psycopg3.3.6 marks its enum COMMITTED before sending COMMIT. - # Only normal scope completion proves acknowledged commit; the - # enum cannot authorize cleanup/publication or a worker wake. + # A lost COMMIT response never authorizes cleanup or a wake. + if outermost and self._project_outcome is not None: + self._project_outcome('unknown') raise CommitOutcomeUnknown( 'PostgreSQL commit acknowledgement was lost. Files were retained; reopen and inspect the saved result before retrying.') from error else: if force_rollback: + if outermost and self._project_outcome is not None: + self._project_outcome('rolled_back') for callback in reversed(callbacks): callback() - elif len(self._rollback) > 1: - self._rollback[-2].extend(callbacks) - self._commit[-2].extend(committed) + elif not outermost: + self._rollback[-1].extend(callbacks) + self._commit[-1].extend(committed) else: + if self._project_outcome is not None: + self._project_outcome('committed') for callback in committed: callback() - finally: - self._rollback.pop() - self._commit.pop() + + def set_project_hooks(self, before_commit, outcome): + """Install one fixed project durability owner, before any worker starts. + + Hooks belong to this physical projection, never to the process or an + imported catalog. They run only at the outer transaction boundary. + """ + with self.lock: + if self.in_transaction: + raise RuntimeError('Project hooks cannot change during a transaction.') + if not callable(before_commit) or not callable(outcome): + raise TypeError('Project transaction hooks must be callable.') + if self._project_before_commit is not None: + raise RuntimeError('Project transaction hooks already have an owner.') + self._project_before_commit, self._project_outcome = before_commit, outcome @property def in_transaction(self): diff --git a/model_definitions.py b/model_definitions.py index a269bd94..dabfe61f 100644 --- a/model_definitions.py +++ b/model_definitions.py @@ -137,29 +137,116 @@ def read_owned_blob(root, material_sha256, item, maximum, *, with_identity=False if directory is not None:os.close(directory) +def decode_model_draft(value, *, catalog_id): + value=checked_json(value) + closed(value,('format','id','revision','name','task','kind','specification','source_selector', + 'preprocessing_policy','output_policy')) + if value['format']!='model_draft_v1' or value['kind'] not in ('template','captured_source'): + raise WorkbenchError('Unsupported model definition.','unsupported',400) + identifier(value['id']);natural(value['revision'],'Model revision',1);text(value['name'],'Model name') + reference(value['task'],'task',catalog_id) + for key in ('specification','preprocessing_policy','output_policy'): + if type(value[key]) is not dict:raise WorkbenchError(key+' must be an object.') + if value['kind']=='captured_source': + selector=value['source_selector'];closed(selector,('project_id','recipe_id','expected_sha256')) + identifier(selector['project_id']);text(selector['recipe_id'],'Recipe ID',64) + if type(selector['expected_sha256']) is not str or not SHA.fullmatch(selector['expected_sha256']): + raise WorkbenchError('Source selector requires the exact declared recipe SHA256.') + elif value['source_selector'] is not None: + raise WorkbenchError('A template does not select external project source.') + return value + + + +def validate_model_blob(item): + closed(item,('name','sha256','bytes','media_type')) + natural(item['bytes'],'Blob bytes',0,MAX_SOURCE_BYTES);text(item['media_type'],'Media type',80) + if (type(item['name']) is not str or not re.fullmatch( + r'(?:source-\d{4}\.bin|specification\.json|preprocessing_policy\.json|output_policy\.json)',item['name']) or + type(item['sha256']) is not str or not SHA.fullmatch(item['sha256'])): + raise WorkbenchError('Invalid model blob identity.') + + + +def decode_model_material(value, *, catalog_id): + value=checked_json(value);closed(value,('ref','material')) + ref=reference(value['ref'],'model',catalog_id) + if digest(value['material'])!=ref['sha256']: + raise WorkbenchError('Published model identity differs.','unavailable',409) + material=value['material'] + closed(material,('format','catalog_id','model_definition_id','model_definition_revision','task','kind', + 'entrypoint','source_selector','source_closure','specification','preprocessing_policy','output_policy')) + if (material['format']!='model_material_v1' or material['catalog_id']!=catalog_id or + material['model_definition_id']!=ref['id'] or material['model_definition_revision']!=ref['revision'] or + material['kind'] not in ('template','captured_source')): + raise WorkbenchError('Published model definition differs.','unavailable',409) + reference(material['task'],'task',catalog_id) + decode_model_draft({'format':'model_draft_v1','id':material['model_definition_id'], + 'revision':material['model_definition_revision'],'name':'Retained model','task':material['task'], + 'kind':material['kind'],'specification':{},'source_selector':material['source_selector'], + 'preprocessing_policy':{},'output_policy':{}}, catalog_id=catalog_id) + from projects import relative_path + relative_path(material['entrypoint']) + if type(material['source_closure']) is not list or not 1<=len(material['source_closure'])<=MAX_SOURCE_FILES: + raise WorkbenchError('Published model source inventory is invalid.','unavailable',409) + paths=[];names=[] + for item in material['source_closure']: + closed(item,('import_path','blob'));relative_path(item['import_path']);paths.append(item['import_path']) + validate_model_blob(item['blob']);names.append(item['blob']['name']) + for key in ('specification','preprocessing_policy','output_policy'): + validate_model_blob(material[key]);names.append(material[key]['name']) + if material[key]['name']!=key+'.json':raise WorkbenchError('Model policy blob name differs.','unavailable',409) + if len(set(paths))!=len(paths) or len(set(names))!=len(names) or material['entrypoint'] not in paths: + raise WorkbenchError('Model source mapping differs.','unavailable',409) + if sum(item['blob']['bytes'] for item in material['source_closure'])>MAX_SOURCE_BYTES: + raise WorkbenchError('Published source closure exceeds its byte bound.','unavailable',409) + return value + + +def verify_model_closure(value, *, read_blob): + """Read only declared immutable model blobs; never import captured source.""" + value=decode_model_material(value,catalog_id=value['ref']['catalog_id']) + original=read_blob + def read_blob(item,maximum): + raw=original(item,maximum) + if type(raw) is not bytes or len(raw)>maximum or len(raw)!=item['bytes'] or hashlib.sha256(raw).hexdigest()!=item['sha256']: + raise WorkbenchError('Retained Model blob differs.','unavailable',409) + return raw + material=value['material'] + for item in [*(material[key] for key in ('specification','preprocessing_policy','output_policy')), + *(item['blob'] for item in material['source_closure'])]: + read_blob(item,MAX_SOURCE_BYTES) + if material['kind']!='captured_source':return None + import extension_bindings + import recipe_contract + material=value['material'];ref=value['ref'] + sources={item['import_path']:read_blob(item['blob'],MAX_SOURCE_BYTES) for item in material['source_closure']} + matches=[raw for raw in sources.values() if hashlib.sha256(raw).hexdigest()==material['source_selector']['expected_sha256']] + if not matches:raise WorkbenchError('Retained model recipe is missing.','unavailable',409) + definition=recipe_contract.decode_definition(matches[0]) + if recipe_contract.is_bound_evidence(definition): + raw=sources.get(extension_bindings.BINDING_PATH) + if raw is None:raise WorkbenchError('Retained model binding is missing.','unavailable',409) + binding=retained_json(raw.decode(),extension_bindings.BOUND_DOCUMENT_LIMIT) + inventory={item['path'] for item in binding['inventory']} + bound_sources={path:data for path,data in sources.items() if path in inventory or path==extension_bindings.BINDING_PATH} + recipe_contract.validate_bound_sources(definition,binding,bound_sources, + specification=retained_json(read_blob(material['specification'],MAX_SOURCE_BYTES).decode()),task=material['task']) + if material['entrypoint']!=definition['entrypoint']: + raise WorkbenchError('Retained Model entrypoint differs.','unavailable',409) + return binding + if extension_bindings.BINDING_PATH in sources: + raise WorkbenchError('Legacy model cannot retain an extension binding.','unavailable',409) + + + class ModelDefinitions: def __init__(self,dataset): self.dataset=dataset;self.access=dataset.access;self.db=dataset.db;self.lock=dataset.lock self.path=dataset.path/'model-materials';self.path.mkdir(mode=0o700,exist_ok=True) def _draft(self,value): - value=checked_json(value) - closed(value,('format','id','revision','name','task','kind','specification','source_selector', - 'preprocessing_policy','output_policy')) - if value['format']!='model_draft_v1' or value['kind'] not in ('template','captured_source'): - raise WorkbenchError('Unsupported model definition.','unsupported',400) - identifier(value['id']);natural(value['revision'],'Model revision',1);text(value['name'],'Model name') - reference(value['task'],'task',self.db.dataset_id) - for key in ('specification','preprocessing_policy','output_policy'): - if type(value[key]) is not dict:raise WorkbenchError(key+' must be an object.') - if value['kind']=='captured_source': - selector=value['source_selector'];closed(selector,('project_id','recipe_id','expected_sha256')) - identifier(selector['project_id']);text(selector['recipe_id'],'Recipe ID',64) - if type(selector['expected_sha256']) is not str or not SHA.fullmatch(selector['expected_sha256']): - raise WorkbenchError('Source selector requires the exact declared recipe SHA256.') - elif value['source_selector'] is not None: - raise WorkbenchError('A template does not select external project source.') - return value + return decode_model_draft(value, catalog_id=self.db.dataset_id) def _get(self,model_id): identifier(model_id) @@ -308,36 +395,9 @@ def material(self,ref,*,verify=False): row=self.db.execute('SELECT document FROM model_materials WHERE id=%s AND revision=%s AND sha256=%s', (ref['id'],ref['revision'],ref['sha256'])).fetchone() if row is None:raise WorkbenchError('Published model material not found.','not_found',404) - value=checked_json(retained_json(row[0]));closed(value,('ref','material')) - if value['ref']!=ref or digest(value['material'])!=ref['sha256']: - raise WorkbenchError('Published model identity differs.','unavailable',409) + value=decode_model_material(retained_json(row[0]),catalog_id=self.db.dataset_id) + if value['ref']!=ref:raise WorkbenchError('Published model reference differs.','unavailable',409) material=value['material'] - closed(material,('format','catalog_id','model_definition_id','model_definition_revision','task','kind', - 'entrypoint','source_selector','source_closure','specification','preprocessing_policy','output_policy')) - if (material['format']!='model_material_v1' or material['catalog_id']!=self.db.dataset_id or - material['model_definition_id']!=ref['id'] or material['model_definition_revision']!=ref['revision'] or - material['kind'] not in ('template','captured_source')): - raise WorkbenchError('Published model definition differs.','unavailable',409) - reference(material['task'],'task',self.db.dataset_id) - self._draft({'format':'model_draft_v1','id':material['model_definition_id'], - 'revision':material['model_definition_revision'],'name':'Retained model','task':material['task'], - 'kind':material['kind'],'specification':{},'source_selector':material['source_selector'], - 'preprocessing_policy':{},'output_policy':{}}) - from projects import relative_path - relative_path(material['entrypoint']) - if type(material['source_closure']) is not list or not 1<=len(material['source_closure'])<=MAX_SOURCE_FILES: - raise WorkbenchError('Published model source inventory is invalid.','unavailable',409) - paths=[];names=[] - for item in material['source_closure']: - closed(item,('import_path','blob'));relative_path(item['import_path']);paths.append(item['import_path']) - self._blob(item['blob']);names.append(item['blob']['name']) - for key in ('specification','preprocessing_policy','output_policy'): - self._blob(material[key]);names.append(material[key]['name']) - if material[key]['name']!=key+'.json':raise WorkbenchError('Model policy blob name differs.','unavailable',409) - if len(set(paths))!=len(paths) or len(set(names))!=len(names) or material['entrypoint'] not in paths: - raise WorkbenchError('Model source mapping differs.','unavailable',409) - if sum(item['blob']['bytes'] for item in material['source_closure'])>MAX_SOURCE_BYTES: - raise WorkbenchError('Published source closure exceeds its byte bound.','unavailable',409) if verify: for item in [*(value['material'][key] for key in ('specification','preprocessing_policy','output_policy')), *(source['blob'] for source in value['material']['source_closure'])]: @@ -347,35 +407,9 @@ def material(self,ref,*,verify=False): return value def _validate_bound_sources(self, value): - import extension_bindings - import recipe_contract - material=value['material'];ref=value['ref'] - sources={item['import_path']:self._read_blob(ref,item['blob']) for item in material['source_closure']} - matches=[raw for raw in sources.values() if hashlib.sha256(raw).hexdigest()==material['source_selector']['expected_sha256']] - if not matches:raise WorkbenchError('Retained model recipe is missing.','unavailable',409) - definition=recipe_contract.decode_definition(matches[0]) - if recipe_contract.is_bound_evidence(definition): - raw=sources.get(extension_bindings.BINDING_PATH) - if raw is None:raise WorkbenchError('Retained model binding is missing.','unavailable',409) - binding=retained_json(raw.decode(),extension_bindings.BOUND_DOCUMENT_LIMIT) - inventory={item['path'] for item in binding['inventory']} - bound_sources={path:data for path,data in sources.items() if path in inventory or path==extension_bindings.BINDING_PATH} - recipe_contract.validate_bound_sources(definition,binding,bound_sources, - specification=retained_json(self._read_blob(ref,material['specification']).decode()),task=material['task']) - if material['entrypoint']!=definition['entrypoint']: - raise WorkbenchError('Retained Model entrypoint differs.','unavailable',409) - return binding - if extension_bindings.BINDING_PATH in sources: - raise WorkbenchError('Legacy model cannot retain an extension binding.','unavailable',409) - - @staticmethod - def _blob(item): - closed(item,('name','sha256','bytes','media_type')) - natural(item['bytes'],'Blob bytes',0,MAX_SOURCE_BYTES);text(item['media_type'],'Media type',80) - if (type(item['name']) is not str or not re.fullmatch( - r'(?:source-\d{4}\.bin|specification\.json|preprocessing_policy\.json|output_policy\.json)',item['name']) or - type(item['sha256']) is not str or not SHA.fullmatch(item['sha256'])): - raise WorkbenchError('Invalid model blob identity.') + return verify_model_closure(value,read_blob=lambda item,maximum:self._read_blob(value['ref'],item)) + + _blob = staticmethod(validate_model_blob) @authorized('inspect','models.read_blob') def read_blob(self,ref,item): diff --git a/model_evidence.py b/model_evidence.py index 852900eb..a786f6e2 100644 --- a/model_evidence.py +++ b/model_evidence.py @@ -211,8 +211,8 @@ def finish_capture(output, envelope, checkpoints, best_epoch, context): 'output_policy_sha256': digest(OUTPUT_POLICY)}}) -def read_document(directory, name, maximum=MAX_SHARD_BYTES): - value = _json(read_binary(directory/name,maximum),name) +def decode_document(raw, name): + value = _json(raw,name) pending = [(value, 0)] visited = 0 while pending: @@ -229,14 +229,27 @@ def read_document(directory, name, maximum=MAX_SHARD_BYTES): return value +def read_document(directory, name, maximum=MAX_SHARD_BYTES): + return decode_document(read_binary(directory/name,maximum),name) + + def _natural(value, minimum=0): return type(value) is int and minimum <= value <= MAX_INTEGER def verify(directory, run, metrics): + return verify_blobs(run,metrics,read_blob=lambda name,maximum:read_binary(Path(directory)/name,maximum)) + + +def verify_blobs(run, metrics, *, read_blob): """Validate task semantics before publication and again on every evidence read.""" - directory = Path(directory) - manifest = read_document(directory, 'model-evidence.json', MAX_MANIFEST_BYTES) + def document(name, maximum=MAX_SHARD_BYTES): + return decode_document(read_blob(name,maximum),name) + def blob_for(name): + raw=read_blob(name,64*1024**2) + return {'name':name,'sha256':hashlib.sha256(raw).hexdigest(),'bytes':len(raw), + 'media_type':'application/json' if name.endswith('.json') else 'application/octet-stream'} + manifest = document('model-evidence.json', MAX_MANIFEST_BYTES) keys = {'format','schema_version','producer','task','model_sha256','data_sha256','profile_sha256','probe', 'metric_definitions','checkpoints','observations','field_index','coverage','inference'} if (set(manifest) != keys or manifest['format'] != FORMAT @@ -249,16 +262,16 @@ def verify(directory, run, metrics): or manifest['data_sha256'] != run['release_sha256'] or manifest['model_sha256'] != context['model_sha256'] or manifest['profile_sha256'] != digest(context['profile']) or manifest['field_index'] is not None or manifest['coverage'] != {'required':context['profile']['required_capabilities'],'optional':[],'omitted':[]} - or canonical(read_document(directory,'model-definition.json')) != canonical(context['model_definition']) - or canonical(read_document(directory,'profile.json')) != canonical(context['profile']) - or canonical(read_document(directory,'fitted-transform.json')) != canonical(EMPTY_FITTED_TRANSFORM) - or canonical(read_document(directory,'output-policy.json')) != canonical(OUTPUT_POLICY)): + or canonical(document('model-definition.json')) != canonical(context['model_definition']) + or canonical(document('profile.json')) != canonical(context['profile']) + or canonical(document('fitted-transform.json')) != canonical(EMPTY_FITTED_TRANSFORM) + or canonical(document('output-policy.json')) != canonical(OUTPUT_POLICY)): raise ValueError('Evidence differs from admitted model/profile/task/source.') def checked_blob(item): if type(item) is not dict or set(item) != {'name','sha256','bytes','media_type'} or item['name'] not in payload_names(run['parameters']['epochs']): raise ValueError('Invalid evidence blob reference.') - if blob(directory/item['name']) != item: raise ValueError('Evidence blob checksum differs.') - return read_document(directory,item['name']) if item['name'].endswith('.json') else None + if blob_for(item['name']) != item: raise ValueError('Evidence blob checksum differs.') + return document(item['name']) if item['name'].endswith('.json') else None probe = checked_blob(manifest['probe']) expected_probe = probe_manifest({'records':run['source_records']}, run['release_sha256'], context['profile']) if canonical(probe) != canonical(expected_probe): raise ValueError('Fixed validation membership differs.') @@ -266,7 +279,7 @@ def checked_blob(item): if set(index) != {'format','run_id','entries'} or index['format'] != 'checkpoint_index_v1' or index['run_id'] != run['id']: raise ValueError('Invalid checkpoint index.') if len(index['entries']) != run['parameters']['epochs']: raise ValueError('Missing required epoch checkpoint.') - logit_document=read_document(directory,'validation-logits.json') + logit_document=document('validation-logits.json') if (set(logit_document)!={'format','run_id','probe_sha256','entries'} or logit_document['format']!='classifier_validation_logits_v1' or logit_document['run_id']!=run['id'] or logit_document['probe_sha256']!=manifest['probe']['sha256'] @@ -286,12 +299,12 @@ def checked_blob(item): or clocks['fit_pass'] is not None or clocks['physical_time'] is not None): raise ValueError('Checkpoint clocks differ from actual admitted progress.') expected = {'format':'checkpoint_state_v1','run_id':run['id'],'model_sha256':context['model_sha256'], - 'weights':blob(directory/f'weights-epoch-{epoch:04d}.pt'), + 'weights':blob_for(f'weights-epoch-{epoch:04d}.pt'), 'fitted_transform_sha256':fitted_transform_sha256(),'clocks':clocks} if canonical(descriptor) != canonical(expected) or any(entry[key] != descriptor[key] for key in ('model_sha256','fitted_transform_sha256','clocks')): raise ValueError('Checkpoint descriptor binding differs.') checked_blob(descriptor['weights']) - prediction = read_document(directory,f'probes-epoch-{epoch:04d}.json') + prediction = document(f'probes-epoch-{epoch:04d}.json') if (set(prediction) != {'run_id','checkpoint_sha256','probe_sha256','rows'} or prediction['run_id'] != run['id'] or prediction['checkpoint_sha256'] != entry['checkpoint']['sha256'] or prediction['probe_sha256'] != manifest['probe']['sha256'] or len(prediction['rows']) != len(probe['members'])): @@ -344,10 +357,10 @@ def checked_blob(item): raise ValueError('Selected validation metrics differ from selected checkpoint observation.') if selected != [metrics['best_epoch']] or metrics['best_epoch'] != best: raise ValueError('Selected checkpoint differs from validation selection.') - if read_binary(directory/'checkpoint.pt') != read_binary(directory/f'weights-epoch-{best:04d}.pt'): + if read_blob('checkpoint.pt',64*1024**2) != read_blob(f'weights-epoch-{best:04d}.pt',64*1024**2): # torch serialization has filename metadata: trainer copies exact selected bytes below. raise ValueError('Selected weight bytes differ.') - selected_rows=read_document(directory,'predictions-validation.json')['rows'] + selected_rows=document('predictions-validation.json')['rows'] by_selected_id={row['id']:row for row in selected_rows} for member,row in zip(probe['members'],predictions[best-1]['rows'],strict=True): original=by_selected_id[member['frozen_reference']['id']] @@ -355,7 +368,7 @@ def checked_blob(item): raise ValueError('Selected full predictions differ from retained checkpoint probes.') definitions = checked_blob(manifest['metric_definitions']) observations=[] - expected_observation_blobs=[blob(directory/'observations.json'),blob(directory/'validation-logits.json'), *[blob(directory/f'probes-epoch-{epoch:04d}.json') for epoch in range(1,run['parameters']['epochs']+1)]] + expected_observation_blobs=[blob_for('observations.json'),blob_for('validation-logits.json'), *[blob_for(f'probes-epoch-{epoch:04d}.json') for epoch in range(1,run['parameters']['epochs']+1)]] if manifest['observations']!=expected_observation_blobs:raise ValueError('Observation inventory differs.') for item in manifest['observations']: value=checked_blob(item) @@ -375,7 +388,7 @@ def checked_blob(item): 'probe_sha256':manifest['probe']['sha256'],'metric_id':metric,'split':'validation','clocks':entry['clocks'], 'state':'observed','value':step['validation'][metric],'sample_count':step['validation']['examples']} if canonical(match) != canonical([expected]):raise ValueError('Scalar observation differs from actual validation.') - expected_inference={'mode':'eval_no_grad','fitted_transforms':[blob(directory/'fitted-transform.json')], + expected_inference={'mode':'eval_no_grad','fitted_transforms':[blob_for('fitted-transform.json')], 'output_policy_sha256':digest(OUTPUT_POLICY)} if manifest['inference'] != expected_inference:raise ValueError('Inference transform differs.') return {'state':'verified','manifest':manifest,'checkpoint_index':index,'probe':probe, diff --git a/native_process_bootstrap.py b/native_process_bootstrap.py new file mode 100644 index 00000000..56466b41 --- /dev/null +++ b/native_process_bootstrap.py @@ -0,0 +1,115 @@ +"""Closed native job launch, entered only behind the existing custodian GO. + +The custodian arms parent-death kill before exec. This module rechecks and +retains that exact parent before importing a captured native worker. It creates +new execution evidence; the original job template is never modified. +""" +import ctypes +import hashlib +import json +import os +from pathlib import Path +import signal +import stat +import sys + +WORKERS = frozenset(('image_training.py', 'text_training.py', 'audio_training.py')) +TEMPLATE_LIMIT = 64 * 1024 + + +def encoded(value): + return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=True, + allow_nan=False).encode('utf-8') + + +def pairs(items): + value = {} + for key, item in items: + if key in value: raise ValueError('Duplicate native template member.') + value[key] = item + return value + + +def read_regular(path): + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(descriptor, 'rb') as source: + before = os.fstat(source.fileno()) + if (not stat.S_ISREG(before.st_mode) or before.st_nlink != 1 + or before.st_uid != os.getuid() or before.st_mode & 0o222 + or before.st_size > TEMPLATE_LIMIT): + raise ValueError('Native template is not an owned immutable file.') + raw = source.read(TEMPLATE_LIMIT + 1) + after = os.fstat(source.fileno()) + if (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns) != ( + after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns): + raise ValueError('Native template changed.') + if len(raw) > TEMPLATE_LIMIT: raise ValueError('Native template exceeds its bound.') + return raw + + +def parent_identity(): + parent = os.getppid() + if parent <= 1: raise ValueError('Native custodian parent is unavailable.') + libc = ctypes.CDLL(None, use_errno=True) + if libc.prctl(1, signal.SIGKILL, 0, 0, 0): + raise ValueError('Native parent-death protection is unavailable.') + fields = Path('/proc/' + str(parent) + '/stat').read_text().rsplit(')', 1)[1].split() + value = dict(pid=parent, start_ticks=fields[19], + boot_id=Path('/proc/sys/kernel/random/boot_id').read_text().strip()) + if os.getppid() != parent: raise ValueError('Native custodian parent changed.') + return value + + +def write_once(directory, name, raw): + descriptor = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o400, dir_fd=directory) + with os.fdopen(descriptor, 'wb') as target: + target.write(raw); target.flush(); os.fsync(target.fileno()) + os.fsync(directory) + + +def main(argv=None): + arguments = sys.argv[1:] if argv is None else argv + if len(arguments) != 2 or arguments[0] not in ('train', 'verify'): + raise ValueError('Unsupported native launch phase.') + # The helper already armed this death signal before executing Python. + # Re-arm and observe it before runpy or any native worker import. + parent = parent_identity() + root = Path.cwd() + template = Path(arguments[1]) + if template != root / 'native-job-template.json': + raise ValueError('Native template path differs.') + raw = read_regular(template) + value = json.loads(raw, object_pairs_hook=pairs, + parse_constant=lambda _: (_ for _ in ()).throw(ValueError('Nonfinite native value.'))) + if (type(value) is not dict or set(value) != {'format', 'phase', 'worker', 'attempt_id', 'request_sha256', 'job'} + or value['format'] != 'native_parent_binding_v1' or value['phase'] != arguments[0] + or value['worker'] not in WORKERS or type(value['job']) is not dict + or type(value['job'].get('parent_pid')) is not int or value['job']['parent_pid'] != 1 + or encoded(value) != raw): + raise ValueError('Native job template differs from the closed contract.') + job = dict(value['job'], parent_pid=parent['pid']) + effective = encoded(job) + observation = encoded(dict(format='native_parent_observation_v1', attempt_id=value['attempt_id'], + request_sha256=value['request_sha256'], template_sha256=hashlib.sha256(raw).hexdigest(), + effective_job_sha256=hashlib.sha256(effective).hexdigest(), helper=parent)) + directory = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + write_once(directory, 'effective-job.json', effective) + write_once(directory, 'native-parent-observation.json', observation) + finally: + os.close(directory) + worker = Path(__file__).resolve().parent / value['worker'] + sys.path.insert(0, str(worker.parent)) + sys.argv[:] = [str(worker), '--job' if value['phase'] == 'train' else '--verify-evidence', + str(root / 'effective-job.json')] + import runpy + runpy.run_path(str(worker), run_name='__main__') + + +if __name__ == '__main__': + try: + main() + except Exception: + print('Native launch binding failed.', file=sys.stderr, flush=True) + raise SystemExit(2) diff --git a/one_fit_evidence.py b/one_fit_evidence.py index 6b8f55ee..1a6106e6 100644 --- a/one_fit_evidence.py +++ b/one_fit_evidence.py @@ -11,7 +11,7 @@ import re from image_training import TrainingError, _invalid, _write_json -from model_evidence import read_binary, read_document +from model_evidence import read_binary, read_document, decode_document from workbench import encode HASH = re.compile(r'^[a-f0-9]{64}$') @@ -205,7 +205,7 @@ def write(name, body): return body -def verify_directory(directory, *, job, adapter, manifest, reference_keys, expected_inputs, reconstruct=True): +def verify_directory(directory, *, job, adapter, manifest, reference_keys, expected_inputs, reconstruct=True, read_blob=None): """Task-owned checks after custody; accepts no source paths from evidence. `manifest` and `expected_inputs` come from the selected frozen task reader, @@ -213,10 +213,16 @@ def verify_directory(directory, *, job, adapter, manifest, reference_keys, expec exact output inventory/outer hashes and atomic publication. """ context = validate_job_selection(job, adapter) - root = Path(directory) + root = Path(directory) if directory is not None else None + def reference_blob(name): + if read_blob is None:return _ref(root/name) + raw=read_blob(name,8*1024**2) + return dict(name=name,sha256=hashlib.sha256(raw).hexdigest(),bytes=len(raw),media_type='application/json') + def hash_blob(name): + return reference_blob(name)['sha256'] def read(name): maximum = 1024**2 if name == 'model-evidence.json' else 8 * 1024**2 - return read_document(root, name, maximum) + return read_document(root,name,maximum) if read_blob is None else decode_document(read_blob(name,maximum),name) evidence = read('model-evidence.json') _exact(evidence, ('format', 'schema_version', 'producer', 'task', 'model_sha256', 'data_sha256', 'profile_sha256', 'probe', 'metric_definitions', 'checkpoints', 'observations', 'field_index', 'coverage', 'inference'), 'Evidence manifest') @@ -226,7 +232,7 @@ def read(name): or evidence['data_sha256'] != job['release_sha256'] or evidence['profile_sha256'] != context['profile_sha256']): _invalid('One-fit evidence material differs from the admitted job.') def reference(ref, name): - if not _same(ref, _ref(root / name)): + if not _same(ref, reference_blob(name)): _invalid('One-fit evidence blob differs from retained bytes.') return read(name) index = reference(evidence['checkpoints'], 'checkpoints.json') @@ -247,10 +253,10 @@ def reference(ref, name): or type(evidence['inference']['fitted_transforms']) is not list or len(evidence['inference']['fitted_transforms']) != 1): _invalid('One-fit inference policy differs from admitted material.') fitted = reference(evidence['inference']['fitted_transforms'][0], 'fitted-transform.json') - if (type(entry['selected']) is not bool or entry != dict(checkpoint=entry['checkpoint'], model_sha256=context['model_sha256'], fitted_transform_sha256=digest_bytes(root / 'fitted-transform.json'), + if (type(entry['selected']) is not bool or entry != dict(checkpoint=entry['checkpoint'], model_sha256=context['model_sha256'], fitted_transform_sha256=hash_blob('fitted-transform.json'), clocks=clocks, selected=True, state='retained') or not _same(checkpoint, dict(format='checkpoint_state_v1', run_id=job['run_id'], model_sha256=context['model_sha256'], - weights=_ref(root / 'model.json'), fitted_transform_sha256=entry['fitted_transform_sha256'], clocks=clocks))): + weights=reference_blob('model.json'), fitted_transform_sha256=entry['fitted_transform_sha256'], clocks=clocks))): _invalid('One-fit descriptor/index binding differs.') saved = read('model.json') if saved.get('adapter') != job['adapter'] or saved.get('source_identity') != {key: job[key] for key in ('dataset_id', 'run_id', 'graph_sha256', 'release_id', 'release_sha256')}: @@ -301,7 +307,7 @@ def reference(ref, name): metrics = read('metrics.json') if not _same(metrics.get('clocks'), clocks) or 'history' in metrics or 'best_epoch' in metrics: _invalid('One-fit metrics cannot publish an artificial training curve.') - if not _same(evidence['observations'], [_ref(root / 'observations.json'), _ref(root / 'probes-fit-0001.json')]): + if not _same(evidence['observations'], [reference_blob('observations.json'), reference_blob('probes-fit-0001.json')]): _invalid('One-fit observations differ from retained bytes.') observations = read('observations.json') expected_observations = [dict(run_id=job['run_id'], checkpoint_sha256=entry['checkpoint']['sha256'], probe_sha256=evidence['probe']['sha256'], @@ -343,7 +349,7 @@ def job_for_run(run, output_dir=None): context = run['evidence_context'] owner = contract(run['adapter']) return dict(schema_version=2, dataset_id=run['dataset_id'], run_id=run['id'], graph_sha256=run['graph_sha256'], - release_id=run['release_id'], release_sha256=run['release_sha256'], release_path=run['release_path'], + release_id=run['release_id'], release_sha256=run['release_sha256'], release_path=run.get('release_path',run['release_id']), output_dir=str(output_dir or run['id']), parameters=run['parameters'], parent_pid=1, adapter=run['adapter'], evidence=dict(catalog_id=run['dataset_id'], material_sha256=context['material_sha256'], adapter_sha256=context['adapter_sha256'], model_sha256=context['model_sha256'], task=owner.task_ref(run['dataset_id']), profile_sha256=digest(context['profile']), @@ -351,20 +357,23 @@ def job_for_run(run, output_dir=None): def verify(directory, run, metrics): + return verify_blobs(run,metrics,read_blob=lambda name,maximum:read_binary(Path(directory)/name,maximum)) + + +def verify_blobs(run, metrics, *, read_blob): """Inert query/publication semantics; executable replay belongs to the worker.""" - root = Path(directory) + def read(name):return decode_document(read_blob(name,8*1024**2),name) owner = contract(run['adapter']) records = run['source_records'] manifest = dict(records=records, class_vocabulary=metrics['class_vocabulary']) - inputs = read_document(root, 'probe-inputs.json')['inputs'] + inputs = read('probe-inputs.json')['inputs'] if owner.task == 'text_classification': inputs = [row['text'] for row in records if row['split'] == 'validation'] elif (type(inputs) is not list or len(inputs) != sum(row['split'] == 'validation' for row in records) or any(type(row) is not list or len(row) != 8 or any(type(value) not in (int, float) or not math.isfinite(value) for value in row) for row in inputs)): _invalid('Frozen audio probe measurements are invalid.') - body = verify_directory(directory, job=job_for_run(run, directory), adapter=owner.adapter.ADAPTER, manifest=manifest, - reference_keys=owner.adapter.REFERENCE_KEYS, expected_inputs=inputs, reconstruct=False) - read = lambda name: read_document(root, name) + body = verify_directory(None, job=job_for_run(run), adapter=owner.adapter.ADAPTER, manifest=manifest, + reference_keys=owner.adapter.REFERENCE_KEYS, expected_inputs=inputs, reconstruct=False,read_blob=read_blob) return dict(state='verified', manifest=body, checkpoint_index=read('checkpoints.json'), probe=read('probe.json'), metric_definitions=read('metric-definitions.json'), observations=read('observations.json'), model_definition=read('model-definition.json')), [read('checkpoint-fit-0001.json')], [read('probes-fit-0001.json')] diff --git a/pipeline.py b/pipeline.py index 7dd0977b..82bd30ba 100644 --- a/pipeline.py +++ b/pipeline.py @@ -5,6 +5,7 @@ """ from access import authorized import copy +from datetime import datetime import hashlib import json import math @@ -42,6 +43,32 @@ def digest(value): return hashlib.sha256(encode(value).encode()).hexdigest() +def decode_exposures(value): + """Decode exact reuse observations; historical facts confer no permission.""" + closed(value, ('exposed','events')) + if type(value['exposed']) is not bool or type(value['events']) is not list: + raise WorkbenchError('Invalid exposure observation.') + if value['exposed'] != bool(value['events']): + raise WorkbenchError('Exposure flag differs from its observed events.') + seen=set() + for event in value['events']: + closed(event, ('id','record_id','source_hash','reason','run_id','release_id','created_at')) + for key in ('id','record_id','run_id'):identifier(event[key], 'Exposure '+key) + if event['id'] in seen:raise WorkbenchError('Duplicate exposure event identity.') + seen.add(event['id']) + for key in ('source_hash','release_id'): + if type(event[key]) is not str or not HASH.fullmatch(event[key]): + raise WorkbenchError('Invalid exposure '+key+'.') + text_value(event['reason'], 'Exposure reason', 500) + at=event['created_at'] + if type(at) is not str or len(at)>80: + raise WorkbenchError('Invalid exposure observation time.') + try: + if datetime.fromisoformat(at).utcoffset() is None:raise ValueError() + except ValueError as error:raise WorkbenchError('Invalid exposure observation time.') from error + return copy.deepcopy(value) + + def _external_preflight_report(graph, prepared, *, storage=None, version=1): """Project fresh prepare() proofs; this is neither admission nor a retained proof cache.""" material = prepared['material'] @@ -263,6 +290,257 @@ def source_content_hash(row): return row['content_hash'] if row['kind'] == 'text' else row['exported_pixel_sha256'] +def decode_recipe_draft(value, *, catalog_id): + from model_definitions import checked_json, natural, reference, text + value = checked_json(value) + closed(value, ('format', 'id', 'revision', 'name', 'task', 'data', 'model', 'trainer', + 'parameters', 'evaluation_profile', 'runtime', 'layout')) + if value['format'] != 'recipe_draft_v2': + raise WorkbenchError('Unsupported model-aware recipe format.', 'unsupported', 400) + identifier(value['id']); natural(value['revision'], 'Recipe revision', 1) + text(value['name'], 'Recipe name') + for key, kind in (('task', 'task'), ('model', 'model'), ('trainer', 'trainer'), + ('evaluation_profile', 'profile'), ('runtime', 'runtime')): + reference(value[key], kind, catalog_id) + if type(value['data']) is not list or not 1 <= len(value['data']) <= 64: + raise WorkbenchError('A model-aware recipe requires1–64 immutable data references.') + for item in value['data']: reference(item, 'release', catalog_id) + if type(value['parameters']) is not dict or type(value['layout']) is not dict or len(value['layout']) > 10: + raise WorkbenchError('Recipe parameters/layout require bounded objects.') + for key, position in value['layout'].items(): + if not NODE_ID.fullmatch(key): raise WorkbenchError('Invalid workflow layout key.') + closed(position, ('x', 'y')) + if any(type(number) not in (int, float) or not math.isfinite(number) or abs(number)>10000 + for number in position.values()): + raise WorkbenchError('Workflow layout requires finite bounded coordinates.') + return value + + + +def decode_recipe_material(value, *, catalog_id): + from model_definitions import checked_json,reference,natural + value=checked_json(value);closed(value,('ref','material'));material=value['material'] + closed(material,('format','catalog_id','recipe_id','recipe_revision','recipe_document','executable_sha256', + 'model','data','trainer','evaluation_profile','resolved_profile','runtime','captures','resource_envelope')) + ref=reference(value['ref'],'recipe_material',catalog_id) + if (material['format']!='recipe_material_v1' or digest(material)!=ref['sha256'] or + material['catalog_id']!=catalog_id): + raise WorkbenchError('Recipe material identity differs.','unavailable',409) + identifier(material['recipe_id']);natural(material['recipe_revision'],'Recipe revision',1) + for key,kind in (('model','model'),('trainer','trainer'),('evaluation_profile','profile'),('runtime','runtime')): + reference(material[key],kind,catalog_id) + if type(material['executable_sha256']) is not str or not HASH.fullmatch(material['executable_sha256']): + raise WorkbenchError('Recipe requires an exact execution identity.','unavailable',409) + if (type(material['data']) is not list or + material['data'] != [] and (len(material['data']) != 1 or material['trainer']['id'] not in BUILTIN_TRAINERS) or + type(material['captures']) is not list or not 1<=len(material['captures'])<=recipe_contract.MAX_FILES): + raise WorkbenchError('Unsupported external material inventory.','unavailable',409) + for item in material['data']:reference(item,'release',catalog_id) + envelope=material['resource_envelope'];closed(envelope,('attempt_slots','admitted_wall_seconds','retained_bytes','peak_staging_bytes')) + for number in envelope.values():natural(number,'Resource envelope',1) + inventory=[material['recipe_document'],material['resolved_profile'],*material['captures']] + names=[];total=0 + for item in inventory: + closed(item,('name','sha256','bytes','media_type'));natural(item['bytes'],'Capture bytes',0,recipe_contract.CPU_CAPTURE_BYTES) + if (type(item['name']) is not str or not re.fullmatch(r'(?:recipe\.json|profile\.json|capture-\d{4}\.bin)',item['name']) or + type(item['sha256']) is not str or not HASH.fullmatch(item['sha256']) or + item['media_type']!=('application/octet-stream' if item['name'].startswith('capture-') else 'application/json')): + raise WorkbenchError('Invalid recipe blob identity.','unavailable',409) + if item['name'] in ('recipe.json','profile.json') and item['bytes']>2*1024**2: + raise WorkbenchError('Recipe/profile document exceeds its bound.','unavailable',409) + names.append(item['name']);total+=item['bytes'] + if len(set(names))!=len(names) or total>recipe_contract.CPU_CAPTURE_BYTES+2*1024**2: + raise WorkbenchError('Recipe inventory exceeds its bound.','unavailable',409) + if material['recipe_document']['name']!='recipe.json' or material['resolved_profile']['name']!='profile.json': + raise WorkbenchError('Recipe document names differ.','unavailable',409) + return value + + +def _verify_extension_closure(composition, model, *, catalog_id, read_blob): + import extension_evidence as evidence + from model_definitions import retained_json,reference,MAX_SOURCE_BYTES,decode_model_material + ref=reference(composition['ref'],'recipe_material',catalog_id) + material=composition['material'];sha=ref['sha256'] + if digest(material)!=sha or material['trainer']['id']!=recipe_contract.EVIDENCE_FORMAT: + raise WorkbenchError('Extension composition identity differs.','unavailable',409) + snapshot=retained_json(read_blob(ref,material['recipe_document'],2*1024**2).decode(),2*1024**2) + closed(snapshot,('format','graph','preflight','capture_paths','evidence_context')) + execution=snapshot['preflight']['material'];graph=decode_graph(snapshot['graph']) + recipe_contract.validate_material(execution) + runtime_sha=digest(execution.get('torch_runtime',execution.get('cpu_snapshot',execution['runtime']))) + if material['runtime']!={'kind':'runtime','catalog_id':catalog_id, + 'id':runtime_sha,'sha256':runtime_sha}: + raise WorkbenchError('Retained extension runtime differs from its measured execution descriptor.','unavailable',409) + if (snapshot['format']!='external_model_evidence_snapshot_v1' or + not recipe_contract.is_evidence(execution['recipe']) or + execution['catalog_id']!=catalog_id or + graph['id']!=material['recipe_id'] or graph['revision']!=material['recipe_revision'] or + digest(execution)!=material['executable_sha256'] or + snapshot['preflight']['material_sha256']!=material['executable_sha256'] or + execution['graph_execution_sha256']!=execution_digest(graph)): + raise WorkbenchError('Retained extension execution binding differs.','unavailable',409) + model_ref=reference(material['model'],'model',catalog_id) + model=decode_model_material(model,catalog_id=catalog_id) + if not evidence.identical(model['ref'],model_ref): + raise WorkbenchError('Retained extension Model identity differs.','unavailable',409) + # Read the exact Model closure; no imports or deserialization of state. + for item in [*(model['material'][key] for key in ('specification','preprocessing_policy','output_policy')), + *(item['blob'] for item in model['material']['source_closure'])]: + read_blob(model_ref,item,MAX_SOURCE_BYTES) + sources={item['import_path']:item['blob'] for item in model['material']['source_closure']} + paths=snapshot['capture_paths'] + if (type(paths) is not list or len(paths)!=len(material['captures']) or + len({item['path'] for item in paths})!=len(paths) or + not evidence.identical(sorted([item['blob'] for item in paths],key=lambda item:item['name']), + sorted(material['captures'],key=lambda item:item['name']))): + raise WorkbenchError('Retained extension capture map differs.','unavailable',409) + captured={} + for item in paths: + closed(item,('path','blob')) + captured[item['path']]=read_blob(ref,item['blob'],recipe_contract.CPU_CAPTURE_BYTES) + expected={path:{'bytes':len(raw),'sha256':hashlib.sha256(raw).hexdigest()} for path,raw in captured.items()} + if not evidence.identical(expected,execution['captures']): + raise WorkbenchError('Retained extension captures differ.','unavailable',409) + for path,raw in captured.items(): + if path.startswith('source/'): + original=sources.get(path[len('source/'):]) + if original is None or original['sha256']!=hashlib.sha256(raw).hexdigest() or original['bytes']!=len(raw): + raise WorkbenchError('Retained Model and execution source differ.','unavailable',409) + recipe_contract.validate_bound_captures(execution,captured) + recipe_contract.validate_canonical_captures(execution,captured) + bound=recipe_contract.is_bound_evidence(execution['recipe']) + resolved_spec=retained_json(read_blob(model_ref,model['material']['specification'],MAX_SOURCE_BYTES).decode()) if bound else None + static=evidence.resolve(execution['recipe']['model_evidence'],model,execution, + model_specification=resolved_spec,binding=execution.get('extension_binding'),captured=captured) + profile=retained_json(read_blob(ref,material['resolved_profile'],2*1024**2).decode(),2*1024**2) + if (not evidence.identical(static,snapshot['evidence_context']) or not evidence.identical(profile,static['profile']) or + not evidence.identical(material['evaluation_profile'],static['profile']['definition']) or + material['trainer']['sha256']!=digest(execution['recipe'])): + raise WorkbenchError('Retained extension profile/context differs.','unavailable',409) + return {'kind':'external','graph':graph,'execution_material':execution,'static_context':static, + 'captured_inputs':recipe_contract.captured_inputs(execution,captured)} + + + +def verify_recipe_closure(composition, model, *, read_blob): + """Exact retained closure only; never qualify a current runtime or source tree. + + read_blob receives the exact owner ref, inventory item and maximum bytes. + Public callers separately authorize disclosure before entering this decoder. + """ + from model_definitions import decode_model_material,verify_model_closure,retained_json,MAX_SOURCE_BYTES + original_reader=read_blob + def read_blob(ref,item,maximum): + raw=original_reader(ref,item,maximum) + if type(raw) is not bytes or len(raw)>maximum or len(raw)!=item['bytes'] or hashlib.sha256(raw).hexdigest()!=item['sha256']: + raise WorkbenchError('Retained composition blob differs.','unavailable',409) + return raw + catalog_id=composition['ref']['catalog_id'] + composition=decode_recipe_material(composition,catalog_id=catalog_id) + model=decode_model_material(model,catalog_id=catalog_id) + material=composition['material'];ref=composition['ref'] + if model['ref']!=material['model']: + raise WorkbenchError('Recipe and Model references differ.','unavailable',409) + verify_model_closure(model,read_blob=lambda item,maximum:read_blob(model['ref'],item,maximum)) + if material['trainer']['id']==recipe_contract.EVIDENCE_FORMAT: + return _verify_extension_closure(composition,model,catalog_id=catalog_id,read_blob=read_blob) + snapshot=retained_json(read_blob(ref,material['recipe_document'],2*1024**2).decode(),2*1024**2) + graph=decode_graph(snapshot['graph']) + if graph['id']!=material['recipe_id'] or graph['revision']!=material['recipe_revision']: + raise WorkbenchError('Retained recipe graph differs.','unavailable',409) + paths=snapshot['capture_paths'] + if type(paths) is not list or len(paths)!=len(material['captures']): + raise WorkbenchError('Retained capture map differs.','unavailable',409) + captures={};names=[] + for item in paths: + closed(item,('path','blob'));recipe_contract.relative_path(item['path']) + if item['path'] in captures or item['blob'] not in material['captures']: + raise WorkbenchError('Retained capture is duplicate or undeclared.','unavailable',409) + captures[item['path']]=read_blob(ref,item['blob'],recipe_contract.CPU_CAPTURE_BYTES);names.append(item['blob']['name']) + if len(set(names))!=len(names):raise WorkbenchError('Retained capture mapping repeats a blob.','unavailable',409) + for key in ('specification','preprocessing_policy','output_policy'): + read_blob(model['ref'],model['material'][key],MAX_SOURCE_BYTES) + sources={item['import_path']:item['blob'] for item in model['material']['source_closure']} + if snapshot['format']=='builtin_recipe_snapshot_v1': + closed(snapshot,('format','graph','validation','model_definition','capture_paths')) + validation=snapshot['validation'] + if (not validation.get('valid') or validation['execution_sha256']!=material['executable_sha256'] + or execution_digest(graph)!=material['executable_sha256'] + or {name:hashlib.sha256(raw).hexdigest() for name,raw in captures.items()}!=validation['runtime']['definition_files'] + or {name:item['sha256'] for name,item in sources.items()}!=validation['runtime']['definition_files'] + or material['runtime']['sha256']!=digest(validation['runtime'])): + raise WorkbenchError('Retained built-in execution/source/runtime differs.','unavailable',409) + for key in ('specification','preprocessing_policy','output_policy'): + if retained_json(read_blob(model['ref'],model['material'][key],MAX_SOURCE_BYTES).decode())!=snapshot['model_definition'][key]: + raise WorkbenchError('Retained built-in Model policy differs.','unavailable',409) + owner=model_contract(graph) + profile=retained_json(read_blob(ref,material['resolved_profile'],2*1024**2).decode(),2*1024**2) + expected_profile=owner.profile(catalog_id,model['ref']['sha256'],snapshot['model_definition']['parameters'],validation['release']['split_counts']['validation']) + trainer=adapter_identity_for_graph(graph) + if (profile!=expected_profile or material['evaluation_profile']!=profile['definition'] or + material['trainer']!={'kind':'trainer','catalog_id':catalog_id,'id':trainer['name']+'_v2','revision':2,'sha256':digest(trainer)} or + material['data']!=[{'kind':'release','catalog_id':catalog_id,'id':validation['release']['id'],'sha256':validation['release']['sha256']}]): + raise WorkbenchError('Retained built-in profile, trainer or data differs.','unavailable',409) + return {'kind':'builtin','graph':graph,'execution_material':validation,'static_context':None,'captured_inputs':{}} + closed(snapshot,('format','graph','preflight','capture_paths')) + if snapshot['format']!='external_recipe_snapshot_v1':raise WorkbenchError('Unsupported retained recipe snapshot.','unsupported',409) + preflight=snapshot['preflight'];execution=preflight['material'];recipe_contract.validate_material(execution) + runtime=execution.get('torch_runtime',execution.get('cpu_snapshot',execution['runtime'])) + expected={name:{'bytes':len(raw),'sha256':hashlib.sha256(raw).hexdigest()} for name,raw in captures.items()} + if (digest(execution)!=material['executable_sha256'] or preflight['material_sha256']!=material['executable_sha256'] + or execution['catalog_id']!=catalog_id or execution['graph_execution_sha256']!=execution_digest(graph) + or expected!=execution['captures'] or material['runtime']['sha256']!=digest(runtime) + or material['trainer']['sha256']!=digest(execution['recipe'])): + raise WorkbenchError('Retained external execution/capture differs.','unavailable',409) + for path,raw in captures.items(): + if path.startswith('source/'): + source_blob=sources.get(path[len('source/'):]) + if source_blob is None or source_blob['sha256']!=hashlib.sha256(raw).hexdigest() or source_blob['bytes']!=len(raw): + raise WorkbenchError('Retained Model/execution source differs.','unavailable',409) + profile=retained_json(read_blob(ref,material['resolved_profile'],2*1024**2).decode(),2*1024**2) + if profile!={'format':'opaque_external_outputs_v1','outputs':preflight['outputs'], + 'numerical_evidence':'unsupported','automatic_checkpoints':'unsupported'}: + raise WorkbenchError('Retained opaque profile differs.','unavailable',409) + return {'kind':'external','graph':graph,'execution_material':execution,'static_context':None, + 'captured_inputs':recipe_contract.captured_inputs(execution,captures)} + + +def decode_probe_observation(recipe,value,*,with_definition=False): + """The finite native/pure runtime observation shared by current readers.""" + fields=('schema_version','available','adapter','runtime','problems') + closed(value,fields+(('definition_files','adapter_definition_sha256') if with_definition else ())) + if value['schema_version']!=1 or type(value['available']) is not bool or value['adapter']!=adapter_identity_for_graph({'recipe':recipe}): + raise WorkbenchError('Malformed native probe identity.','invalid',409) + adapter=adapter_for_graph({'recipe':recipe});runtime=value['runtime'] + if adapter is release_validation: + closed(runtime,('python','execution')) + if runtime['execution']!='bounded_local_integrity':raise WorkbenchError('Invalid integrity runtime observation.') + versions=() + else: + versions=tuple(name.replace('-','_') for name in image_training.RUNTIME_PINS) if adapter is image_training else ('scikit_learn','numpy','scipy','Pillow') + closed(runtime,('python','device','threads',*versions)) + if runtime['device']!='cpu' or type(runtime['threads']) is not int or not 1<=runtime['threads']<=65536: + raise WorkbenchError('Invalid native CPU runtime observation.') + if type(runtime['python']) is not str or not 1<=len(runtime['python'])<=80: + raise WorkbenchError('Invalid native Python version observation.') + for name in versions: + if runtime[name] is not None and (type(runtime[name]) is not str or not 1<=len(runtime[name])<=120): + raise WorkbenchError('Invalid native package version observation.') + if type(value['problems']) is not list or len(value['problems'])>100:raise WorkbenchError('Invalid native runtime problems.') + for problem in value['problems']: + closed(problem,('code','message')) + if any(type(problem[key]) is not str or not 1<=len(problem[key])<=2000 for key in problem): + raise WorkbenchError('Invalid native runtime problem.') + if with_definition: + files=value['definition_files'] + if type(files) is not dict or not 1<=len(files)<=10000:raise WorkbenchError('Invalid runtime source inventory.') + for name,sha in files.items(): + if type(name) is not str or Path(name).name!=name or not name.endswith('.py') or type(sha) is not str or not HASH.fullmatch(sha): + raise WorkbenchError('Invalid runtime source identity.') + if digest(files)!=value['adapter_definition_sha256']:raise WorkbenchError('Runtime source digest differs.') + return copy.deepcopy(value) + + class Pipelines: def __init__(self, dataset): self.access = dataset.access @@ -628,7 +906,7 @@ def exposures(self, records): 'SELECT id,record_id,source_hash,reason,run_id,release_id,created_at FROM workspace_exposures ' 'WHERE record_id = ANY(%s) OR source_hash = ANY(%s) ORDER BY created_at,id', ([row['id'] for row in records], [source_content_hash(row) for row in records]))] - return {'exposed': bool(exposures), 'events': exposures} + return decode_exposures({'exposed': bool(exposures), 'events': exposures}) @authorized('inspect', 'pipeline.releases') def releases(self): @@ -654,6 +932,10 @@ def probe(self, recipe='image_classification'): # A native interpreter launch is execution even when its command only # reports runtime capability. Pure integrity recipes above never spawn. self.access.require('execute', 'pipeline.probe_native', recipe) + from project_native_preparation import preparation_owner + current=preparation_owner(self) + if current is not None: + return current.probe(self,recipe) worker_path = Path(adapter.__file__).resolve() executable = os.environ.get('TULDOK_TRAINING_PYTHON') or sys.executable try: @@ -662,10 +944,7 @@ def probe(self, recipe='image_classification'): if len(response.stdout) > 32768: raise ValueError('Oversized runtime response.') probe = json.loads(response.stdout) - if (not isinstance(probe, dict) or probe.get('schema_version') != 1 or - type(probe.get('available')) is not bool or probe.get('adapter') != adapter_identity_for_graph({'recipe':recipe}) - or not isinstance(probe.get('runtime'), dict) or not isinstance(probe.get('problems'), list)): - raise ValueError('Malformed runtime response.') + probe=decode_probe_observation(recipe,probe) if response.returncode and probe['available']: raise ValueError('Runtime probe failed.') except (OSError, ValueError, subprocess.TimeoutExpired): @@ -790,29 +1069,7 @@ def snapshot(self, graph_id, revision, validation_token): return graph, result, self.inspect_release(result['release']['id'], private=True, recipe=graph.get('recipe', 'image_classification')) def _recipe_draft(self, value): - from model_definitions import checked_json, natural, reference, text - value = checked_json(value) - closed(value, ('format', 'id', 'revision', 'name', 'task', 'data', 'model', 'trainer', - 'parameters', 'evaluation_profile', 'runtime', 'layout')) - if value['format'] != 'recipe_draft_v2': - raise WorkbenchError('Unsupported model-aware recipe format.', 'unsupported', 400) - identifier(value['id']); natural(value['revision'], 'Recipe revision', 1) - text(value['name'], 'Recipe name') - for key, kind in (('task', 'task'), ('model', 'model'), ('trainer', 'trainer'), - ('evaluation_profile', 'profile'), ('runtime', 'runtime')): - reference(value[key], kind, self.db.dataset_id) - if type(value['data']) is not list or not 1 <= len(value['data']) <= 64: - raise WorkbenchError('A model-aware recipe requires1–64 immutable data references.') - for item in value['data']: reference(item, 'release', self.db.dataset_id) - if type(value['parameters']) is not dict or type(value['layout']) is not dict or len(value['layout']) > 10: - raise WorkbenchError('Recipe parameters/layout require bounded objects.') - for key, position in value['layout'].items(): - if not NODE_ID.fullmatch(key): raise WorkbenchError('Invalid workflow layout key.') - closed(position, ('x', 'y')) - if any(type(number) not in (int, float) or not math.isfinite(number) or abs(number)>10000 - for number in position.values()): - raise WorkbenchError('Workflow layout requires finite bounded coordinates.') - return value + return decode_recipe_draft(value,catalog_id=self.db.dataset_id) def _get_recipe(self, recipe_id): from model_definitions import retained_json @@ -897,6 +1154,21 @@ def model_template(self, draft): names = self.definition_identity(image_training.MODEL_RECIPE)['definition_files'] return 'image_training.py', {name:(self.source_path/name).read_bytes() for name in names} + def _builtin_model_for_graph(self,graph,model_ref): + """The existing exact template/policy decision, before native probing.""" + from model_definitions import read_owned_blob,retained_json + owner=model_contract(graph) + model = self.dataset.models.material(model_ref, verify=True) + model_body = model['material'] + definition = owner.model_definition(next(node['parameters'] for node in graph['nodes'] if node['type']=='train')) + if model_body['kind'] != 'template' or model_body['task'] != owner.task_ref(self.db.dataset_id): + raise WorkbenchError('Recipe requires its exact published classifier template.', 'unsupported', 409) + for key in ('specification','preprocessing_policy','output_policy'): + raw=read_owned_blob(self.dataset.models.path,model_ref['sha256'],model_body[key],1024**2) + if retained_json(raw.decode()) != definition[key]: + raise WorkbenchError('Published model policies differ from recipe parameters.', 'stale', 409) + return model,definition + @authorized('edit', 'recipes.prepare_builtin_material') def prepare_builtin_material(self, graph_id, revision, model_ref, *, reconcile_capture=False): from model_definitions import blob, read_owned_blob, retained_json @@ -910,15 +1182,8 @@ def prepare_builtin_material(self, graph_id, revision, model_ref, *, reconcile_c validation = self.validate_graph(graph) if not validation['valid']: error = validation['errors'][0]; raise WorkbenchError(error['message'], error['code'], 409) - model = self.dataset.models.material(model_ref, verify=True) - model_body = model['material'] - definition = owner.model_definition(next(node['parameters'] for node in graph['nodes'] if node['type']=='train')) - if model_body['kind'] != 'template' or model_body['task'] != owner.task_ref(self.db.dataset_id): - raise WorkbenchError('Recipe requires its exact published classifier template.', 'unsupported', 409) - for key in ('specification','preprocessing_policy','output_policy'): - raw=read_owned_blob(self.dataset.models.path,model_ref['sha256'],model_body[key],1024**2) - if retained_json(raw.decode()) != definition[key]: - raise WorkbenchError('Published model policies differ from recipe parameters.', 'stale', 409) + model,definition=self._builtin_model_for_graph(graph,model_ref) + model_body=model['material'] files=validation['runtime']['definition_files'] captured={name:(self.source_path/name).read_bytes() for name in files} if any(hashlib.sha256(raw).hexdigest()!=files[name] for name,raw in captured.items()): @@ -935,7 +1200,8 @@ def prepare_builtin_material(self, graph_id, revision, model_ref, *, reconcile_c capture_bytes=sum(map(len,payloads.values())) model_bytes=sum(item['blob']['bytes'] for item in model_body['source_closure'])+sum(model_body[key]['bytes'] for key in ('specification','preprocessing_policy','output_policy')) output_bytes=64*1024**2 - envelope={'attempt_slots':1,'admitted_wall_seconds':750,'retained_bytes':capture_bytes+model_bytes+output_bytes+256*1024, + from project_native_execution import native_admission_wall_seconds + envelope={'attempt_slots':1,'admitted_wall_seconds':native_admission_wall_seconds(),'retained_bytes':capture_bytes+model_bytes+output_bytes+256*1024, 'peak_staging_bytes':2*capture_bytes+model_bytes+2*output_bytes+256*1024} material={'format':'recipe_material_v1','catalog_id':self.db.dataset_id,'recipe_id':graph_id,'recipe_revision':revision, 'recipe_document':blob(payloads['recipe.json'],'recipe.json'),'executable_sha256':validation['execution_sha256'], @@ -956,6 +1222,12 @@ def prepare_builtin_material(self, graph_id, revision, model_ref, *, reconcile_c shutil.rmtree(staging,ignore_errors=True);raise def _prepare_builtin_recipe_material(self, value): + from project_native_preparation import preparation_owner + current=preparation_owner(self) + if current is not None:return current.current(self).capture_builtin(self,value) + return self._prepare_builtin_recipe_material_current(value) + + def _prepare_builtin_recipe_material_current(self, value): from model_definitions import read_owned_blob, retained_json material=value['material'];sha=value['ref']['sha256'] snapshot=retained_json(read_owned_blob(self.material_path,sha,material['recipe_document'],2*1024**2).decode(),2*1024**2) @@ -1032,13 +1304,14 @@ def definition(kind,name,value): model['material'][key]['bytes'] for key in ('specification','preprocessing_policy','output_policy')) import recipe_process # The same measured runtime facets select the custody owner's readiness - # and post-GO verification budgets in the final execution specification. + # and post-GO verification budgets. New admissions also reserve the + # custodian's separate finite wait for acknowledged owner GO intent. ready=recipe_process.preparation_seconds(preflight['material']) verification=recipe_process.verification_seconds(preflight['material']) output_bytes=preflight['material']['resources']['max_output_bytes'] custody_bytes=2*recipe_process.STREAM_LIMIT+2*16384+256*1024 envelope={'attempt_slots':1,'admitted_wall_seconds':preflight['material']['resources']['wall_seconds']+ - ready+2+verification+15, + ready+2+recipe_process.GO_WAIT_SECONDS+verification+15, 'retained_bytes':capture_bytes+model_bytes+output_bytes+custody_bytes, 'peak_staging_bytes':2*capture_bytes+model_bytes+2*output_bytes+custody_bytes} if evidence: @@ -1119,32 +1392,9 @@ def _recipe_material_record(self, ref): row=self.db.execute('SELECT recipe_id,recipe_revision,document FROM workspace_recipe_materials WHERE sha256=%s', (ref['sha256'],)).fetchone() if row is None:raise WorkbenchError('Recipe material not found.','not_found',404) - value=retained_json(row['document']);closed(value,('ref','material'));material=value['material'] - closed(material,('format','catalog_id','recipe_id','recipe_revision','recipe_document','executable_sha256', - 'model','data','trainer','evaluation_profile','resolved_profile','runtime','captures','resource_envelope')) - if (value['ref']!=ref or material['format']!='recipe_material_v1' or digest(material)!=ref['sha256'] or - material['catalog_id']!=self.db.dataset_id or row['recipe_id']!=material['recipe_id'] or - row['recipe_revision']!=material['recipe_revision']): - raise WorkbenchError('Recipe material identity differs.','unavailable',409) - identifier(material['recipe_id']);natural(material['recipe_revision'],'Recipe revision',1) - for key,kind in (('model','model'),('trainer','trainer'),('evaluation_profile','profile'),('runtime','runtime')): - reference(material[key],kind,self.db.dataset_id) - if (material['data'] != [] and (len(material['data']) != 1 or material['trainer']['id'] not in BUILTIN_TRAINERS)) or type(material['captures']) is not list or not 1<=len(material['captures'])<=recipe_contract.MAX_FILES: - raise WorkbenchError('Unsupported external material inventory.','unavailable',409) - envelope=material['resource_envelope'];closed(envelope,('attempt_slots','admitted_wall_seconds','retained_bytes','peak_staging_bytes')) - for number in envelope.values():natural(number,'Resource envelope',1) - inventory=[material['recipe_document'],material['resolved_profile'],*material['captures']] - names=[];total=0 - for item in inventory: - closed(item,('name','sha256','bytes','media_type'));natural(item['bytes'],'Capture bytes',0,recipe_contract.CPU_CAPTURE_BYTES) - if (type(item['name']) is not str or not re.fullmatch(r'(?:recipe\.json|profile\.json|capture-\d{4}\.bin)',item['name']) or - type(item['sha256']) is not str or not HASH.fullmatch(item['sha256'])): - raise WorkbenchError('Invalid recipe blob identity.','unavailable',409) - names.append(item['name']);total+=item['bytes'] - if len(set(names))!=len(names) or total>recipe_contract.CPU_CAPTURE_BYTES+2*1024**2: - raise WorkbenchError('Recipe inventory exceeds its bound.','unavailable',409) - if material['recipe_document']['name']!='recipe.json' or material['resolved_profile']['name']!='profile.json': - raise WorkbenchError('Recipe document names differ.','unavailable',409) + value=decode_recipe_material(retained_json(row['document']),catalog_id=self.db.dataset_id) + if value['ref']!=ref or row['recipe_id']!=value['material']['recipe_id'] or row['recipe_revision']!=value['material']['recipe_revision']: + raise WorkbenchError('Recipe row identity differs.','unavailable',409) return value def recipe_material(self, ref, *, verify=False): @@ -1175,76 +1425,16 @@ def recipe_material(self, ref, *, verify=False): return value def _retained_extension_static(self, composition, *, with_captured_inputs=False): - """Pure retained integrity for Runs recovery; public callers guard access. - - No current project, runtime, draft or caller context document is consulted. - Like Runs._load this private primitive does not authorize disclosure. - """ - import extension_evidence as evidence - from model_definitions import read_owned_blob,retained_json,reference,MAX_SOURCE_BYTES - ref=reference(composition['ref'],'recipe_material',self.db.dataset_id) - material=composition['material'];sha=ref['sha256'] - if digest(material)!=sha or material['trainer']['id']!=recipe_contract.EVIDENCE_FORMAT: - raise WorkbenchError('Extension composition identity differs.','unavailable',409) - snapshot=retained_json(read_owned_blob(self.material_path,sha,material['recipe_document'],2*1024**2).decode(),2*1024**2) - closed(snapshot,('format','graph','preflight','capture_paths','evidence_context')) - execution=snapshot['preflight']['material'];graph=decode_graph(snapshot['graph']) - recipe_contract.validate_material(execution) - runtime_sha=digest(execution.get('torch_runtime',execution.get('cpu_snapshot',execution['runtime']))) - if material['runtime']!={'kind':'runtime','catalog_id':self.db.dataset_id, - 'id':runtime_sha,'sha256':runtime_sha}: - raise WorkbenchError('Retained extension runtime differs from its measured execution descriptor.','unavailable',409) - if (snapshot['format']!='external_model_evidence_snapshot_v1' or - not recipe_contract.is_evidence(execution['recipe']) or - execution['catalog_id']!=self.db.dataset_id or - graph['id']!=material['recipe_id'] or graph['revision']!=material['recipe_revision'] or - digest(execution)!=material['executable_sha256'] or - snapshot['preflight']['material_sha256']!=material['executable_sha256'] or - execution['graph_execution_sha256']!=execution_digest(graph)): - raise WorkbenchError('Retained extension execution binding differs.','unavailable',409) - model_ref=reference(material['model'],'model',self.db.dataset_id) + from model_definitions import retained_json,read_owned_blob + model_ref=composition['material']['model'] row=self.db.execute('SELECT document FROM model_materials WHERE id=%s AND revision=%s AND sha256=%s', - (model_ref['id'],model_ref['revision'],model_ref['sha256'])).fetchone() + (model_ref['id'],model_ref['revision'],model_ref['sha256'])).fetchone() if row is None:raise WorkbenchError('Retained extension Model is unavailable.','unavailable',409) - model=retained_json(row[0]);closed(model,('ref','material')) - if not evidence.identical(model['ref'],model_ref) or digest(model['material'])!=model_ref['sha256']: - raise WorkbenchError('Retained extension Model identity differs.','unavailable',409) - model_root=self.dataset.path/'model-materials' - # Read the exact Model closure; no imports or deserialization of state. - for item in [*(model['material'][key] for key in ('specification','preprocessing_policy','output_policy')), - *(item['blob'] for item in model['material']['source_closure'])]: - read_owned_blob(model_root,model_ref['sha256'],item,MAX_SOURCE_BYTES) - sources={item['import_path']:item['blob'] for item in model['material']['source_closure']} - paths=snapshot['capture_paths'] - if (type(paths) is not list or len(paths)!=len(material['captures']) or - len({item['path'] for item in paths})!=len(paths) or - not evidence.identical(sorted([item['blob'] for item in paths],key=lambda item:item['name']), - sorted(material['captures'],key=lambda item:item['name']))): - raise WorkbenchError('Retained extension capture map differs.','unavailable',409) - captured={} - for item in paths: - closed(item,('path','blob')) - captured[item['path']]=read_owned_blob(self.material_path,sha,item['blob'],recipe_contract.CPU_CAPTURE_BYTES) - expected={path:{'bytes':len(raw),'sha256':hashlib.sha256(raw).hexdigest()} for path,raw in captured.items()} - if not evidence.identical(expected,execution['captures']): - raise WorkbenchError('Retained extension captures differ.','unavailable',409) - for path,raw in captured.items(): - if path.startswith('source/'): - original=sources.get(path[len('source/'):]) - if original is None or original['sha256']!=hashlib.sha256(raw).hexdigest() or original['bytes']!=len(raw): - raise WorkbenchError('Retained Model and execution source differ.','unavailable',409) - recipe_contract.validate_bound_captures(execution,captured) - recipe_contract.validate_canonical_captures(execution,captured) - bound=recipe_contract.is_bound_evidence(execution['recipe']) - resolved_spec=retained_json(read_owned_blob(model_root,model_ref['sha256'],model['material']['specification'],MAX_SOURCE_BYTES).decode()) if bound else None - static=evidence.resolve(execution['recipe']['model_evidence'],model,execution, - model_specification=resolved_spec,binding=execution.get('extension_binding'),captured=captured) - profile=retained_json(read_owned_blob(self.material_path,sha,material['resolved_profile'],2*1024**2).decode(),2*1024**2) - if (not evidence.identical(static,snapshot['evidence_context']) or not evidence.identical(profile,static['profile']) or - not evidence.identical(material['evaluation_profile'],static['profile']['definition']) or - material['trainer']['sha256']!=digest(execution['recipe'])): - raise WorkbenchError('Retained extension profile/context differs.','unavailable',409) - return (static,recipe_contract.captured_inputs(execution,captured)) if with_captured_inputs else static + def read(ref,item,maximum): + root=self.dataset.path/'model-materials' if ref['kind']=='model' else self.material_path + return read_owned_blob(root,ref['sha256'],item,maximum) + closure=verify_recipe_closure(composition,retained_json(row[0]),read_blob=read) + return (closure['static_context'],closure['captured_inputs']) if with_captured_inputs else closure['static_context'] def _prepare_recipe_material(self, ref, *, _verified=None): """Revalidate source/runtime against retained bytes, never the current graph.""" diff --git a/pointclouds.py b/pointclouds.py index 4b807a6c..229e81d7 100644 --- a/pointclouds.py +++ b/pointclouds.py @@ -51,14 +51,24 @@ def constant(value): if isinstance(error, WorkbenchError): raise invalid('malformed UTF-8 JSON.') + value = manifest_declaration(value) + if value['geometry_bytes'] != len(geometry): + invalid('geometry filename or byte count mismatch.') + if hashlib.sha256(geometry).hexdigest() != value['geometry_sha256']: + invalid('geometry SHA256 mismatch or invalid lowercase hex.') + return value + + +def manifest_declaration(value): + """Validate declared source metadata without reading or inventing points.""" exact(value, ('format', 'geometry_file', 'geometry_bytes', 'geometry_sha256', 'units', 'coordinate_system', 'provenance', 'lineage'), 'sidecar') if value['format'] != 'tuldok_pointcloud_v1': raise WorkbenchError('Unsupported point-cloud sidecar version.', 'unsupported') - if value['geometry_file'] != 'points.ply' or type(value['geometry_bytes']) is not int or value['geometry_bytes'] != len(geometry): + if value['geometry_file'] != 'points.ply' or type(value['geometry_bytes']) is not int or not 0 < value['geometry_bytes'] <= PLY_LIMIT: invalid('geometry filename or byte count mismatch.') digest = value['geometry_sha256'] - if type(digest) is not str or not HASH.fullmatch(digest) or hashlib.sha256(geometry).hexdigest() != digest: + if type(digest) is not str or not HASH.fullmatch(digest): invalid('geometry SHA256 mismatch or invalid lowercase hex.') if value['units'] not in ('m', 'cm', 'mm'): invalid('units must be m, cm or mm.') diff --git a/project_artifacts.py b/project_artifacts.py new file mode 100644 index 00000000..ce691bd6 --- /dev/null +++ b/project_artifacts.py @@ -0,0 +1,380 @@ +"""Exact project payload custody, separate from domain role and retention policy. + +Declarations contain intrinsic byte identity only. Domain records own origin, +media type, unique/regenerable policy and permission. Publishing never deletes a +final payload on SQL rollback: unreferenced immutable bytes remain inert. Local +resolution capabilities never enter portable records, and no source is executed. +""" +from contextlib import contextmanager +import copy +import errno +import hashlib +import os +import re +import stat +import threading +import uuid + +from project_store import (ProjectStoreError, anchored_directory, DIR_FLAGS, FILE_FLAGS, + _directory, _physical, _relative_parts, _rename_noreplace, _walk) + +FORMAT = 'project_artifact_v1' +MAX_BYTES = 2 ** 63 - 1 +DEFAULT_READ_BYTES = 40 * 1024 * 1024 +CHUNK_BYTES = 1024 * 1024 + + +class ProjectArtifactError(ProjectStoreError): + pass + + +class _Missing(Exception): + pass + + +def _fail(message, code='invalid', status=400): + raise ProjectArtifactError(message, code, status) + + +def validate_declaration(value): + if type(value) is not dict: + _fail('Artifact declaration must be an object.') + if value.get('format') != FORMAT: + if type(value.get('format')) is str and re.fullmatch(r'project_artifact_v[1-9][0-9]*', value['format']): + _fail('Unsupported artifact declaration format.', 'unsupported', 422) + _fail('Invalid artifact declaration format.') + if set(value) != {'format', 'sha256', 'bytes'}: + _fail('Artifact declarations contain only format, SHA256 and byte count.') + digest, size = value['sha256'], value['bytes'] + if type(digest) is not str or len(digest) != 64 or any(c not in '0123456789abcdef' for c in digest): + _fail('Artifact requires a SHA256 digest.') + if type(size) is not int or not 0 <= size <= MAX_BYTES: + _fail('Artifact byte count must fit a nonnegative signed 64-bit integer.') + return dict(format=FORMAT, sha256=digest, bytes=size) + + +def _maximum(declaration, maximum_bytes): + if maximum_bytes is not None: + if type(maximum_bytes) is not int or not 0 <= maximum_bytes <= MAX_BYTES: + _fail('Artifact caller byte bound must be a nonnegative integer.') + if declaration['bytes'] > maximum_bytes: + _fail('Artifact exceeds the consuming operation byte bound.', 'too_large', 413) + + +def _info(fd, *, rooted=False): + info = os.fstat(fd) + # An explicit caller fd or our unlinked private snapshot can have no name. + # Rooted selected files must have exactly one name; aliases are never accepted. + if not stat.S_ISREG(info.st_mode) or info.st_nlink > 1 or (rooted and info.st_nlink != 1): + _fail('Artifact source must be an ordinary file without hard-link aliases.') + return info + + +def _identity(info): + return info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns + + +def _write(fd, raw): + view = memoryview(raw) + while view: + written = os.write(fd, view) + if not written: + raise OSError(errno.EIO, 'short artifact write') + view = view[written:] + + +def _observe(fd, declaration, *, output=None): + """Observe one bounded source and optionally capture the exact observed bytes.""" + before = _info(fd) + if before.st_size != declaration['bytes']: + return dict(state='corrupt', expected_sha256=declaration['sha256'], expected_bytes=declaration['bytes'], + observed_sha256=None, observed_bytes=before.st_size) + digest, count = hashlib.sha256(), 0 + while True: + raw = os.pread(fd, min(CHUNK_BYTES, declaration['bytes'] + 1 - count), count) + if not raw: + break + count += len(raw) + digest.update(raw) + if output is not None: + _write(output, raw) + if count > declaration['bytes']: + break + after = _info(fd) + if _identity(before) != _identity(after): + _fail('Artifact source changed during observation.', 'stale', 409) + observed = digest.hexdigest() + return dict(state='verified' if count == declaration['bytes'] and observed == declaration['sha256'] else 'corrupt', + expected_sha256=declaration['sha256'], expected_bytes=declaration['bytes'], + observed_sha256=observed, observed_bytes=count) + + +def _verified(observation): + if observation['state'] != 'verified': + failure = ProjectArtifactError('Artifact bytes disagree with their exact declaration.', 'invalid', 400) + failure.observation = observation + raise failure + + +def _io(error, *, published=False): + if published: + raise ProjectArtifactError('Artifact publication durability is unknown; retain bytes and retry the same identity.', + 'unknown', 503) from error + if error.errno in (errno.ELOOP, errno.ENOTDIR, errno.ENXIO): + raise ProjectArtifactError('Unsafe artifact filesystem object.') from error + if error.errno in (errno.EXDEV, errno.ENOSYS, errno.EOPNOTSUPP): + raise ProjectArtifactError('Required artifact filesystem mechanism is unsupported.', 'unsupported', 422) from error + raise ProjectArtifactError('Artifact bytes are unavailable.', 'unavailable', 503) from error + + +class ProjectArtifacts: + def __init__(self, store, *, resolutions=None): + """Bind optional explicit SHA -> {root_fd, root_path, relative} locations. + + Overrides choose one local source explicitly; a corrupt selected source + never silently falls back to another copy. The caller retains its fds; + this owner duplicates and releases only its own root capabilities. + """ + self.store = store + self._mutex = threading.RLock() + self._closed = False + self._resolutions = {} + try: + if resolutions is not None and type(resolutions) is not dict: + _fail('Artifact resolutions must be an explicit mapping.') + for digest, location in (resolutions or {}).items(): + validate_declaration(dict(format=FORMAT, sha256=digest, bytes=0)) + if type(location) is not dict or set(location) != {'root_fd', 'root_path', 'relative'}: + _fail('Invalid local artifact resolution fields.') + if type(location['root_fd']) is not int or location['root_fd'] < 0: + _fail('Local artifact resolution needs a directory capability.') + parts = _relative_parts(location['relative']) + with anchored_directory(location['root_fd'], root_path=location['root_path']): + owned = os.dup(location['root_fd']) + self._resolutions[digest] = dict(root_fd=owned, root_path=location['root_path'], parts=parts) + except OSError as error: + self.close() + _io(error) + except BaseException: + self.close() + raise + + def _ensure_open(self): + if self._closed: + _fail('Artifact owner is closed.', 'unavailable', 409) + + @contextmanager + def _source(self, root_fd, declaration): + override = self._resolutions.get(declaration['sha256']) + if override is None: + parts = ['artifacts', 'sha256', declaration['sha256']] + yield from self._source_at(root_fd, parts) + else: + with anchored_directory(override['root_fd'], root_path=override['root_path']) as external: + yield from self._source_at(external, override['parts']) + + def _source_at(self, root_fd, parts): + parent = source = None + try: + try: + parent = _walk(root_fd, parts[:-1]) + source = os.open(parts[-1], FILE_FLAGS, dir_fd=parent) + except FileNotFoundError as error: + raise _Missing() from error + _info(source, rooted=True) + yield source + # Path replacement cannot redirect a pinned fd, but it invalidates + # the selected source location and must not be called current. + current = _walk(root_fd, parts[:-1]) + try: + if _physical(current) != _physical(parent): + _fail('Artifact source directory changed.', 'stale', 409) + selected = os.stat(parts[-1], dir_fd=current, follow_symlinks=False) + if (selected.st_dev, selected.st_ino) != _physical(source): + _fail('Artifact source location changed.', 'stale', 409) + finally: + os.close(current) + finally: + if source is not None: + os.close(source) + if parent is not None: + os.close(parent) + + def inspect(self, declaration): + """Presence is unverified; it never claims a hash was checked.""" + declaration = validate_declaration(declaration) + with self._mutex: + self._ensure_open() + try: + with self.store.root_directory() as root: + try: + with self._source(root, declaration) as source: + observed = _info(source, rooted=True).st_size + state = 'unverified' + except _Missing: + observed, state = None, 'missing' + return dict(state=state, expected_sha256=declaration['sha256'], expected_bytes=declaration['bytes'], + observed_sha256=None, observed_bytes=observed) + except OSError as error: + _io(error) + + def status(self, declaration): + declaration = validate_declaration(declaration) + with self._mutex: + self._ensure_open() + try: + with self.store.root_directory() as root: + try: + with self._source(root, declaration) as source: + return _observe(source, declaration) + except _Missing: + return dict(state='missing', expected_sha256=declaration['sha256'], expected_bytes=declaration['bytes'], + observed_sha256=None, observed_bytes=None) + except OSError as error: + _io(error) + + @contextmanager + def _payload_directory(self, root): + artifacts = payloads = None + try: + artifacts = _directory(root, 'artifacts', create=True) + payloads = _directory(artifacts, 'sha256', create=True) + yield artifacts, payloads + finally: + if payloads is not None: + os.close(payloads) + if artifacts is not None: + os.close(artifacts) + + def _publish(self, declaration, capture): + with self._mutex: + self._ensure_open() + published = False + try: + with self.store.root_directory() as root, self._payload_directory(root) as (artifacts, payloads): + temporary = '.artifact-' + uuid.uuid4().hex + '.tmp' + output = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, + 0o600, dir_fd=payloads) + try: + capture(output) + os.fchmod(output, 0o400) + os.fsync(output) + finally: + os.close(output) + try: + _rename_noreplace(payloads, temporary, declaration['sha256']) + published = True + except FileExistsError: + existing = os.open(declaration['sha256'], FILE_FLAGS, dir_fd=payloads) + try: + _info(existing, rooted=True) + _verified(_observe(existing, declaration)) + os.fsync(existing) + finally: + os.close(existing) + os.unlink(temporary, dir_fd=payloads) + published = True + os.fsync(payloads) + os.fsync(artifacts) + os.fsync(root) + return copy.deepcopy(declaration) + except OSError as error: + _io(error, published=published) + # Failed staging remains inert. No callback owns deletion of final bytes. + + def publish_bytes(self, raw, declaration, *, maximum_bytes=None): + declaration = validate_declaration(declaration) + _maximum(declaration, maximum_bytes) + if type(raw) is not bytes: + _fail('Artifact publication requires exact bytes.') + if len(raw) != declaration['bytes'] or hashlib.sha256(raw).hexdigest() != declaration['sha256']: + _fail('Artifact input differs from its declaration.') + return self._publish(declaration, lambda output: _write(output, raw)) + + def publish_fd(self, source_fd, declaration, *, maximum_bytes=None): + declaration = validate_declaration(declaration) + _maximum(declaration, maximum_bytes) + if type(source_fd) is not int or source_fd < 0: + _fail('Artifact publication requires an explicit file capability.') + def capture(output): + _verified(_observe(source_fd, declaration, output=output)) + return self._publish(declaration, capture) + + def _snapshot(self, declaration): + snapshot = None + try: + with self.store.root_directory() as root: + local = staging = None + try: + with self._source(root, declaration) as source: + local = _directory(root, '.tuldok-local', create=True) + staging = _directory(local, 'artifact-snapshots', create=True) + temporary = '.snapshot-' + uuid.uuid4().hex + '.tmp' + output = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, + 0o600, dir_fd=staging) + try: + _verified(_observe(source, declaration, output=output)) + os.fchmod(output, 0o400) + os.fsync(output) + finally: + os.close(output) + snapshot = os.open(temporary, FILE_FLAGS, dir_fd=staging) + _info(snapshot, rooted=True) + os.unlink(temporary, dir_fd=staging) + except _Missing as error: + raise ProjectArtifactError('Required artifact bytes are missing.', 'unavailable', 404) from error + finally: + if staging is not None: + os.close(staging) + if local is not None: + os.close(local) + return snapshot + except BaseException: + if snapshot is not None: + os.close(snapshot) + raise + + @contextmanager + def open_verified(self, declaration, *, maximum_bytes=None): + """Borrow a verified unlinked read-only snapshot, not a mutable source fd.""" + declaration = validate_declaration(declaration) + _maximum(declaration, maximum_bytes) + with self._mutex: + self._ensure_open() + try: + snapshot = self._snapshot(declaration) + except OSError as error: + _io(error) + # Release owner/project locks before consumer work. Source resolution + # roots can close while this independent byte snapshot remains borrowed. + try: + yield snapshot + finally: + os.close(snapshot) + + def read_blob(self, descriptor, *, maximum_bytes=DEFAULT_READ_BYTES): + if type(descriptor) is not dict or set(descriptor) != {'sha256', 'bytes'}: + _fail('Blob references contain exactly SHA256 and byte count.') + declaration = dict(format=FORMAT, **descriptor) + with self.open_verified(declaration, maximum_bytes=maximum_bytes) as source: + chunks = [] + while True: + raw = os.read(source, CHUNK_BYTES) + if not raw: + break + chunks.append(raw) + return b''.join(chunks) + + def close(self): + with self._mutex: + if not self._closed: + for location in self._resolutions.values(): + os.close(location['root_fd']) + self._resolutions.clear() + self._closed = True + + def __enter__(self): + return self + + def __exit__(self, *error): + self.close() diff --git a/project_cli.py b/project_cli.py index 9af517d0..1d54389b 100644 --- a/project_cli.py +++ b/project_cli.py @@ -12,8 +12,8 @@ import signal import threading -from agent_api import (AgentAPIError, AgentClient, PublicationOwnerClient, CoordinatorOwnerClient, REQUEST_LIMIT, decode, - validate_arguments, verify_artifact, verify_publication_artifact) +from agent_api import (AgentAPIError, AgentClient, ProjectFilesOwnerClient, ProjectTransferOwnerClient, PublicationOwnerClient, CoordinatorOwnerClient, REQUEST_LIMIT, decode, + validate_arguments, verify_artifact, verify_publication_artifact, PROJECT_FILES_M2, PROJECT_FILES_M3, PROJECT_FILES_M4) from tuldok_client import WorkflowClient, download from mcp_observer import (add_interface_options, configured_interface, observe_begin, observe_stage, observe_selection, observe_close, outcome) @@ -43,7 +43,7 @@ def invoke(client, operation, arguments, *, observer=None, observation=None): if envelope['status'] >= 400: error = envelope['result'] raise AgentAPIError(error['error'], error['code'], envelope['status'], - preflight_report=error.get('preflight_report')) + preflight_report=error.get('preflight_report'), receipt=error.get('receipt')) return envelope @@ -51,8 +51,48 @@ def parser(): result = argparse.ArgumentParser(description=__doc__) result.add_argument('--config', type=Path, required=True, help='Private existing AgentClient endpoint/catalog/token-file configuration') + result.add_argument('--owner-project-files', action='store_true', + help='Use an existing owner credential, restricted to file-* commands') add_interface_options(result) commands = result.add_subparsers(dest='command', required=True) + commands.add_parser('file-status', help='Inspect the active external file project and process binding') + for action in ('new', 'open', 'save', 'close', 'save-as'): + command = commands.add_parser('file-' + action, help='Explicit external project ' + action) + command.add_argument('--binding', required=True) + command.add_argument('--expected-checkpoint', required=True, + help='Exact SHA256; use null only when no project is open for New/Open') + command.add_argument('--request-key', required=True) + if action in ('new', 'open', 'save-as'): + command.add_argument('--root', required=True) + command.add_argument('--directory', required=True) + if action in ('new','save-as'): + command.add_argument('--name', required=True) + for action in ('query', 'get', 'import-text', 'annotate'): + command = commands.add_parser('file-' + action, help='Checkpoint-bound project text ' + action) + command.add_argument('--arguments', type=Path, required=True, + help='Exact project_files operation JSON arguments including binding/checkpoint') + for operation in sorted((PROJECT_FILES_M2 | PROJECT_FILES_M3 | PROJECT_FILES_M4)-{'project_files.save_as'}): + action = operation.split('.')[1].replace('_', '-') + command = commands.add_parser('file-' + action, help='Checkpoint-bound project ' + action) + command.add_argument('--arguments', type=Path, required=True, + help='Exact selected project operation arguments') + transfer=commands.add_parser('project-transfer',help='Explicit owner extraction; Stop requires new current-host credentials') + actions=transfer.add_subparsers(dest='transfer_action',required=True) + actions.add_parser('status',help='Inspect current source/control phase and controller binding') + for action in ('preview','report-page','stop','extract'): + command=actions.add_parser(action,help='Explicit extraction '+action) + command.add_argument('--binding',required=True) + if action=='preview':command.add_argument('--selection',type=Path,required=True,help='Closed native ownership selection JSON file; no credentials or cessation proof') + else:command.add_argument('--report-sha256',required=True) + if action=='report-page': + command.add_argument('--section',required=True,choices=('included','required','unassigned','excluded','blockers','warnings','artifacts','working_files')) + command.add_argument('--offset',type=int,default=0);command.add_argument('--limit',type=int,default=40) + if action=='stop': + command.add_argument('--backup-root',required=True);command.add_argument('--backup-directory',required=True) + if action in ('stop','extract'): + command.add_argument('--destination-root',required=True);command.add_argument('--directory',required=True) + if action=='extract': + command.add_argument('--name',required=True);command.add_argument('--request-key',required=True) opening = commands.add_parser('open', help='Register/inspect a project without executing its code') opening.add_argument('--root', required=True) opening.add_argument('--manifest', required=True) @@ -251,8 +291,13 @@ def main(argv=None, *, observer=None): try: status, http_status = 200, None client_type = PublicationOwnerClient if args.command in ('recover-publication', 'cancel-publication') else AgentClient + if args.command=='project-transfer':client_type=ProjectTransferOwnerClient if args.command == 'recover-coordinator': client_type = CoordinatorOwnerClient + if args.owner_project_files: + if not args.command.startswith('file-'): + raise AgentAPIError('--owner-project-files is restricted to file-* commands.', 'configuration') + client_type = ProjectFilesOwnerClient client = client_type.from_config(args.config) if args.command in WORKFLOW_COMMANDS: result, success = workflow_command(client, args) @@ -271,7 +316,30 @@ def main(argv=None, *, observer=None): observe_stage(observer, observation, phase) result = download(client, run_id, args.name, args.expected_sha256, args.out, result_ref=ref) else: - if args.command == 'call': + if args.command=='project-transfer': + action=args.transfer_action.replace('-','_');operation='project_transfer.'+action;arguments={} + if action!='status':arguments['binding']=args.binding + if action=='preview':arguments['selection']=read_arguments(args.selection) + if action in ('report_page','stop','extract'):arguments['report_sha256']=args.report_sha256 + if action=='report_page':arguments.update(section=args.section,offset=args.offset,limit=args.limit) + if action=='stop':arguments.update(backup_root=args.backup_root,backup_directory=args.backup_directory) + if action in ('stop','extract'):arguments.update(destination_root=args.destination_root,directory=args.directory) + if action=='extract':arguments.update(name=args.name,request_key=args.request_key) + elif args.command == 'file-status': + operation, arguments = 'project_files.status', {} + elif args.command.startswith('file-'): + action = args.command[5:].replace('-', '_') + operation = 'project_files.' + action + if action in ('new', 'open', 'save', 'close', 'save_as'): + arguments = dict(binding=args.binding, request_key=args.request_key, + expected_checkpoint=None if args.expected_checkpoint == 'null' else args.expected_checkpoint) + if action in ('new', 'open', 'save_as'): + arguments.update(root=args.root, directory=args.directory) + if action in ('new','save_as'): + arguments['name'] = args.name + else: + arguments = read_arguments(args.arguments) + elif args.command == 'call': operation, arguments = args.operation, read_arguments(args.arguments) elif args.command == 'phase-timings': operation, arguments = 'runs.phase_timings', {'run_id': args.run_id} @@ -328,6 +396,8 @@ def main(argv=None, *, observer=None): result = {'code': error.code, 'error': str(error), 'status': error.status} if error.preflight_report is not None: result['preflight_report'] = error.preflight_report + if error.receipt is not None: + result['receipt'] = error.receipt _write_output(sys.stderr, result, observer, observation, status=error.status, failed=True, observed_outcome=( 'transport-error' if error.code in ('transport', 'response_too_large') else diff --git a/project_custody.py b/project_custody.py new file mode 100644 index 00000000..2e0a97aa --- /dev/null +++ b/project_custody.py @@ -0,0 +1,511 @@ +"""Private original-host custody index; never part of a portable project. + +Only fixed Run and producer facts are recorded. These files can fence a local +physical projection and identify its exact receipts; they never grant execution, +select a PID to signal, or restore a Run/admission from project history. +""" +import copy +import hashlib +import json +import os +from pathlib import Path +import re +import stat +import threading +import uuid +from run_capacity import MAX_ATTEMPTS + +NATIVE_PHASE_LIMIT=3*MAX_ATTEMPTS+1 + +import agent_api +from workbench import WorkbenchError + +LIMIT = 1024 * 1024 +ID = re.compile(r'^[a-f0-9]{32}$') +SHA = re.compile(r'^[a-f0-9]{64}$') +PRODUCERS = frozenset(('generation_history','grounded_history','caption_proposal_history','text_proposal_history')) +PHASES = ('admitted','launch_intent','launched','ready','go_intent','drained') +DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC +FILE_FLAGS = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC + + +def _fail(message, code='local_custody_unavailable'): + raise WorkbenchError(message, code, 409) + + +def _id(value): + if type(value) is not str or not ID.fullmatch(value):_fail('Invalid local custody identity.') + return value + + +def _sha(value): + if type(value) is not str or not SHA.fullmatch(value):_fail('Invalid local custody digest.') + return value + + +def _shape(value, fields): + if type(value) is not dict or set(value) != set(fields):_fail('Unclassified local custody fields.') + + +def records_sha256(records): + return hashlib.sha256(agent_api.encoded(records)).hexdigest() + + +def validate(value): + version=value.get('format') if type(value) is dict else None + fields=('format','host_catalog_id','project_id','root_identity','projection_id', + 'project_name','selected_checkpoint','attempted','runs','producers','runtime_effects','package_effects') + _shape(value,(*fields,*(['native_phases'] if version in ('project_local_custody_v2','project_local_custody_v3') else []), + *(['native_admissions'] if version=='project_local_custody_v3' else []))) + if version not in ('project_local_custody_v1','project_local_custody_v2','project_local_custody_v3'):_fail('Unsupported local custody format.') + for key in ('host_catalog_id','project_id','projection_id'):_id(value[key]) + if type(value['project_name']) is not str or not value['project_name'].strip() or len(value['project_name'])>200: + _fail('Invalid retained local project name.') + root=value['root_identity'] + if type(root) is not list or len(root)!=2 or any(type(n) is not int or n<0 for n in root): + _fail('Invalid local project physical identity.') + _sha(value['selected_checkpoint']) + if value['attempted'] is not None: + attempt=value['attempted'];_shape(attempt,('records_sha256','request_key','input_sha256')) + _sha(attempt['records_sha256']);_sha(attempt['input_sha256']) + if type(attempt['request_key']) is not str or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}',attempt['request_key']): + _fail('Invalid local canonical publication request.') + if type(value['runs']) is not list or len(value['runs'])>16:_fail('Too many unresolved local Runs.') + seen=set() + for run in value['runs']: + _shape(run,('run_id','admission_id','request_sha256','spec_sha256','phase','custody_identity','receipt_sha256')) + _id(run['run_id']);_id(run['admission_id']);_sha(run['request_sha256']);_sha(run['spec_sha256']) + if run['run_id'] in seen or run['phase'] not in PHASES:_fail('Invalid or duplicate local Run custody.') + seen.add(run['run_id']) + identity=run['custody_identity'] + if identity is not None: + _shape(identity,('attempt_id','request_sha256','spec_sha256','helper')) + from recipe_process import valid_identity + if (identity['attempt_id']!=run['run_id'] or identity['request_sha256']!=run['request_sha256'] + or identity['spec_sha256']!=run['spec_sha256'] or not valid_identity(identity['helper'])): + _fail('Local helper identity differs from its admitted specification.') + if run['phase'] in ('launched','ready','go_intent','drained') and identity is None: + _fail('Local launched Run requires its observed helper identity.') + if run['receipt_sha256'] is not None:_sha(run['receipt_sha256']) + if run['receipt_sha256'] is not None and run['phase']!='drained':_fail('Drain receipt precedes drainage.') + if type(value['producers']) is not list or len(value['producers'])>16:_fail('Too many unresolved local producers.') + seen=set() + for producer in value['producers']: + _shape(producer,('family','producer_id','admission_id','request_sha256','phase','provider_outcome','worker_drained')) + if producer['family'] not in PRODUCERS:_fail('Unsupported local producer custody family.') + for key in ('producer_id','admission_id'):_id(producer[key]) + _sha(producer['request_sha256']) + identity=producer['family'],producer['producer_id'] + if identity in seen or producer['phase'] not in ('admitted','effect_intent','unknown'): + _fail('Invalid or duplicate current producer custody.') + seen.add(identity) + if producer['provider_outcome'] not in ('not_admitted','unknown','result_received') or type(producer['worker_drained']) is not bool: + _fail('Invalid current producer outcome.') + for family,identity_field in (('runtime_effects','manifest_sha256'),('package_effects','material_sha256')): + entries=value[family] + if type(entries) is not list or len(entries)>8:_fail('Too many unresolved local file writers.') + seen=set() + for entry in entries: + _shape(entry,('request_key','input_sha256',identity_field)) + key=entry['request_key'] + if type(key) is not str or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}',key) or key in seen: + _fail('Invalid local file writer request.') + seen.add(key) + for field in ('input_sha256',identity_field): + if entry[field] is not None:_sha(entry[field]) + native=value.get('native_phases',[]) + if type(native) is not list or len(native)>(NATIVE_PHASE_LIMIT if version=='project_local_custody_v3' else 16):_fail('Too many unresolved native phases.') + seen=set() + for phase in native: + _shape(phase,('phase_id','phase','run_id','request_sha256','spec_sha256','state','helper','receipt_sha256')) + _id(phase['phase_id']);_sha(phase['request_sha256']);_sha(phase['spec_sha256']) + if phase['phase_id'] in seen or phase['phase'] not in ('probe','train','verify'): + _fail('Invalid or duplicate native phase custody.') + seen.add(phase['phase_id']) + if phase['phase']!='probe' and phase['run_id'] is None:_fail('Native phase Run association differs.') + if phase['run_id'] is not None:_id(phase['run_id']) + if phase['state'] not in ('prepared','launch_intent','launched','ready','go_intent','completed','failed','unknown'): + _fail('Unclassified native custody state.') + from recipe_process import valid_identity + if phase['helper'] is not None and not valid_identity(phase['helper']):_fail('Invalid native helper observation.') + if phase['state'] in ('launched','ready','go_intent','completed') and phase['helper'] is None: + _fail('Native launched custody lacks its actual helper identity.') + if phase['receipt_sha256'] is not None: + _sha(phase['receipt_sha256']) + if phase['state'] not in ('completed','failed') or phase['helper'] is None: + _fail('Native terminal receipt lacks exact helper drainage.') + if phase['state']=='completed' and phase['receipt_sha256'] is None:_fail('Native completion requires drainage proof.') + admissions=value.get('native_admissions',[]) + if type(admissions) is not list or len(admissions)>16:_fail('Too many unresolved native admissions.') + seen=set() + for admission in admissions: + _shape(admission,('run_id','admission_id','request_sha256','graph_sha256','release_sha256','runtime_sha256','phase_started')) + for key in ('run_id','admission_id'):_id(admission[key]) + for key in ('request_sha256','graph_sha256','release_sha256','runtime_sha256'):_sha(admission[key]) + if admission['run_id'] in seen or type(admission['phase_started']) is not bool: + _fail('Invalid or duplicate native admission custody.') + seen.add(admission['run_id']) + return copy.deepcopy(value) + + +def _read(directory, name): + try:descriptor=os.open(name,FILE_FLAGS,dir_fd=directory) + except FileNotFoundError:return None + except OSError:_fail('Local custody index is not an anchored ordinary file.') + try: + before=os.fstat(descriptor) + if (not stat.S_ISREG(before.st_mode) or before.st_uid!=os.getuid() or before.st_nlink!=1 + or before.st_mode & 0o077 or before.st_size>LIMIT):_fail('Unsafe local custody index file.') + chunks=[];size=0 + while size<=LIMIT: + chunk=os.read(descriptor,min(65536,LIMIT+1-size)) + if not chunk:break + chunks.append(chunk);size+=len(chunk) + raw=b''.join(chunks);after=os.fstat(descriptor) + if len(raw)>LIMIT or (before.st_dev,before.st_ino,before.st_size,before.st_mtime_ns,before.st_ctime_ns)!=( + after.st_dev,after.st_ino,after.st_size,after.st_mtime_ns,after.st_ctime_ns):_fail('Local custody changed while reading.') + def pairs(items): + result={} + for key,item in items: + if key in result:_fail('Duplicate local custody JSON field.') + result[key]=item + return result + return validate(json.loads(raw,object_pairs_hook=pairs)) + except (OSError,UnicodeError,ValueError,RecursionError): + _fail('Local custody index cannot be read safely.') + finally:os.close(descriptor) + + +class ProjectCustody: + """Original host's private index, using only configured control directory.""" + def __init__(self, control_path, host_catalog_id): + self.path=Path(control_path);self.host_catalog_id=_id(host_catalog_id) + self.lock=threading.RLock() + + def _directory(self, create=False): + parent=os.open(self.path,DIR_FLAGS) + try: + if create: + try:os.mkdir('.project-custody',0o700,dir_fd=parent);os.fsync(parent) + except FileExistsError:pass + try:directory=os.open('.project-custody',DIR_FLAGS,dir_fd=parent) + except FileNotFoundError:return None + info=os.fstat(directory) + if not stat.S_ISDIR(info.st_mode) or info.st_uid!=os.getuid() or info.st_mode&0o077: + os.close(directory);_fail('Unsafe original-host custody directory.') + return directory + finally:os.close(parent) + + def _binding(self, store, projection_id): + snapshot=store.snapshot() + with store.root_directory() as descriptor: + info=os.fstat(descriptor) + return dict(host_catalog_id=self.host_catalog_id,project_id=snapshot['project_id'], + root_identity=[info.st_dev,info.st_ino],projection_id=_id(projection_id)) + + @staticmethod + def _name(binding): + return binding['host_catalog_id']+'.'+binding['project_id']+'.'+binding['projection_id']+'.json' + + def for_projection(self, store, projection_id): + return CustodyJournal(self,store,self._binding(store,projection_id)) + + def pending(self, store): + """Read only; no SQL owner, directory creation or worker recovery.""" + binding=self._binding(store,'0'*32);directory=self._directory() + if directory is None:return [] + result=[] + try: + names=os.listdir(directory) + if len(names)>10000:_fail('Original-host custody index requires explicit maintenance.') + # Local custody belongs to the physical root. A different valid + # project UUID at that same inode cannot erase an unresolved effect. + prefix=self.host_catalog_id+'.' + for name in sorted(names): + if not name.startswith(prefix):continue + if not re.fullmatch(r'[a-f0-9]{32}\.[a-f0-9]{32}\.[a-f0-9]{32}\.json',name): + _fail('Unclassified original-host custody index filename.') + value=_read(directory,name) + if value is None:continue + if self._name(value)!=name or value['host_catalog_id']!=self.host_catalog_id: + _fail('Local custody filename differs from its host binding.') + if value['root_identity']!=binding['root_identity']:continue + if value['runs'] or value['producers'] or value['runtime_effects'] or value['package_effects'] or value.get('native_phases') or value.get('native_admissions'): + result.append({key:copy.deepcopy(value[key]) for key in ('host_catalog_id','project_id','root_identity','projection_id')}) + return result + finally:os.close(directory) + + +class CustodyJournal: + def __init__(self, owner, store, binding): + self.owner,self.store,self.binding=owner,store,binding + self.name=owner._name(binding);self.lock=threading.RLock() + directory=owner._directory() + try:value=_read(directory,self.name) if directory is not None else None + finally: + if directory is not None:os.close(directory) + persisted=value is not None + if value is None: + value=dict(format='project_local_custody_v1',**binding, + project_name=store.snapshot()['name'],selected_checkpoint=store.snapshot()['checkpoint'], + attempted=None,runs=[],producers=[],runtime_effects=[],package_effects=[]) + if any(value[key]!=item for key,item in binding.items()):_fail('Original physical custody binding differs.') + self.value=validate(value) + self._persisted=persisted + + @property + def has_pending(self): + with self.lock:return bool(self.value['runs'] or self.value['producers'] or self.value['runtime_effects'] or self.value['package_effects'] or self.value.get('native_phases') or self.value.get('native_admissions')) + + def snapshot(self): + with self.lock:return copy.deepcopy(self.value) + + def refresh(self): + """Read this exact private binding after an uncertain journal ACK. + + This neither reads a canonical checkout nor clears an effect. The + execution owner separately proves selection and current authority. + """ + with self.owner.lock,self.lock: + directory=self.owner._directory() + try:current=_read(directory,self.name) if directory is not None else None + finally: + if directory is not None:os.close(directory) + if current is None: + if self._persisted:_fail('Original physical custody index is missing.') + return self.snapshot() + if any(current[key]!=value for key,value in self.binding.items()): + _fail('Original physical custody binding changed.') + self.value=current;self._persisted=True + return self.snapshot() + + def _write(self, value): + value=validate(value);raw=agent_api.encoded(value) + if len(raw)>LIMIT:_fail('Local custody exceeds its bounded format.') + directory=self.owner._directory(create=True);temporary='.custody-'+uuid.uuid4().hex + try: + descriptor=os.open(temporary,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW|os.O_CLOEXEC,0o600,dir_fd=directory) + try: + os.fchmod(descriptor,0o600);view=memoryview(raw) + while view: + count=os.write(descriptor,view) + if not count:raise OSError('Short local custody write') + view=view[count:] + os.fsync(descriptor) + finally:os.close(descriptor) + os.replace(temporary,self.name,src_dir_fd=directory,dst_dir_fd=directory);os.fsync(directory) + self.value=value + self._persisted=True + except OSError as error: + raise WorkbenchError('Local custody publication outcome is uncertain. Execution remains fenced.','outcome_unknown',409) from error + finally: + try:os.unlink(temporary,dir_fd=directory) + except OSError:pass + os.close(directory) + + def _change(self, change): + with self.owner.lock,self.lock: + directory=self.owner._directory() + try:current=_read(directory,self.name) if directory is not None else None + finally: + if directory is not None:os.close(directory) + if current is not None and current!=self.value or current is None and self._persisted: + _fail('Original-host custody changed; refuse to replace another writer.','outcome_unknown') + value=copy.deepcopy(self.value);change(value);self._write(value) + + def expect_publication(self, before, records, key, sha): + if not self.has_pending:return + def change(value): + value['selected_checkpoint']=before['checkpoint'] + value['attempted']=dict(records_sha256=records_sha256(records),request_key=key,input_sha256=sha) + self._change(change) + + def acknowledge(self, snapshot): + def change(value):value.update(selected_checkpoint=snapshot['checkpoint'],attempted=None) + self._change(change) + + def validate_selection(self, snapshot): + value=self.snapshot();attempt=value['attempted'] + if snapshot['project_id']!=value['project_id'] or snapshot['name']!=value['project_name']: + _fail('Current canonical project differs from local custody.') + if snapshot['checkpoint']==value['selected_checkpoint']:return + if (attempt is not None and records_sha256(snapshot['records'])==attempt['records_sha256'] + and self.store.reconcile(attempt['request_key'],attempt['input_sha256']) is not None):return + _fail('Canonical checkout differs from this original-host custody outcome.','stale_project') + + def observe_run(self, run, *, receipt_sha256=None): + from recipe_process import encoded + spec_sha=hashlib.sha256(encoded(run['spec'])).hexdigest() + def change(value): + old=next((item for item in value['runs'] if item['run_id']==run['id']),None) + immutable=dict(run_id=run['id'],admission_id=run['admission_id'],request_sha256=run['request_sha256'],spec_sha256=spec_sha) + if old is None: + old=dict(**immutable,phase='admitted',custody_identity=None,receipt_sha256=None);value['runs'].append(old) + if any(old[key]!=item for key,item in immutable.items()):_fail('Current Run differs from its local admission journal.') + phase={'prepared':'admitted','barrier_ready':'ready','go_intent':'go_intent','drained':'drained'}[run['custody_phase']] + if PHASES.index(phase)>PHASES.index(old['phase']):old['phase']=phase + identity=run.get('custody_identity') + if identity is not None: + if old['custody_identity'] is not None and old['custody_identity']!=identity:_fail('Current helper identity changed.') + old['custody_identity']=copy.deepcopy(identity) + if receipt_sha256 is not None:old['receipt_sha256']=receipt_sha256 + value['runs'].sort(key=lambda item:item['run_id']) + self._change(change) + + def launch_intent(self, run_id): + def change(value): + run=next((item for item in value['runs'] if item['run_id']==run_id),None) + if run is None or run['phase']!='admitted':_fail('Launch requires its acknowledged current admission.') + run['phase']='launch_intent' + self._change(change) + + def launched(self, run_id, helper): + from recipe_process import valid_identity + if not valid_identity(helper):_fail('Current spawned custodian identity is unavailable.') + def change(value): + run=next((item for item in value['runs'] if item['run_id']==run_id),None) + if run is None or run['phase']!='launch_intent':_fail('Custodian has no current launch intent.') + run['phase']='launched' + run['custody_identity']=dict(attempt_id=run_id,request_sha256=run['request_sha256'],spec_sha256=run['spec_sha256'],helper=copy.deepcopy(helper)) + self._change(change) + + def settle_run(self, run_id): + def change(value):value['runs']=[item for item in value['runs'] if item['run_id']!=run_id] + self._change(change) + + def observe_native_admission(self,run): + """Original physical admission intent; never a replay/GO capability.""" + from pipeline import digest + entry=dict(run_id=run['id'],admission_id=run['admission_id'],request_sha256=run['request_sha256'], + graph_sha256=run['graph_sha256'],release_sha256=run['release_sha256'],runtime_sha256=digest(run['runtime'])) + def change(value): + value['format']='project_local_custody_v3';value.setdefault('native_phases',[]) + rows=value.setdefault('native_admissions',[]) + old=next((item for item in rows if item['run_id']==run['id']),None) + if old is None:rows.append(dict(entry,phase_started=False)) + elif any(old[key]!=item for key,item in entry.items()):_fail('Native admission differs from its original physical intent.') + rows.sort(key=lambda row:row['run_id']) + self._change(change) + + def settle_native_admission(self,run_id): + def change(value): + if any(row['run_id']==run_id for row in value.get('native_phases',[])): + _fail('Native admission still has unresolved phase custody.') + value['native_admissions']=[row for row in value.get('native_admissions',[]) if row['run_id']!=run_id] + self._change(change) + + def native_prepared(self, phase): + """Fence one acknowledged, current native phase on this physical host.""" + from recipe_process import encoded + entry=dict(phase_id=phase['phase_id'],phase=phase['phase'],run_id=phase['run_id'], + request_sha256=phase['request_sha256'],spec_sha256=hashlib.sha256(encoded(phase['spec'])).hexdigest(), + state='prepared',helper=None,receipt_sha256=None) + def change(value): + if value['format']=='project_local_custody_v1':value['format']='project_local_custody_v2' + value.setdefault('native_phases',[]) + if entry['run_id'] is not None: + admission=next((row for row in value.get('native_admissions',[]) if row['run_id']==entry['run_id']),None) + if admission is None:_fail('A current native Run phase requires its original physical admission.') + admission['phase_started']=True + old=next((row for row in value['native_phases'] if row['phase_id']==entry['phase_id']),None) + if old is not None:_fail('A native phase cannot register another current launch.') + value['native_phases'].append(entry);value['native_phases'].sort(key=lambda row:row['phase_id']) + self._change(change) + + def native_launch_intent(self, phase_id): + def change(value): + row=next((row for row in value.get('native_phases',[]) if row['phase_id']==phase_id),None) + if row is None or row['state']!='prepared':_fail('Native launch requires its current acknowledged specification.') + row['state']='launch_intent' + self._change(change) + + def native_launched(self, phase_id, helper): + def change(value): + row=next((row for row in value.get('native_phases',[]) if row['phase_id']==phase_id),None) + if row is None or row['state']!='launch_intent':_fail('Native helper has no current launch intent.') + row.update(state='launched',helper=copy.deepcopy(helper)) + self._change(change) + + def native_observed(self, phase): + from recipe_process import encoded + def change(value): + row=next((row for row in value.get('native_phases',[]) if row['phase_id']==phase['phase_id']),None) + if row is None:_fail('Native observation has no original local phase.') + event=phase['events'][-1] + if (row['request_sha256']!=phase['request_sha256'] or row['phase']!=phase['phase'] + or row['run_id']!=phase['run_id'] or event['helper']!=row['helper'] + or row['spec_sha256']!=hashlib.sha256(encoded(phase['spec'])).hexdigest()): + _fail('Native observation differs from original local custody.') + transitions={'prepared':('failed',),'launch_intent':('failed',), + 'launched':('ready','failed','unknown'),'ready':('go_intent','failed','unknown'), + 'go_intent':('completed','failed','unknown')} + if event['state'] not in transitions.get(row['state'],()): + _fail('Native custody observation cannot skip or replay its acknowledged phase.') + row.update(state=event['state'],receipt_sha256=event['receipt_sha256']) + self._change(change) + + def native_confirmed(self, phase_id): + def change(value): + row=next((row for row in value.get('native_phases',[]) if row['phase_id']==phase_id),None) + if row is None or row['state'] not in ('completed','failed') or row['receipt_sha256'] is None: + _fail('Native phase cannot settle without acknowledged terminal drainage.') + value['native_phases']=[item for item in value['native_phases'] if item['phase_id']!=phase_id] + self._change(change) + + def native_not_launched(self,phase_id): + """Settle only the caller's proven never-entered launch boundary.""" + def change(value): + row=next((item for item in value.get('native_phases',[]) if item['phase_id']==phase_id),None) + if row is None or row['state']!='failed' or row['helper'] is not None or row['receipt_sha256'] is not None: + _fail('Never-launched native settlement requires its acknowledged failed non-process fact.') + value['native_phases']=[item for item in value['native_phases'] if item['phase_id']!=phase_id] + if row['run_id'] is not None and not any(item['run_id']==row['run_id'] for item in value['native_phases']): + admission=next((item for item in value.get('native_admissions',[]) if item['run_id']==row['run_id']),None) + if admission is not None:admission['phase_started']=False + self._change(change) + + def register_producer(self, family, producer_id, admission_id, request_digest): + def change(value): + item=dict(family=family,producer_id=producer_id,admission_id=admission_id,request_sha256=request_digest, + phase='admitted',provider_outcome='not_admitted',worker_drained=False) + previous=next((row for row in value['producers'] if (row['family'],row['producer_id'])==(family,producer_id)),None) + if previous is not None: + if any(previous[key]!=item[key] for key in ('family','producer_id','admission_id','request_sha256')):_fail('Producer admission changed.') + else:value['producers'].append(item) + self._change(change) + + def producer_effect_intent(self, family, producer_id): + def change(value): + item=next((row for row in value['producers'] if (row['family'],row['producer_id'])==(family,producer_id)),None) + if item is None:_fail('Provider request has no current admission journal.') + item.update(phase='effect_intent',provider_outcome='unknown',worker_drained=False) + self._change(change) + + def producer_outcome(self, family, producer_id, provider_outcome, worker_drained): + def change(value): + item=next((row for row in value['producers'] if (row['family'],row['producer_id'])==(family,producer_id)),None) + if item is None:_fail('Provider result has no current admission journal.') + item.update(provider_outcome=provider_outcome,worker_drained=worker_drained) + if provider_outcome=='unknown':item['phase']='unknown' + elif worker_drained:value['producers'].remove(item) + self._change(change) + + def runtime_started(self, key, input_sha256=None, manifest_sha256=None): + self._file_started('runtime_effects',dict(request_key=key,input_sha256=input_sha256,manifest_sha256=manifest_sha256)) + + def package_started(self, key, input_sha256=None, material_sha256=None): + self._file_started('package_effects',dict(request_key=key,input_sha256=input_sha256,material_sha256=material_sha256)) + + def _file_started(self, family, entry): + def change(value): + previous=next((item for item in value[family] if item['request_key']==entry['request_key']),None) + if previous is not None and previous!=entry:_fail('File writer key has different retained input.','conflict') + if previous is None:value[family].append(entry) + self._change(change) + + def runtime_confirmed(self, key):self._file_confirmed('runtime_effects',key) + def package_confirmed(self, key):self._file_confirmed('package_effects',key) + + def _file_confirmed(self, family, key): + def change(value):value[family]=[item for item in value[family] if item['request_key']!=key] + self._change(change) diff --git a/project_data.py b/project_data.py new file mode 100644 index 00000000..69c86914 --- /dev/null +++ b/project_data.py @@ -0,0 +1,1457 @@ +"""Workbench-owned project data and history, composed by ProjectSession. + +Each family below has a closed semantic decoder. SQL is a disposable projection; +the adapter supplies changed owned documents to the shared session, never a +replacement inventory or an independent commit. Existing domain owners admit +mutations and immutable artifact storage owns exact source bytes. +""" +import copy +from contextlib import contextmanager +from datetime import datetime +import hashlib +import json +import re +from types import SimpleNamespace +import unicodedata + +from workbench import (Workbench, WorkbenchError, IDENTIFIER, TASKS, REVIEWS, MAX_TEXT, + encode, encode_retained, strings, text_value, timestamp, validate_annotation) +from project_text import decode_records as decode_text_records, SNAPSHOT_FIELDS +from saved_searches import SavedSearches, criteria +from saved_selections import SavedSelections +from dataset_releases import Releases + +FAMILIES = { + 'workbench_record': 'workbench_record_v1', + 'workbench_response': 'workbench_response_v1', + 'workbench_preference': 'workbench_preference_v1', + 'saved_selection': 'saved_selection_v1', + 'saved_search': 'saved_search_v1', + 'dataset_release': 'dataset_release_v1', + 'dataset_exposure': 'dataset_exposure_v1', +} +SHA = re.compile(r'^[a-f0-9]{64}$') +ASSET_KINDS = ('sequence', 'mesh', 'pointcloud', 'volume', 'audio', 'field_sequence') +SOURCE_FIELDS = set('id sha256 filename width height book_id session_id split created_at updated_at revision annotation'.split()) +RESPONSE_FIELDS = set('id prompt_id revision completion review provenance created_at updated_at'.split()) +JUDGMENT_FIELDS = set('id prompt_id parent_revision source_revision left_id left_revision right_id right_revision revision outcome rationale review deleted created_at updated_at provenance'.split()) + + +def fail(message, code='invalid', status=400): + raise WorkbenchError(message, code, status) + + +def closed(value, fields, name): + if type(value) is not dict or set(value) != set(fields): + fail('Invalid ' + name + ' fields.') + + +def identity(value, name='identity'): + if type(value) is not str or not IDENTIFIER.fullmatch(value): + fail('Invalid project data ' + name + '.') + return value + + +def sha(value): + if type(value) is not str or not SHA.fullmatch(value): + fail('Invalid project data digest.') + return value + + +def natural(value, minimum=1): + if type(value) is not int or not minimum <= value <= 2**53 - 1: + fail('Invalid project data revision/count.') + return value + + +def date(value): + text_value(value, 'Timestamp', 80) + try: + parsed = datetime.fromisoformat(value) + if parsed.utcoffset() is None: + raise ValueError() + return parsed + except (ValueError, TypeError): + fail('Invalid project data timestamp.') + + +def digest(value): + return hashlib.sha256(encode_retained(value).encode()).hexdigest() + + +def _audit(facts): + if type(facts) is not list: + fail('Data audit must be a list.') + for fact in facts: + closed(fact, {'action', 'target', 'details', 'result', 'actor_id', 'occurred_at'}, 'data audit') + if type(fact['action']) is not str or fact['action'] not in DATA_ACTIONS or fact['result'] != 'success' or type(fact['details']) is not dict: + fail('Invalid data audit action/result.') + identity(fact['actor_id'], 'historical actor') + if fact['target'] is not None: + text_value(fact['target'], 'Audit target', 128) + date(fact['occurred_at']) + # The current domain owners publish hashes/revisions, never credentials. + if set(fact['details']) & {'credential_id', 'token', 'admission_id', 'grant_id'}: + fail('Current access authority cannot enter data audit facts.') + + +def _events(events): + if type(events) is not list: + fail('Data events must be a list.') + for event in events: + closed(event, {'operation', 'request_key', 'record_revision', 'source_revision', 'deleted', 'occurred_at', 'snapshot'}, 'data event') + if type(event['operation']) is not str or event['operation'] not in DATA_OPERATIONS|RUNTIME_DATA_OPERATIONS or type(event['deleted']) is not bool: + fail('Invalid data lifecycle event.') + text_value(event['request_key'], 'Operation key', 128) + natural(event['record_revision']) + if event['source_revision'] is not None: + natural(event['source_revision']) + date(event['occurred_at']) + state=event['snapshot'];validate_record_snapshot(state) + if state['revision']!=event['record_revision'] or state['source_revision']!=event['source_revision']: + fail('Lifecycle event snapshot revision differs.') + + +def _edge(value, declarations, kind, role): + closed(value, {'artifact', 'media_type', 'kind', 'role', 'preservation', 'required_for'}, 'artifact edge') + closed(value['artifact'], {'namespace', 'sha256'}, 'artifact reference') + identity(value['artifact']['namespace'], 'artifact namespace') + sha(value['artifact']['sha256']) + if value['kind'] != kind or value['role'] != role or value['preservation'] not in ('unique', 'regenerable'): + fail('Artifact edge meaning differs from its data owner.') + text_value(value['media_type'], 'Media type', 120) + if value['required_for'] != ['inspect', 'release']: + fail('Unsupported data artifact required operations.') + declaration = declarations.get(value['artifact']['sha256']) + if declaration is None: + fail('Data artifact declaration is missing.') + return declaration + + +def _source_row(row, record_id): + closed(row, SOURCE_FIELDS, 'image source snapshot') + if row['id'] != record_id: + fail('Image source identity differs.') + sha(row['sha256']); natural(row['revision']) + natural(row['width']); natural(row['height']) + if row['width'] * row['height'] > 40_000_000: + fail('Image geometry exceeds owner limit.') + text_value(row['filename'], 'Filename', 200) + from image_sources import metadata, validate_annotation as validate_corners + if metadata(row) != {key: row[key] for key in ('book_id', 'session_id', 'split')}: + fail('Image source metadata is not canonical.') + if row['annotation'] is not None and validate_corners(row['annotation']) != row['annotation']: + fail('Image corner annotation is not canonical.') + if date(row['updated_at']) < date(row['created_at']): + fail('Image source update precedes creation.') + + +def _source_metadata(kind,value): + if kind=='sequence': + from sequence_assets import SequenceAssets + SequenceAssets.validate_project_metadata(value) + elif kind=='mesh': + from meshes import MeshAssets + MeshAssets.validate_project_metadata(value) + elif kind in ('pointcloud','volume'): + from spatial_assets import SpatialAssets + SpatialAssets.validate_project_metadata(kind,value) + elif kind=='audio': + from audio_assets import validate_project_metadata + validate_project_metadata(value) + else: + from scientific_fields import validate_project_metadata + validate_project_metadata(value) + + +def _acquisition(value,record,source,records): + """Only the finite receipts produced by the existing import owners.""" + if record['kind']=='sequence': + from sequence_assets import SequenceAssets + SequenceAssets.validate_project_acquisition(value,source['metadata']) + return + if type(value) is not dict:fail('Acquisition facts must be an object.') + from bulk_import import leaf_name, REQUEST_ID, MAX_ROWS + if type(value.get('request_id')) is not str or not REQUEST_ID.fullmatch(value['request_id']):fail('Invalid retained import request marker.') + format_name=value.get('format');declared=value.get('declared') + if format_name=='tuldok_assets_jsonl_v1': + closed(value,{'format','request_id','row_sha256','declared'},'JSONL acquisition') + fields={'manifest_name','row_number'}|({'image_file'} if record['kind']=='image' else set()) + closed(declared,fields,'JSONL source declaration');leaf_name(declared['manifest_name'],'Manifest name') + if record['kind']=='image':leaf_name(declared['image_file'],'Image filename') + if type(declared['row_number']) is not int or not 1<=declared['row_number']<=MAX_ROWS:fail('Invalid retained JSONL row number.') + sha(value['row_sha256']);return + if format_name=='image_caption_v1': + from caption_import import snapshot, SNAPSHOT_KEYS, RECORD_KEYS + closed(value,{'format','request_id','row_sha256','observed','declared'},'caption acquisition') + closed(value['observed'],{'manifest_sha256','metadata_sha256','asset_sha256','pixel_sha256'},'caption observed hashes') + closed(declared,{'manifest_name','metadata_path','row_number','image_file','origin_record','protected_component'},'caption declaration') + if declared['manifest_name']!='manifest.json' or declared['metadata_path'] not in ('train/metadata.jsonl','val/metadata.jsonl','test/metadata.jsonl'):fail('Invalid retained caption metadata location.') + upstream=declared['origin_record'];closed(upstream,RECORD_KEYS,'caption origin record') + members=declared['protected_component'] + if type(members) is not list or not 1<=len(members)<=30:fail('Invalid retained protected caption component.') + for member in members:snapshot(member) + if not any(member=={k:upstream[k] for k in SNAPSHOT_KEYS} for member in members):fail('Caption source is absent from its declared component.') + for digest_value in value['observed'].values():sha(digest_value) + elif format_name=='canonical_v1': + text_source=record['kind']=='text' + fields={'format','request_id','archive_sha256','manifest_sha256','row_sha256','asset_sha256','input_sha256','input_basis','declared'} + fields|={'metadata_sha256'} if text_source else {'coco_sha256','row_hash_basis','pixel_sha256','upstream_graph'} + closed(value,fields,'native acquisition') + closed(declared,{'archive_name','metadata_path','row_number','asset','upstream'},'native source declaration') + leaf_name(declared['archive_name'],'Archive filename') + closed(declared['upstream'],{'record','original_status'},'native upstream declaration') + if declared['upstream']['original_status']!='unavailable':fail('Native imported original status differs.') + upstream=declared['upstream']['record'] + from native_text_import import RECORD_KEYS + closed(upstream,RECORD_KEYS,'native origin record') + if text_source: + sha(value['metadata_sha256']) + if value['input_basis']!='consumed_exported_text' or declared['metadata_path'] not in ('train/records.jsonl','validation/records.jsonl','test/records.jsonl'):fail('Invalid retained native text input.') + else: + closed(value['coco_sha256'],{'train','validation','test'},'native COCO hashes') + for item in value['coco_sha256'].values():sha(item) + sha(value['pixel_sha256']) + if value['input_basis']!='consumed_exported_png' or value['row_hash_basis']!='canonical_parsed_manifest_record' or value['upstream_graph']!='selected_declared_links_only' or declared['metadata_path']!='manifest.json':fail('Invalid retained native image input.') + for field in ('archive_sha256','manifest_sha256','asset_sha256','input_sha256'):sha(value[field]) + else:fail('Unsupported retained acquisition format.','unsupported',409) + sha(value['row_sha256']) + if type(declared['row_number']) is not int or not 1<=declared['row_number']<=1000:fail('Invalid retained source row number.') + path=declared['image_file'] if format_name=='image_caption_v1' else declared['asset'] + if type(path) is not str or path!=upstream['asset'] or any(part in ('','.','..') for part in path.split('/')) or '\\' in path or path.startswith('/'):fail('Invalid retained source asset reference.') + captured={k:upstream[k] for k in SNAPSHOT_FIELDS} + _record(captured,identity(captured['id'])) + foreign_source=dict(kind=captured['kind'],original={'artifact':{'sha256':captured['source_sha256']}}) + origin=dict(captured['provenance']);origin.pop('rights_note_correction',None) + _origin(origin,captured,foreign_source,records) + + +def _origin(value,record,source,records): + kind=record['kind'] + method=value.get('method') + if method=='project_fork': + from project_data_fork import validate_origin + validate_origin(value,record,source,records) + return + if method in ('model_generated','model_grounded_rewrite'): + from project_producers import validate_record_origin + validate_record_origin(value,record,records) + return + if method=='procedural': + closed(value,{'method','recipe','recipe_version','seed','ordinal','factors','rights','verification'},'procedural origin') + from dataset_recipes import RECIPES + if value['recipe'] not in RECIPES or RECIPES[value['recipe']]['kind']!=kind or type(value['recipe_version']) is not int or value['recipe_version']!=1 or type(value['seed']) is not int or not 0<=value['seed']<=2**32-1 or type(value['ordinal']) is not int or not 0<=value['ordinal']<100 or type(value['factors']) is not dict: + fail('Invalid retained procedural origin.') + factors=value['factors'] + if kind=='image': + closed(factors,{'canvas','objects','occlusion'},'procedural image factors') + if factors['canvas']!=[128,96] or any(type(n) is not int for n in factors['canvas']) or type(factors['objects']) is not int or factors['objects']!=value['ordinal']%4 or type(factors['occlusion']) is not int or factors['occlusion']!=0:fail('Procedural image factors differ from the owned recipe.') + else: + from dataset_recipes import TEMPLATES + closed(factors,{'topic','day','hour','template'},'procedural text factors') + if factors['topic'] not in ('design review','project planning','reading group','demo','studio session') or factors['day'] not in ('Monday','Tuesday','Wednesday','Thursday','Friday') or type(factors['hour']) is not str or not re.fullmatch(r'(?:[89]|1[0-8]):(?:00|15|30|45)',factors['hour']) or type(factors['template']) is not int or factors['template']!=value['ordinal']%len(TEMPLATES):fail('Procedural text factors differ from the owned recipe.') + if value['verification']!='Targets derived from authored scene/template state; no human review or real-world quality claim.':fail('Procedural verification declaration differs.') + return + if kind=='text': + fields={'method','rights','normalization'} + if 'acquisition' in value:fields.add('acquisition') + closed(value,fields,'text origin') + if method!='import' or value['normalization']!='NFC; LF newlines':fail('Unsupported text origin.','unsupported',409) + elif kind=='image': + fields={'method','rights','source_sha256'} + if 'acquisition' in value:fields.add('acquisition') + closed(value,fields,'image origin') + if method!='import' or value['source_sha256']!=source['original']['artifact']['sha256']:fail('Image origin differs from its original bytes.') + elif kind=='sequence': + fields={'method','rights','adapter','run_sha256','frames_sha256'} + if 'sequence_acquisition' in value:fields.add('sequence_acquisition') + closed(value,fields,'sequence origin');meta=source['metadata'] + if method!='simulation_import' or value['adapter']!=meta['adapter'] or value['run_sha256']!=meta['run_sha256'] or value['frames_sha256']!=meta['manifest']['frames_sha256']:fail('Sequence origin differs from its owner metadata.') + elif kind in ('mesh','pointcloud','volume'): + fields={'method','rights','format','manifest_sha256'}|({'geometry_sha256'} if kind=='mesh' else set()) + closed(value,fields,'native source origin');meta=source['metadata'] + if method!=kind+'_import' or value['format']!=meta['manifest']['format'] or value['manifest_sha256']!=meta['manifest_sha256'] or kind=='mesh' and value['geometry_sha256']!=meta['manifest']['geometry_sha256']:fail('Native source provenance differs from its metadata.') + elif kind=='audio': + closed(value,{'method','rights','audio','source_sha256','transform'},'audio origin') + if method!='import' or value['audio']!=source['metadata'] or value['source_sha256']!=source['asset']['artifact']['sha256'] or value['transform']!='none; original interleaved PCM WAV bytes preserved':fail('Audio origin differs from its original source.') + else: + key='project_source' if method=='project_data_import' else 'run_source' + closed(value,{'method','rights','field_sequence','source_sha256',key},'scientific origin') + if value['field_sequence']!=source['metadata'] or value['source_sha256']!=source['asset']['artifact']['sha256']:fail('Scientific source origin differs.') + from scientific_fields import validate_source_declaration + validate_source_declaration(method,value[key],value['source_sha256'],source['metadata']['source_bytes']) + acquisition=value.get('acquisition',value.get('sequence_acquisition')) + if 'acquisition' in value or 'sequence_acquisition' in value: + _acquisition(acquisition,record,source,records) + + +def _record(row, record_id, source=None): + if type(row) is not dict or row.get('kind') not in ('image', 'text', *ASSET_KINDS): + fail('Unsupported data record kind.', 'unsupported', 409) + extra = {row['kind']} if row['kind'] in ASSET_KINDS else set() + optional = {'target_proposal'} if 'target_proposal' in row else set() + closed(row, SNAPSHOT_FIELDS | extra | optional, 'Workbench record') + if row['id'] != record_id: + fail('Workbench record identity differs.') + natural(row['revision']); sha(row['content_hash']) + if row['source_revision'] is not None: + natural(row['source_revision']) + if row['source_sha256'] is not None: + sha(row['source_sha256']) + if row['pixel_hash'] is not None: + sha(row['pixel_hash']) + text_value(row['name'], 'Name') + if strings(row['groups'], 'Groups') != row['groups'] or not row['groups'] or strings(row['parents'], 'Parents') != row['parents']: + fail('Invalid Workbench groups or parents.') + for parent in row['parents']: + identity(parent, 'parent') + if row['task'] not in TASKS or not row['task'].startswith(row['kind'] + '_') or row['review'] not in REVIEWS: + fail('Invalid Workbench task or review.') + if type(row['source_available']) is not bool or type(row['source_lineage_known']) is not bool: + fail('Invalid historical source availability.') + if type(row['provenance']) is not dict: + fail('Invalid Workbench provenance.') + text_value(row['provenance'].get('rights'), 'Rights note', 1000) + correction = row['provenance'].get('rights_note_correction') + if correction is not None: + closed(correction, {'note', 'revision'}, 'rights correction') + text_value(correction['note'], 'Rights note', 1000) + natural(correction['revision']) + if correction['revision'] > row['revision']: + fail('Rights correction is newer than its record.') + annotation_record = row + deleted_geometry = row['task'] == 'image_detection' and (row['width'] is None or row['height'] is None) + if deleted_geometry and source is not None: + # Source deletion removes the live join, not the retained image geometry. + # The complete codec validates this source history before acknowledging it. + geometry = source['revisions'][-1] + annotation_record = dict(row, width=geometry['width'], height=geometry['height']) + # A standalone tombstone frame has no geometry to prove box fit. Its full + # record decoder supplies retained source geometry; receipt validation also + # requires an exact match to that fully validated retained state. + if row['annotation'] is not None and not (deleted_geometry and source is None): + if validate_annotation(row['task'], row['annotation'], annotation_record) != row['annotation']: + fail('Workbench annotation is not canonical.') + if row['review'] != 'draft' and row['annotation'] is None: + fail('Reviewed record has no annotation.') + if row['kind'] == 'text': + text_value(row['text'], 'Text', MAX_TEXT) + if hashlib.sha256(row['text'].encode()).hexdigest() != row['content_hash']: + fail('Normalized text digest differs.') + elif row['text'] is not None: + fail('Non-text record contains text source.') + if date(row['updated_at']) < date(row['created_at']): + fail('Workbench update precedes creation.') + return row + + +def validate_record_snapshot(record): + """Pure Workbench capture validation for the producer history owner.""" + if type(record) is not dict: + fail('Captured record must be an object.') + _record(record, identity(record.get('id'))) + return copy.deepcopy(record) + + +def _response(row, record_id): + closed(row, RESPONSE_FIELDS, 'response') + if row['id'] != record_id: + fail('Response identity differs.') + identity(row['prompt_id'], 'prompt'); natural(row['revision']) + text_value(row['completion'], 'Completion', MAX_TEXT) + if row['review'] not in ('draft', 'human_reviewed'): + fail('Invalid response review.') + closed(row['provenance'], {'method', 'prompt_id', 'prompt_sha256', 'source_sha256'}, 'response provenance') + if row['provenance']['method'] not in ('human_authored', 'agent_authored') or row['provenance']['prompt_id'] != row['prompt_id']: + fail('Invalid response origin.') + sha(row['provenance']['prompt_sha256']); sha(row['provenance']['source_sha256']) + if date(row['updated_at']) < date(row['created_at']): + fail('Response update precedes creation.') + + +def _judgment(row, record_id): + closed(row, JUDGMENT_FIELDS, 'preference') + if row['id'] != record_id: + fail('Preference identity differs.') + for key in ('id', 'prompt_id', 'left_id', 'right_id'): + identity(row[key], key) + for key in ('revision', 'parent_revision', 'source_revision', 'left_revision', 'right_revision'): + natural(row[key]) + if row['left_id'] == row['right_id'] or row['outcome'] not in ('left', 'right', 'tie', 'abstain') or row['review'] not in ('draft', 'human_reviewed') or type(row['deleted']) is not bool: + fail('Invalid preference meaning.') + text_value(row['rationale'], 'Rationale', 4000) + closed(row['provenance'], {'method'}, 'preference provenance') + if row['provenance']['method'] != ('human_comparative_judgment' if row['review'] == 'human_reviewed' else 'draft_comparative_judgment'): + fail('Preference origin differs from review.') + if date(row['updated_at']) < date(row['created_at']): + fail('Preference update precedes creation.') + + +def _audit_bindings(family,record_id,doc): + """Audit hashes describe exact owner snapshots, never free-standing claims.""" + for fact in doc.get('audit',[]): + action,details=fact['action'],fact['details'] + if action=='dataset.add_generated' and family=='workbench_record': + origin=doc['origin'] + if origin.get('method')!='model_generated' or fact['target']!=origin['generation']['job_id']: + fail('Generated source audit differs from its exact producer job.') + elif fact['target']!=record_id:fail('Data audit target differs from its owning record.') + expected=None + if family=='workbench_record': + states=[doc['current'],*doc['history'],*(e['snapshot'] for e in doc['events'])] + if action.startswith('workbench.'): + candidates=[s for s in states if s['revision']==details.get('revision') and s['source_revision']==details.get('source_revision') and digest(s)==details.get('content_sha256')] + if not candidates:fail('Workbench audit snapshot is not retained.') + state=candidates[0] + expected=dict(revision=state['revision'],source_revision=state['source_revision'],content_sha256=digest(state)) + if action in ('workbench.save','workbench.approve_review','workbench.verify','workbench.correct_rights_note'): + expected.update(before_revision=state['revision']-1,before_source_revision=state['source_revision']) + elif action in ('dataset.add','dataset.add_generated'): + source=doc['source'] + if source['kind']!='image':fail('Image audit is attached to another modality.') + expected=dict(sample_id=record_id,revision=1,source_sha256=source['revisions'][0]['sha256']) + elif action=='dataset.save': + source=doc['source'];sample=next((r for r in source.get('revisions',[]) if r['revision']==details.get('revision')),None) + if sample is None:fail('Image audit source revision is not retained.') + expected=dict(before_revision=sample['revision']-1,revision=sample['revision'],annotation_sha256=digest(sample['annotation'])) + elif action=='dataset.delete': + if not doc['source'].get('deleted'):fail('Image deletion audit lacks its tombstone.') + expected={} + elif action in ('immutable_assets.admit','audio_assets.admit','scientific_fields.admit'): + expected=dict(revision=1,source_sha256=doc['current']['content_hash']) + elif family=='workbench_response': + state=next((h for h in doc['history'] if h['response']['revision']==details.get('revision')),None) + if state and action in ('workbench.save_response','workbench.approve_response'): + r,p=state['response'],state['parent'] + expected=dict(before_revision=r['revision']-1,revision=r['revision'],prompt_id=p['id'],parent_revision=p['revision'],source_revision=p['source_revision'],content_sha256=digest(state)) + elif family=='workbench_preference': + state=next((h for h in doc['history'] if h['judgment']['revision']==details.get('revision')),None) + if state and action in ('preferences.save','preferences.delete'): + expected=dict(revision=state['judgment']['revision'],review=state['judgment']['review'],binding_sha256=digest(state)) + elif family in ('saved_selection','saved_search'): + owner='saved_selections' if family=='saved_selection' else 'saved_searches' + state=next((h for h in doc['history'] if h['revision']==details.get('revision')),None) + if state: + if action==owner+'.create':expected=dict(revision=1,content_sha256=digest(state['items'] if family=='saved_selection' else state['criteria'])) + elif action==owner+'.rename':expected=dict(revision=state['revision'],name_sha256=hashlib.sha256(state['name'].encode()).hexdigest()) + elif action==owner+'.delete' and doc['deleted']:expected=dict(revision=state['revision'],retired_sha256=digest(state)) + elif family=='dataset_release' and action=='dataset_releases.create': + expected=dict(release_sha256=record_id,format=doc['release_format']) + if expected is None or encode_retained(expected)!=encode_retained(details): + fail('Data audit details do not match the retained owner facts.') + + +def _decode_data_records(records): + """Validate complete owned closure, without opening missing payloads or SQL.""" + if type(records) is not list or any(type(r) is not dict for r in records): + fail('Project records must be a list.') + owned, assets, responses, declarations, seen = [], {}, {}, {}, set() + legacy = [r for r in records if r.get('family') == 'workbench_text'] + # Legacy parent closure may cross to a new record; validate each legacy + # record locally, then check parent closure across all data families below. + for item in legacy: + single = copy.deepcopy(item) + parents = single['document']['current']['parents'] + if parents: + # The unchanged M1 decoder requires its family-local parent set. + # Supply the legacy set, whose already-admitted parents stay v1. + decode_text_records(legacy) + break + decode_text_records([single]) + for item in legacy: + assets[item['id']] = item['document'] + for envelope in records: + if type(envelope) is not dict: + fail('Invalid project envelope.') + family = envelope.get('family') + if family not in FAMILIES: + continue + closed(envelope, {'family', 'id', 'document'}, 'data envelope') + record_id, doc = envelope['id'], envelope['document'] + if family in ('dataset_release', 'dataset_exposure'): + sha(record_id) if family == 'dataset_release' else identity(record_id) + else: + identity(record_id) + if (family, record_id) in seen: + fail('Duplicate data envelope.') + seen.add((family, record_id)) + if type(doc) is not dict or doc.get('format') != FAMILIES[family]: + fail('Unsupported data document format.', 'unsupported', 409) + owned.append(envelope) + if family == 'workbench_record': + if record_id in assets: + fail('An asset has more than one current domain family.') + assets[record_id] = doc + elif family == 'workbench_response': + responses[record_id] = doc + for envelope in records: + if envelope.get('family') == 'project_artifact': + declaration = envelope['document'] + closed(declaration, {'format', 'sha256', 'bytes'}, 'artifact declaration') + if declaration['format'] != 'project_artifact_v1' or declaration['sha256'] != envelope['id']: + fail('Artifact declaration identity differs.') + sha(declaration['sha256']) + if type(declaration['bytes']) is not int or not 0<=declaration['bytes']<=2**63-1:fail('Invalid artifact declared byte count.') + declarations[envelope['id']] = declaration + def binding(parent): + _record(parent, identity(parent.get('id'))) + retained = assets.get(parent['id']) + if retained is None: + fail('Captured prompt/source is missing from data closure.') + candidates = [retained['current'], *retained['history'], *(e['snapshot'] for e in retained.get('events',[]))] + if not any(state == parent for state in candidates): + fail('Captured prompt/source snapshot differs from its retained revision.') + for envelope in owned: + family, record_id, doc = envelope['family'], envelope['id'], envelope['document'] + if family == 'workbench_record': + closed(doc, {'format', 'origin', 'source', 'current', 'history', 'events', 'audit'}, 'data record document') + source, current, history = doc['source'], doc['current'], doc['history'] + if type(source) is not dict or source.get('kind') not in ('image', 'text', *ASSET_KINDS): + fail('Invalid data source kind.') + kind = source['kind'] + _record(current, record_id, source) + if current['kind'] != kind or type(doc['origin']) is not dict or 'rights_note_correction' in doc['origin']: + fail('Record source/origin differs.') + if kind == 'text': + closed(source, {'kind', 'original_text'}, 'text source') + text_value(source['original_text'], 'Original text', MAX_TEXT) + normalized = unicodedata.normalize('NFC', source['original_text'].replace('\r\n', '\n').replace('\r', '\n')) + if current['text'] != normalized or current['source_sha256'] != hashlib.sha256(source['original_text'].encode()).hexdigest(): + fail('Original text normalization/source hash differs.') + elif kind == 'image': + closed(source, {'kind', 'original', 'oriented', 'revisions', 'deleted'}, 'image source') + original = _edge(source['original'], declarations, kind, 'original') + oriented = _edge(source['oriented'], declarations, kind, 'oriented') + if type(source['deleted']) is not bool or type(source['revisions']) is not list or not source['revisions']: + fail('Invalid image source lifecycle.') + previous = 0 + for row in source['revisions']: + _source_row(row, record_id) + if row['revision'] != previous + 1 or row['sha256'] != original['sha256'] or any(row[k] != source['revisions'][0][k] for k in ('width','height','created_at')): + fail('Image source revisions or original hash differ.') + previous = row['revision'] + if current['content_hash'] != oriented['sha256']: + fail('Oriented image identity differs.') + else: + closed(source, {'kind', 'asset', 'metadata'}, 'immutable source') + declaration = _edge(source['asset'], declarations, kind, 'original') + if current['content_hash'] != declaration['sha256'] or current[kind] != source['metadata']: + fail('Immutable source metadata or content identity differs.') + if type(source['metadata']) is not dict: + fail('Immutable metadata must be an object.') + _source_metadata(kind,source['metadata']) + expected_bytes=source['metadata'].get('bundle_bytes',source['metadata'].get('source_bytes')) + if expected_bytes!=declaration['bytes'] or not set(source['metadata']['protected_groups'])<=set(current['groups']): + fail('Immutable byte count or protected groups differ.') + _origin(doc['origin'],current,source,records) + if type(history) is not list or not history: + fail('Workbench annotation history is missing.') + for revision, state in enumerate(history, 1): + _record(state, record_id, source) + if state['revision'] != revision or state['kind'] != kind or state['content_hash'] != current['content_hash']: + fail('Workbench immutable identity/history revisions differ.') + if state['created_at']!=current['created_at'] or state['parents']!=current['parents'] or state['pixel_hash']!=current['pixel_hash']: + fail('Workbench history rewrites immutable source lineage.') + if revision>1 and date(state['updated_at'])1 and date(state['response']['updated_at'])1:fail('Fork asset has ambiguous destination mappings.') + return values[0]['destination_id'] if values else None + def asset_row(record_id): + new_id=asset_mapping(record_id) + return indexed[('workbench_record',new_id)]['document']['history'][0] if new_id else None + for mapping in own: + family=mapping['family'];original=indexed[(family,mapping['source_id'])] + destination=indexed[(_destination_family(family),mapping['destination_id'])] + doc=destination['document'] + initial=doc['history'][0] + row=initial['response'] if family=='workbench_response' else initial['judgment'] if family=='workbench_preference' else initial + now=row['created_at'];date(now) + if family in ('workbench_record','workbench_text'): + if mapping['source_revision']!=_revision(original):fail('Fork source domain revision differs.') + expected=_asset(original,destination['id'],overlay['source']['project_id'],overlay['source']['checkpoint'],now,asset_mapping)['document'] + if initial!=expected['history'][0] or doc['origin']!=expected['origin']: + fail('Fork initial Workbench snapshot differs from its exact derived draft.') + if doc['source']['kind']=='image': + if doc['source']['revisions'][0]!=expected['source']['revisions'][0]:fail('Fork initial image source differs.') + elif doc['source']!=expected['source']:fail('Fork immutable source differs.') + elif family=='workbench_response': + revision=mapping['source_revision'];natural(revision) + source_row=next((h['response'] for h in original['document']['history'] if h['response']['revision']==revision),None) + if source_row is None:fail('Fork response source revision is missing.') + parent=asset_row(source_row['prompt_id']) + if parent is None:fail('Fork response lost its current prompt.') + expected=_response(original,revision,destination['id'],parent,now)['document'] + if initial!=expected['history'][0]:fail('Fork initial response binding differs.') + elif family=='workbench_preference': + if mapping['source_revision']!=_revision(original):fail('Fork judgment source revision differs.') + source_row=original['document']['current'];parent=asset_row(source_row['prompt_id']);answers=[] + for side in ('left','right'): + answer=by_source.get(('workbench_response',source_row[side+'_id'],source_row[side+'_revision'])) + if answer is None:fail('Fork judgment exact answer revision is missing.') + answers.append(indexed[('workbench_response',answer['destination_id'])]['document']['history'][0]['response']) + expected=_preference(original,destination['id'],parent,*answers,now)['document'] + if initial!=expected['history'][0]:fail('Fork initial preference binding differs.') + else: + if mapping['source_revision']!=_revision(original):fail('Fork saved intent source revision differs.') + expected=_intent(original,destination['id'],now,asset_row)['document'] + if initial!=expected['history'][0]:fail('Fork initial saved intent differs.') + return True + + +def source_origin(origin, records): + """Follow only validated inert fork facts to a subtype source declaration.""" + indexed=_index(records);seen=set();value=origin + while value.get('method')=='project_fork': + ref=value['origin'];key=ref['family'],ref['id'] + if key in seen or key not in indexed:fail('Fork source provenance is cyclic or unavailable.') + seen.add(key);doc=indexed[key]['document'] + value=doc.get('origin',doc['current']['provenance']) + return copy.deepcopy(value) diff --git a/project_dispatch.py b/project_dispatch.py new file mode 100644 index 00000000..d12eb3f9 --- /dev/null +++ b/project_dispatch.py @@ -0,0 +1,426 @@ +"""Fixed M2 public-to-owner dispatch for one already bound project projection. + +The workspace validates selection, binding, checkpoint and the HTTP envelope. +This module projects bounded pages and calls existing domain methods. It neither +constructs live owners nor interprets scientific records or imported authority. +""" +import base64 +import binascii +import copy + +from workbench import WorkbenchError + + +_BOUND_FIELDS = frozenset(('binding', 'checkpoint', 'expected_checkpoint', 'request_key')) +_DATA_ACTIONS = frozenset(('data_query', 'data_get', 'data_availability', 'data_history', + 'data_responses', 'data_response_history', 'data_preferences', 'data_preference_history', + 'data_import_text', 'data_import_image', 'data_import_audio', 'data_import_mesh', + 'data_import_pointcloud', 'data_import_volume', 'data_import_sequence', 'data_import_scientific', + 'data_annotate', 'data_rights', 'data_source_save', 'data_source_delete', 'data_response_save', + 'data_preference_save', 'data_preference_delete', 'selections', 'selection_get', 'selection_create', + 'selection_rename', 'selection_delete', 'searches', 'search_create', 'search_rename', + 'search_delete', 'release_preview', 'release_freeze')) +_MATERIAL_ACTIONS = frozenset(('definitions', 'models', 'model_get', 'model_create', 'model_save', + 'model_publish', 'graphs', 'graph_get', 'graph_create', 'graph_create_external', 'graph_save', 'graph_clone', 'recipes', + 'recipe_get', 'recipe_create', 'recipe_save', 'recipe_prepare', 'model_material', + 'recipe_material', 'runtime_material', 'snapshot', 'materials')) +_HISTORY_ACTIONS = frozenset(('studies', 'study', 'study_create', 'study_save', 'trial', 'trial_prepare', + 'lineage', 'lineage_record', 'run', 'runs', 'candidate', 'candidate_select', 'evidence', + 'retained_analysis', 'retained_analysis_export', 'policies', 'policy', 'dependencies', + 'publication_result', 'package_state', 'expose_holdout', 'run_document_segment')) +_OTHER_ACTIONS = frozenset(('artifacts', 'artifact_status', 'artifact_resolve', 'producers', 'producer', 'source_register', 'source_references')) +_EXECUTION_ACTIONS = frozenset(('native_preflight','native_submit','native_retry','native_reconcile','execution_health', 'execution_preflight_external', 'execution_approve_external', + 'execution_submit_external', 'execution_cancel', 'execution_study_continue', 'execution_study_approve', + 'execution_study_admit', 'execution_study_stop', 'execution_reconcile_current', + 'execution_producer_start', 'execution_generation_resume', 'execution_grounded_review', + 'execution_producer_decide', 'execution_producer_cancel', 'execution_producer_health', + 'execution_study_prepare_package','execution_study_prepare_verifier','execution_study_abandon_preparation','execution_study_reconcile_preparation')) +_RUNTIME_ACTIONS=frozenset(('runtime_sources','runtime_inspect','runtime_capture','runtime_status','runtime_abandon')) +_M4_ACTIONS=frozenset(('files_inspect','file_get','file_history','file_adopt','file_adopt_deletion','material_intents','material_intent','material_intent_capabilities','fork_info')) +SUPPORTED_OPERATIONS = frozenset('project_files.' + action for action in + _DATA_ACTIONS | _MATERIAL_ACTIONS | _HISTORY_ACTIONS | _OTHER_ACTIONS | _EXECUTION_ACTIONS | _M4_ACTIONS | _RUNTIME_ACTIONS) + + +def _body(arguments, *omit): + return {key: copy.deepcopy(value) for key, value in arguments.items() + if key not in _BOUND_FIELDS and key not in omit} + + +def _page(items, arguments, *, default=40): + offset, limit = arguments.get('offset', 0), arguments.get('limit', default) + if type(offset) is not int or offset < 0 or type(limit) is not int or not 1 <= limit <= (4 if default == 1 else 40): + raise WorkbenchError('Invalid project page bounds.') + return dict(items=copy.deepcopy(items[offset:offset + limit]), total=len(items), offset=offset, limit=limit) + + +def _origin_items(items, arguments): + origin = arguments.get('origin_catalog_id') + return [item for item in items if origin is None or item['origin_catalog_id'] == origin] + + +def _declarations(projection): + return {item['id']: item['document'] for item in projection.session.snapshot['records'] + if item['family'] == 'project_artifact'} + + +def _artifact(projection, sha): + declaration = _declarations(projection).get(sha) + if declaration is None: + raise WorkbenchError('Project artifact declaration is unavailable.', 'unavailable', 404) + return declaration + + +def _bundle(projection, kind, arguments): + """Existing native parsers alone assign bundle meaning, before publication.""" + body = _body(arguments) + files = body['files'] + try: + if kind == 'mesh': + import meshes + prepared = meshes.prepare(meshes.decode_file(files['mesh.ply'], meshes.PLY_LIMIT, 'mesh.ply'), + meshes.decode_file(files['mesh.json'], meshes.MANIFEST_LIMIT, 'mesh.json')) + elif kind in ('pointcloud', 'volume'): + import spatial_assets + parser, names, caps = spatial_assets.profile(kind) + raw = [] + for name, cap in zip(names, caps): + value = base64.b64decode(files[name], validate=True) + if not 0 < len(value) <= cap: + raise ValueError() + raw.append(value) + prepared = parser.prepare(*raw) + elif kind == 'sequence': + prepared = projection.data.workbench.sequences.prepare_project_files(files) + else: + raise WorkbenchError('Unsupported native project import.', 'unsupported', 409) + except WorkbenchError: + raise + except (ValueError, binascii.Error, KeyError, TypeError): + raise WorkbenchError('Invalid native project import bundle.') from None + return projection.data.import_bundle(kind, prepared, body, arguments['expected_checkpoint'], arguments['request_key']) + + +def _data(projection, action, a): + data = projection.data + if action == 'data_query': return data.query(_body(a)) + if action == 'data_get': return data.get(a['record_id']) + if action == 'data_availability': return data.availability(a['record_id']) + if action == 'data_history': return _page(data.history(a['record_id']), a, default=1) + if action == 'data_responses': return _page(data.responses(a['record_id'])['responses'], a) + if action == 'data_response_history': + return _page(sorted(data.response_history(a['record_id'])['history'], key=lambda item: item['revision'], reverse=True), a, default=1) + if action == 'data_preferences': return _page(data.preferences(a['record_id'])['judgments'], a) + if action == 'data_preference_history': + return _page(sorted(data.preference_history(a['record_id'])['history'], key=lambda item: item['revision'], reverse=True), a, default=1) + if action == 'selections': return _page(sorted(data.list_selections()['selections'], key=lambda item: item['id']), a) + if action == 'searches': return _page(sorted(data.list_searches()['searches'], key=lambda item: item['id']), a) + if action == 'selection_get': return data.load_selection(a['selection_id']) + if action == 'release_preview': return data.preview_release(_body(a)) + cp, key = a['expected_checkpoint'], a['request_key'] + if action == 'data_import_text': return data.import_text(_body(a), cp, key) + if action == 'data_import_image': return data.import_image(_body(a), cp, key) + if action == 'data_import_audio': return data.import_audio(_body(a), cp, key) + if action == 'data_import_mesh': return _bundle(projection, 'mesh', a) + if action == 'data_import_pointcloud': return _bundle(projection, 'pointcloud', a) + if action == 'data_import_volume': return _bundle(projection, 'volume', a) + if action == 'data_import_sequence': return _bundle(projection, 'sequence', a) + if action == 'data_import_scientific': return projection.source_adapter.import_scientific(_body(a), cp, key) + if action == 'data_annotate': return data.annotate(a['record_id'], _body(a, 'record_id'), cp, key) + if action == 'data_rights': return data.correct_rights_note(a['record_id'], _body(a, 'record_id'), cp, key) + if action == 'data_source_save': return data.save_image_source(a['record_id'], a['document'], cp, key) + if action == 'data_source_delete': return data.delete_image_source(a['record_id'], a['document'], cp, key) + if action == 'data_response_save': return data.save_response(a['document'], cp, key) + if action == 'data_preference_save': return data.save_preference(a['document'], cp, key) + if action == 'data_preference_delete': return data.delete_preference(a['document'], cp, key) + if action == 'selection_create': return data.create_selection(a['document'], cp, key) + if action == 'selection_rename': return data.rename_selection(a['selection_id'], _body(a, 'selection_id'), cp, key) + if action == 'selection_delete': return data.delete_selection(a['selection_id'], _body(a, 'selection_id'), cp, key) + if action == 'search_create': return data.create_search(a['document'], cp, key) + if action == 'search_rename': return data.rename_search(a['search_id'], _body(a, 'search_id'), cp, key) + if action == 'search_delete': return data.delete_search(a['search_id'], _body(a, 'search_id'), cp, key) + if action == 'release_freeze': return data.freeze_release(_body(a), cp, key) + raise AssertionError('Closed data dispatch is incomplete.') + + +def _definitions(projection, family, a): + docs = projection.materials._documents() + values = [doc['current'] for (kind, _), doc in docs.items() + if kind == family and doc['origin_catalog_id'] == projection.session.snapshot['project_id']] + return _page(sorted(values, key=lambda item: item['id']), a) + + +def _material(projection, action, a): + materials, models, pipelines = projection.materials, projection.context.models, projection.context.pipelines + if action == 'definitions': return pipelines.definitions() + if action == 'models': return _definitions(projection, 'model_definition', a) + if action == 'graphs': return _definitions(projection, 'pipeline_graph', a) + if action == 'recipes': return _definitions(projection, 'recipe_definition', a) + if action == 'model_get': return models.get(a['model_id']) + if action == 'graph_get': return pipelines.get(a['graph_id']) + if action == 'recipe_get': return pipelines.get_recipe(a['recipe_id']) + if action == 'model_material': return materials.model(a['ref'], verify=a.get('verify', False)) + if action == 'recipe_material': return materials.recipe(a['ref'], verify=a.get('verify', False)) + if action == 'runtime_material': return materials.runtime(a['ref'], verify=a.get('verify', False)) + if action == 'snapshot': return materials.snapshot_ref(a['project_id'], a['manifest_sha256'], + catalog_id=a['origin_catalog_id'], verify=a.get('verify', False)) + if action == 'materials': + items = [] + for (family, _), doc in materials._documents().items(): + if family not in ('model_material', 'recipe_material', 'runtime_material', 'project_snapshot'): continue + value = doc['value'] + identity = value.get('ref', {}).get('id', value.get('id')) + item = dict(family=family, origin_catalog_id=doc['origin_catalog_id'], id=identity, + format=value.get('material', value).get('format')) + if 'ref' in value: item['ref'] = copy.deepcopy(value['ref']) + else: item['manifest_sha256'] = value['manifest_sha256'] + items.append(item) + return _page(sorted(_origin_items(items, a), key=lambda item: (item['origin_catalog_id'], item['family'], item['id'])), a) + cp, key = a['expected_checkpoint'], a['request_key'] + if action == 'model_create': + body = _body(a) + return materials.mutate_definition('model_definition', 'models.create', None, body, cp, key, lambda: models.create(body)) + if action == 'model_save': + body = _body(a, 'model_id') + return materials.mutate_definition('model_definition', 'models.save', a['model_id'], body, cp, key, lambda: models.save(a['model_id'], body)) + if action == 'model_publish': return materials.publish_model(models, a['model_id'], _body(a, 'model_id'), cp, key) + if action == 'graph_create': + body = _body(a) + return materials.mutate_definition('pipeline_graph', 'graphs.create', None, body, cp, key, lambda: pipelines.create(body)) + if action == 'graph_create_external': + body = _body(a) + return materials.mutate_definition('pipeline_graph', 'graphs.create_external', None, body, cp, key, lambda: pipelines.create_external(body)) + if action == 'graph_save': + body = _body(a, 'graph_id') + return materials.mutate_definition('pipeline_graph', 'graphs.save', a['graph_id'], body, cp, key, lambda: pipelines.save(a['graph_id'], body)) + if action == 'graph_clone': + body = _body(a, 'graph_id') + return materials.mutate_definition('pipeline_graph', 'graphs.clone', a['graph_id'], body, cp, key, lambda: pipelines.clone(a['graph_id'], body)) + if action == 'recipe_create': + body = _body(a) + return materials.mutate_definition('recipe_definition', 'recipes.create', None, body, cp, key, lambda: pipelines.create_recipe(body)) + if action == 'recipe_save': + body = _body(a, 'recipe_id') + return materials.mutate_definition('recipe_definition', 'recipes.save', a['recipe_id'], body, cp, key, lambda: pipelines.save_recipe(a['recipe_id'], body)) + if action == 'recipe_prepare': + from pipeline import model_aware_graph + graph=pipelines.get(a['graph_id']);builtin=model_aware_graph(graph) + if graph.get('recipe')!='external_recipe' and not builtin: + raise WorkbenchError('This native graph does not support composed model material.','unsupported',409) + if 'builtin' in a and a['builtin'] is not builtin: + raise WorkbenchError('Recipe preparation kind differs from the saved graph.','conflict',409) + return materials.prepare_recipe(pipelines, a['graph_id'], a['revision'], a['model'], cp, key, + builtin=builtin, reconcile_capture=a.get('reconcile_capture', False), + runtime_manifest=projection.runtime_manifest) + raise AssertionError('Closed material dispatch is incomplete.') + + +def _study_summary(study): + summary = copy.deepcopy(study) + trial_ids = summary.pop('trial_ids') + summary['source_format'] = summary.pop('format') + summary['format'] = 'project_study_summary_v1' + summary['trial_count'] = len(trial_ids) + return summary + + +def _study_page(history, arguments): + value = history.study(arguments['study_id'], origin_catalog_id=arguments['origin_catalog_id'], + offset=arguments.get('offset', 0), limit=arguments.get('limit', 40), revision=arguments.get('revision')) + result = copy.deepcopy(value) + result['study'] = _study_summary(value['study']) + result['trials'] = _page([{'id': identity} for identity in value['study']['trial_ids']], arguments) + return result + + +def _run_page(history, arguments): + value = history.run(arguments['run_id'], origin_catalog_id=arguments['origin_catalog_id'], + offset=arguments.get('offset', 0), limit=arguments.get('limit', 40)) + # The Run owner applies the shared disclosure projection first. Large + # captured documents and collections remain exact through document segments. + run = value['run'] + fields = ('mode', 'id', 'dataset_id', 'request_key', 'state', 'created_at', 'started_at', + 'finished_at', 'graph_revision', 'graph_sha256', 'attempt', 'parent_run_id', + 'quality_claim', 'execution_kind', 'result_kind', 'material_sha256', + 'approval_id', 'actor_id', 'custody_phase', 'release_id', 'release_sha256', 'release_bytes') + summary = {key: copy.deepcopy(run[key]) for key in fields if key in run} + summary.update(format='project_run_summary_v1', source_schema_version=run['schema_version'], + available_fields=sorted(run), + collection_counts={key: len(run[key]) for key in + ('events', 'artifacts', 'node_attempts', 'exposure_events', 'prior_test_exposure', '_phase_timings') + if isinstance(run.get(key), list)}) + result = {key: copy.deepcopy(item) for key, item in value.items() if key != 'run'} + result['run'] = summary + return result + + +def _history(projection, action, a): + history = projection.history + origin = a.get('origin_catalog_id') + if action == 'studies': + return _page([dict(origin_catalog_id=item['origin_catalog_id'], study=_study_summary(item['study'])) + for item in _origin_items(history.studies()['studies'], a)], a) + if action == 'study': return _study_page(history, a) + if action == 'trial': return history.trial(a['trial_id'], origin_catalog_id=origin) + if action == 'run': return _run_page(history, a) + if action == 'run_document_segment': return history.run_document_segment(a['run_id'], + origin_catalog_id=origin, expected_sha256=a['expected_sha256'], offset=a['offset'], limit=a['limit']) + if action == 'candidate': return history.candidate(a['candidate_id'], origin_catalog_id=origin, verify=a.get('verify', False)) + if action == 'lineage': return _page(history.lineage(origin_catalog_id=origin)['records'], a) + if action == 'policies': return _page(history.policies(origin_catalog_id=origin)['policies'], a) + if action == 'policy': return history.policy(a['ref']) + if action == 'runs': + items = [] + for (catalog, identity), doc in sorted(history._facts().runs.items()): + run = doc['run'] + summary = {key: copy.deepcopy(run[key]) for key in ('id', 'name', 'state', 'created_at', 'finished_at', 'execution_kind') if key in run} + items.append(dict(origin_catalog_id=catalog, run=summary)) + return _page(_origin_items(items, a), a) + if action == 'dependencies': return history.dependencies(a['run_id'], origin_catalog_id=origin, + offset=a.get('offset', 0), limit=a.get('limit', 40)) + if action == 'publication_result': return history.publication_result(a['run_id'], origin_catalog_id=origin) + if action == 'package_state': return history.package_state(a['candidate_id'], origin_catalog_id=origin) + if action == 'evidence': return history.evidence(a['run_id'], a.get('selectors'), origin_catalog_id=origin) + if action in ('retained_analysis', 'retained_analysis_export'): + return history.retained_analysis(origin, _body(a, 'origin_catalog_id'), export=action == 'retained_analysis_export') + cp, key = a['expected_checkpoint'], a['request_key'] + if action == 'study_create': return history.create_study(_body(a), cp, key) + if action == 'study_save': return history.save_study(a['study_id'], _body(a, 'study_id'), cp, key) + if action == 'trial_prepare': return history.prepare_trial_from_material(a['study_id'], _body(a, 'study_id'), cp, key) + if action == 'candidate_select': return history.select_candidate(a['study_id'], _body(a, 'study_id'), cp, key) + if action == 'lineage_record': return history.record_lineage(_body(a), cp, key) + if action == 'expose_holdout': return projection.disclose(a['run_id'], _body(a, 'run_id'), cp, key) + raise AssertionError('Closed historical dispatch is incomplete.') + + +def _execution(projection, action, a): + execution = projection.execution + if execution is None: + raise WorkbenchError('Current execution is unavailable for this project.', 'unavailable', 409) + if action == 'execution_health': return execution.health(offset=a.get('offset', 0), limit=a.get('limit', 40)) + if action == 'execution_preflight_external': return execution.preflight_external(a['graph_id'], a['revision']) + if action == 'execution_reconcile_current': return execution.reconcile_current() + if action == 'execution_producer_health': + if execution.producer_execution is None: + raise WorkbenchError('Current producer execution is unavailable.', 'unavailable', 409) + return execution.producer_execution.status(offset=a.get('offset', 0), limit=a.get('limit', 40)) + if action=='execution_study_reconcile_preparation': + if execution.study_execution is None:raise WorkbenchError('Current Study execution is unavailable.','unavailable',409) + return execution.study_execution.reconcile_preparation(a['study_id'],_body(a,'study_id'),a['expected_checkpoint']) + if action=='native_reconcile':return execution.native_reconcile(a['phase_id'],a['expected_checkpoint']) + cp, key = a['expected_checkpoint'], a['request_key'] + if action=='native_preflight':return execution.native_preflight(a['graph_id'],a['revision'],cp,key) + if action=='native_submit':return execution.native_submit(_body(a),cp,key) + if action=='native_retry':return execution.native_retry(a['run_id'],cp,key) + if action == 'execution_producer_start' or action in ('execution_generation_resume', 'execution_grounded_review', 'execution_producer_decide', 'execution_producer_cancel'): + producers = execution.producer_execution + if producers is None: + raise WorkbenchError('Current producer execution is unavailable.', 'unavailable', 409) + if action == 'execution_producer_start': return producers.start(a['family'], a['document'], cp, key) + if action == 'execution_generation_resume': return producers.resume_generation(a['producer_id'], cp, key) + if action == 'execution_grounded_review': return producers.review_grounded(a['producer_id'], a['document'], cp, key) + if action == 'execution_producer_decide': return producers.decide(a['family'], a['producer_id'], a['document'], cp, key) + return producers.cancel(a['family'], a['producer_id'], cp, key) + if action == 'execution_approve_external': return execution.approve_external(_body(a), cp, key) + if action == 'execution_submit_external': return execution.submit_external(_body(a), cp, key) + if action == 'execution_cancel': return execution.cancel_run(a['run_id'], cp, key) + studies = execution.study_execution + if studies is None: + raise WorkbenchError('Current Study execution is unavailable.', 'unavailable', 409) + if action=='execution_study_prepare_package':return studies.prepare_package(a['study_id'],_body(a,'study_id'),cp,key) + if action=='execution_study_prepare_verifier':return studies.prepare_verifier(a['study_id'],_body(a,'study_id'),cp,key) + if action=='execution_study_abandon_preparation':return studies.abandon_preparation(a['study_id'],_body(a,'study_id'),cp,key) + if action == 'execution_study_continue': return studies.continue_study(a['study_id'], _body(a, 'study_id'), cp, key) + if action == 'execution_study_approve': return studies.approve(a['study_id'], _body(a, 'study_id'), cp, key) + if action == 'execution_study_admit': return studies.admit(a['study_id'], _body(a, 'study_id'), cp, key) + if action == 'execution_study_stop': return studies.stop(a['study_id'], _body(a, 'study_id'), cp, key) + raise AssertionError('Closed execution dispatch is incomplete.') + + +def _runtime(projection, action, a): + if action=='runtime_sources': + return _page([dict(source=alias) for alias in projection.runtime_sources()['sources']],a) + if action=='runtime_inspect': + value=projection.runtime.inspect({'source':a['source']});manifest=value['manifest'] + return dict(format='project_runtime_inspection_v1',source=a['source'], + **{field:copy.deepcopy(value[field]) for field in ('inventory_sha256','file_count','total_bytes','manifest_bytes','free_bytes')}, + profile=manifest['profile'],python_version=manifest['python_version'],launch=copy.deepcopy(manifest['launch']), + packages=[dict(name=name,version=version) for name,version in sorted(manifest['packages'].items())], + files=_page(manifest['files'],a)) + if action=='runtime_status':return projection.runtime.status(a['capture_key']) + if action=='runtime_abandon':return projection.runtime.abandon(a['capture_key'],a['reason'],a['expected_checkpoint'],a['request_key']) + if action=='runtime_capture': + options={'reserve_bytes':a['reserve_bytes']} if 'reserve_bytes' in a else {} + return projection.runtime.capture({'source':a['source']},a['expected_inventory_sha256'], + a['expected_checkpoint'],a['request_key'],**options) + raise AssertionError('Closed runtime dispatch is incomplete.') + + +def _m4(projection,action,a): + session=projection.session + if action=='file_adopt': + return projection.files.adopt(_body(a),a['expected_checkpoint'],a['request_key']) + if action=='file_adopt_deletion': + return projection.files.adopt_deletion(_body(a),a['expected_checkpoint'],a['request_key']) + with session.access.lock,session.lock,session.db.lock: + session.access.require('inspect','project_files.'+action);snapshot=session.check() + if action=='files_inspect':return _page(projection.files.inspect()['items'],a) + if action=='file_get':return projection.files.read(a['file_id'],a.get('revision')) + if action=='file_history': + values=projection.files.history(a['file_id']) + return dict(id=a['file_id'],**_page([{k:v[k] for k in ('revision','sha256','bytes','adopted_at','actor_id',*(['deleted'] if v.get('deleted') is True else []))} for v in values],a)) + if action in ('material_intents','material_intent'): + intents=projection.materials.intents() + if action=='material_intent': + result=next((item for item in intents if item['family']==a['family'] and item['current']['id']==a['definition_id']),None) + if result is None:raise WorkbenchError('Unresolved material intent is unavailable.','unavailable',404) + return result + return _page([dict(family=item['family'],id=item['id'],catalog_id=item['current']['catalog_id'], + definition_id=item['current']['id'],name=item['current']['name'],revision=item['current']['revision'], + required_reselection=item['current']['required_reselection'],fork_origin=item['fork_origin']) for item in intents],a) + if action=='material_intent_capabilities': + value=projection.materials.intent_capabilities() + items=[dict(item,requires_reselection=[]) for item in value['editable']] + items.extend(dict(family=item['family'],id=item['id'],requires_reselection=item['fields']) for item in value['requires_reselection']) + return _page(sorted(items,key=lambda item:(item['family'],item['id'])),a) + if action=='fork_info': + from project_fork import decode_fork + overlay=decode_fork(snapshot['records'],project_id=snapshot['project_id']) + return dict(source=overlay['source'] if overlay else None,project_id=snapshot['project_id'], + mapping=_page(overlay['mapping'] if overlay else [],a),archived_count=len(overlay['archived']) if overlay else 0, + copied_count=len(overlay['artifacts']['copied']) if overlay else 0, + missing_regenerable_count=len(overlay['artifacts']['missing_regenerable']) if overlay else 0) + raise AssertionError('Closed M4 dispatch is incomplete.') + + +def dispatch(projection, name, arguments): + if name not in SUPPORTED_OPERATIONS: + raise WorkbenchError('Unsupported external-project operation.', 'unsupported', 409) + action = name.split('.')[1] + projection.session.access.require('inspect', name) + if action in _RUNTIME_ACTIONS:return _runtime(projection,action,arguments) + if action in _M4_ACTIONS:return _m4(projection,action,arguments) + if action in _EXECUTION_ACTIONS: return _execution(projection, action, arguments) + if action in _DATA_ACTIONS: return _data(projection, action, arguments) + if action in _MATERIAL_ACTIONS: return _material(projection, action, arguments) + if action in _HISTORY_ACTIONS: return _history(projection, action, arguments) + if action == 'artifacts': + items = [dict(copy.deepcopy(declaration), availability=projection.artifacts.inspect(declaration)) + for _, declaration in sorted(_declarations(projection).items())] + return _page(items, arguments) + if action == 'artifact_status': return projection.artifacts.status(_artifact(projection, arguments['sha256'])) + if action == 'artifact_resolve': + projection.session.access.require('edit', 'project_files.artifact_resolve', arguments['sha256'], owner_only=True) + return projection.resolve_artifact(_body(arguments), arguments['expected_checkpoint'], arguments['request_key']) + if action == 'producers': return projection.producers.list(arguments['family'], + origin_catalog_id=arguments.get('origin_catalog_id'), offset=arguments.get('offset', 0), limit=arguments.get('limit', 40)) + if action == 'producer': return projection.producers.get(arguments['family'], arguments['producer_id'], + origin_catalog_id=arguments['origin_catalog_id'], entries_offset=arguments.get('entries_offset', 0), + entries_limit=arguments.get('entries_limit', 40)) + if action == 'source_references': + return projection.source_adapter.references( + {key: arguments[key] for key in ('origin_catalog_id', 'project_id', 'manifest_sha256')}, + offset=arguments.get('offset', 0), limit=arguments.get('limit', 40)) + if action == 'source_register': + projection.session.access.require('edit', 'project_files.source_register', owner_only=True) + return projection.source_adapter.register(_body(arguments), arguments['expected_checkpoint'], arguments['request_key']) + raise AssertionError('Closed project dispatch is incomplete.') diff --git a/project_execution.py b/project_execution.py new file mode 100644 index 00000000..ca5c36d0 --- /dev/null +++ b/project_execution.py @@ -0,0 +1,1041 @@ +"""Current-host project execution, separate from inert imported Run history. + +Construction installs transaction capture hooks but never constructs Runs, +claims runtime ownership, recovers a process or wakes a queue. Only an explicit +currently authorized action may create the local execution owner. +""" +import copy +from contextlib import contextmanager +import hashlib +import json +import os +import re +import threading +import time +import uuid + +import agent_api +from access import Access, AccessError +from model_definitions import natural +from pipeline import closed, digest, identifier +from project_history import capture_run, declarations +from project_session import operation_records +from workbench import WorkbenchError + + +OPERATIONS = frozenset(('runs.submit_external', 'runs.submit', 'runs.retry', 'runs.cancel', 'runs.approve_external')) + + +class _FileEffect: + """One of the two named nonexecuting filesystem publication guards.""" + def __init__(self, execution, kind, key, input_sha256, content_sha256): + self.execution,self.kind,self.key=execution,kind,key + self.input_sha256,self.content_sha256=input_sha256,content_sha256 + self.started=False;self.done=False + + def publication_started(self): + execution=self.execution + pending=execution._runtime_pending if self.kind=='runtime' else execution._package_pending + identity_field='manifest_sha256' if self.kind=='runtime' else 'material_sha256' + entry=dict(request_key=self.key,input_sha256=self.input_sha256,**{identity_field:self.content_sha256}) + previous=pending.get(self.key) + if previous is not None and previous!=entry: + raise WorkbenchError('File publication key has different current input.','conflict',409) + # Retain a conservative local fence even if journal fsync acknowledgement + # fails. No owner filesystem write follows an unacknowledged start. + pending[self.key]=entry;self.started=True + if execution.custody is not None: + with execution._journal_guard(): + if not execution.custody.has_pending:execution.custody.acknowledge(execution.session.snapshot) + if self.kind=='runtime':execution.custody.runtime_started(self.key,self.input_sha256,self.content_sha256) + else:execution.custody.package_started(self.key,self.input_sha256,self.content_sha256) + + def confirmed(self): + if not self.started:raise RuntimeError('File publication must have a durable start before confirmation.') + execution=self.execution;execution.session.check() + if execution.db.in_transaction: + raise RuntimeError('File publication confirms only after its canonical and outer SQL acknowledgement.') + if execution.custody is not None: + with execution._journal_guard(): + if self.kind=='runtime':execution.custody.runtime_confirmed(self.key) + else:execution.custody.package_confirmed(self.key) + pending=execution._runtime_pending if self.kind=='runtime' else execution._package_pending + pending.pop(self.key,None);self.done=True + + +def _admission_result(run): + return dict(format='project_run_admission_v1', origin_catalog_id=run['dataset_id'], + run_id=run['id'], request_sha256=run['request_sha256']) + + +def validate_operation_result(operation, result, records=None): + """Validate inert operation facts, never a live grant or resume instruction.""" + if operation not in OPERATIONS: + return False + if operation == 'runs.approve_external': + closed(result, ('format','origin_catalog_id','approval_id','material_sha256', + 'consumer_actor_id','approver_actor_id','created_at','expires_at')) + if result['format'] != 'project_execution_approval_v1': + raise WorkbenchError('Invalid project approval observation.') + for key in ('origin_catalog_id','approval_id','consumer_actor_id','approver_actor_id'): + identifier(result[key], key) + if type(result['material_sha256']) is not str or not re.fullmatch('[a-f0-9]{64}', result['material_sha256']): + raise WorkbenchError('Invalid approved material identity.') + natural(result['created_at'], 'Approval time');natural(result['expires_at'], 'Approval expiry') + if not result['created_at'] < result['expires_at'] <= result['created_at'] + 3600: + raise WorkbenchError('Invalid approval observation lifetime.') + return True + closed(result, ('format','origin_catalog_id','run_id','request_sha256')) + if result['format'] != 'project_run_admission_v1': + raise WorkbenchError('Invalid project Run observation.') + identifier(result['origin_catalog_id'], 'Run origin');identifier(result['run_id'], 'Run ID') + if type(result['request_sha256']) is not str or not re.fullmatch('[a-f0-9]{64}', result['request_sha256']): + raise WorkbenchError('Invalid Run request digest.') + if records is not None: + matches = [item['document']['run'] for item in records if item['family'] == 'run_history' + and item['id'] == result['origin_catalog_id']+'.'+result['run_id']] + if len(matches) != 1 or _admission_result(matches[0]) != result: + raise WorkbenchError('Run operation observation differs from its retained Run.') + return True + + +class ProjectExecution: + def __init__(self, projection): + self.projection = projection + self.session, self.db = projection.session, projection.db + self.access = projection.access + self.custody = getattr(projection, 'custody', None) + local=self.custody.snapshot() if self.custody is not None else dict(runtime_effects=[],package_effects=[]) + self._runtime_pending={item['request_key']:item for item in local['runtime_effects']} + self._package_pending={item['request_key']:item for item in local['package_effects']} + self._runtime_active=self._package_active=None + self._journal_publication_pending=False + self.runtime_access = Access(self.db, self.db.path, authority=self.access) + self.session.runtime_access = self.runtime_access + self._runs = None + self._constructing = False + self._reconstructing = False + self._dirty_runs = set() + self._pending_studies = set() + self._pending_producers = {kind:set() for kind in ('generation','grounded','caption','text_proposal')} + self.study_execution = None + self.producer_execution = None + self._custody_lock = threading.RLock() + self._handles = {} + self._active_attempts = set() + from project_native_execution import ProjectNativeExecution + self.native=ProjectNativeExecution(self) + self.session.extra_capture = self.capture_dirty + if self.custody is not None:self.session.publication_intent=self._publication_intent + self.db.set_project_hooks(self.before_commit, self.outcome) + + def attach_runs(self, owner): + if not self._constructing or owner.project_execution is not self or self._runs is not None: + raise RuntimeError('Only explicit local construction can attach a Run owner.') + self._runs = owner + + @contextmanager + def _journal_guard(self): + """A failed private custody acknowledgement fences every SQL outcome.""" + try: + yield + except BaseException: + self.session.available = False + if self._runs is not None:self._runs._fence_coordinator() + raise + + def attempt_started(self, identity): + with self._custody_lock:self._active_attempts.add(identity) + + def attempt_finished(self, identity): + with self._custody_lock:self._active_attempts.discard(identity) + + def register_custody(self, identity, handle): + if handle.spec['attempt_id'] != identity: + raise RuntimeError('Current custodian differs from its Run.') + with self._custody_lock: + if identity in self._handles: + raise RuntimeError('A current Run cannot replace its custodian.') + self._handles[identity] = handle + if self.custody is not None: + from recipe_process import identity as process_identity + with self._journal_guard(): + self.custody.launched(identity,process_identity(handle.process.pid)) + + def before_launch(self, identity): + with self.db.lock: + self.session.check();run=self.runs._load(identity);self._proof(run) + self.runtime_access.require_job('runs',identity,run['admission_id'],run['request_sha256']) + if self.custody is not None: + with self._journal_guard():self.custody.launch_intent(identity) + + def _publication_intent(self, before, records, key, sha): + if self.custody is not None and self.custody.has_pending: + with self._journal_guard():self.custody.expect_publication(before,records,key,sha) + self._journal_publication_pending=True + + def _local_owner(self, *, recover=True): + """Construct execution only after the caller checked exact authority.""" + self.session.check() + if self._runs is None: + from runs import Runs + from pipeline import Pipelines + context = copy.copy(self.projection.context) + context.access, context.lock = self.runtime_access, self.db.lock + context.project_execution = self + pipelines = Pipelines(context) + context.pipelines = pipelines + self._constructing = True + try: + with self.runtime_access._reconcile(): + owner = Runs(context, pipelines, recover=recover) + finally: + self._constructing = False + self._runs = owner + context.runs = owner + return self._runs + + @property + def runs(self): + """An already activated owner, never an implicit constructor.""" + if self._runs is None: + raise WorkbenchError('No current local execution owner is active.', 'unavailable', 409) + return self._runs + + @property + def has_local_custody(self): + """Observe only current physical owners and their unresolved local index.""" + with self._custody_lock: + return (self._runs is not None or bool(self._handles) + or self.native.has_local_custody + or self.producer_execution is not None and self.producer_execution.has_local_custody + or bool(self._runtime_pending or self._package_pending or self._runtime_active or self._package_active) + or self.custody is not None and self.custody.has_pending) + + @contextmanager + def runtime_inspection(self): + with self.access.lock,self.session.lock,self.db.lock: + self.assert_quiescent() + self._runtime_active='inspection' + try:yield + finally:self._runtime_active=None + + def runtime_effect(self, request_key, input_sha256=None, manifest_sha256=None): + return self._file_effect('runtime',request_key,input_sha256,manifest_sha256) + + def package_effect(self, request_key, input_sha256=None, material_sha256=None): + return self._file_effect('package',request_key,input_sha256,material_sha256) + + @contextmanager + def package_reconciliation(self, request_key, input_sha256, material_sha256, expected_checkpoint): + """Rebind one stopped original physical package writer; never rerun it. + + The caller publishes its fixed final/abandonment fact before confirming + this guard. Imported intent alone cannot select or clear local custody. + """ + with self._file_reconciliation('package',request_key,input_sha256,material_sha256,expected_checkpoint) as effect: + yield effect + + @contextmanager + def runtime_reconciliation(self, request_key, input_sha256, manifest_sha256, expected_checkpoint): + """Hold one original stopped runtime capture for a canonical owner decision.""" + with self._file_reconciliation('runtime',request_key,input_sha256,manifest_sha256,expected_checkpoint) as effect: + yield effect + + @contextmanager + def _file_reconciliation(self, kind, request_key, input_sha256, content_sha256, expected_checkpoint): + with self.access.lock,self.session.lock,self.db.lock: + self._restore_file_writer(kind,request_key,input_sha256,content_sha256,expected_checkpoint) + if kind=='runtime':self._runtime_active=request_key + else:self._package_active=request_key + effect=_FileEffect(self,kind,request_key,input_sha256,content_sha256) + effect.started=True + try: + yield effect + finally: + if kind=='runtime':self._runtime_active=None + else:self._package_active=None + + def runtime_pending(self, request_key): + """Observe one current-host private runtime marker, never an imported intent.""" + self.access.require('admin','project_execution.runtime_pending',owner_only=True) + if type(request_key) is not str or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}',request_key): + raise WorkbenchError('Invalid current runtime publication key.') + with self.access.lock,self.session.lock,self.db.lock: + self.access.require('admin','project_execution.runtime_pending',owner_only=True) + if self.custody is None:return None + with self._journal_guard():journal=self.custody.refresh() + return next((copy.deepcopy(entry) for entry in journal['runtime_effects'] + if entry['request_key']==request_key),None) + + def prepare_runtime_reconciliation(self, request_key, input_sha256, manifest_sha256, expected_checkpoint): + """Restore one stopped capture before an explicit normal owner retry. + + No active file guard or marker is changed. The caller must still verify + its actual target and acknowledge normal canonical completion. + """ + return self._restore_file_writer('runtime',request_key,input_sha256,manifest_sha256,expected_checkpoint) + + def _restore_file_writer(self, kind, request_key, input_sha256, content_sha256, expected_checkpoint): + # Exactly the two admitted synchronous filesystem owners share this + # physical-custody proof. There is no extensible effect registry. + if kind=='runtime': + authority,scope,action=self.access,'admin','project_execution.runtime_reconciliation' + field='manifest_sha256' + elif kind=='package': + authority,scope,action=self.runtime_access,'cancel','project_execution.package_reconciliation' + field='material_sha256' + else:raise RuntimeError('Unsupported file reconciliation owner.') + authority.require(scope,action,owner_only=True) + if type(request_key) is not str or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}',request_key): + raise WorkbenchError('Invalid current file publication key.') + for value in (input_sha256,content_sha256,expected_checkpoint): + if type(value) is not str or not re.fullmatch('[a-f0-9]{64}',value): + raise WorkbenchError('File reconciliation requires exact input and checkpoint digests.') + with self.access.lock,self.session.lock,self.db.lock: + authority.require(scope,action,owner_only=True) + if self.db.in_transaction: + raise RuntimeError('File reconciliation requires no outer SQL transaction.') + if self.session.snapshot['checkpoint']!=expected_checkpoint: + raise WorkbenchError('Expected current file checkpoint changed.','stale_project',409) + if self.custody is None: + raise WorkbenchError('No original physical file custody exists.','unavailable',409) + if self._runtime_active or self._package_active: + raise WorkbenchError('A current runtime or package writer is active.','busy',409) + expected=dict(request_key=request_key,input_sha256=input_sha256,**{field:content_sha256}) + with self._journal_guard():journal=self.custody.refresh() + own,other=(('runtime_effects','package_effects') if kind=='runtime' else ('package_effects','runtime_effects')) + pending=self._runtime_pending if kind=='runtime' else self._package_pending + other_pending=self._package_pending if kind=='runtime' else self._runtime_pending + if expected not in journal[own]: + raise WorkbenchError('No exact original file writer matches this request.','unavailable',409) + if (journal['runs'] or journal['producers'] or journal.get('native_phases') or journal.get('native_admissions') or journal[other] + or journal[own]!=[expected] or other_pending + or any(key!=request_key or value!=expected for key,value in pending.items())): + raise WorkbenchError('Other original local custody remains unresolved.','outcome_unknown',409) + self._assert_run_drained() + self.native.assert_quiescent() + if self.producer_execution is not None:self.producer_execution.assert_quiescent() + binding=self.custody.binding + if (binding['projection_id']!=self.db.projection_id or binding['project_id']!=self.db.dataset_id + or binding['host_catalog_id']!=self.access.catalog_id): + raise WorkbenchError('File custody belongs to another physical owner.','local_custody_unavailable',409) + # Recovery remains fenced until every fixed inert owner has been + # reconstructed from this exact acknowledged/attempted selection. + self.session.available=False + snapshot=self.session.store.recover_checkpoint() + with self.session.store.root_directory() as descriptor: + info=os.fstat(descriptor) + if [info.st_dev,info.st_ino]!=binding['root_identity']: + raise WorkbenchError('File custody belongs to another physical root.','local_custody_unavailable',409) + self.custody.validate_selection(snapshot) + self.projection.reconcile_inert(snapshot) + pending[request_key]=expected + self.assert_quiescent(_allow_file=(kind,request_key)) + return self.session.snapshot + + @contextmanager + def _file_effect(self, kind, key, input_sha256, content_sha256): + if type(key) is not str or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}',key): + raise WorkbenchError('Invalid current file publication key.') + for value in (input_sha256,content_sha256): + if value is not None and (type(value) is not str or not re.fullmatch('[a-f0-9]{64}',value)): + raise WorkbenchError('Invalid current file publication digest.') + with self.access.lock,self.session.lock,self.db.lock: + self.assert_quiescent(_allow_file=(kind,key)) + if kind=='runtime':self._runtime_active=key + else:self._package_active=key + effect=_FileEffect(self,kind,key,input_sha256,content_sha256) + try:yield effect + finally: + if kind=='runtime':self._runtime_active=None + else:self._package_active=None + + def mark_run(self, run_id): + identifier(run_id, 'Dirty Run ID') + if not self.db.in_transaction: + raise RuntimeError('Run changes require their owned SQL transaction.') + self._dirty_runs.add(run_id) + + def mark_study(self, study_id): + identifier(study_id, 'Dirty Study ID') + if not self.db.in_transaction: + raise RuntimeError('Study changes require their owned SQL transaction.') + if self.study_execution is None: + self._pending_studies.add(study_id) + else: + self.study_execution.mark_study(study_id) + + def _mark_producer(self, kind, identity): + identifier(identity, 'Dirty producer ID') + if not self.db.in_transaction: + raise RuntimeError('Producer changes require their owned SQL transaction.') + if self.producer_execution is None: + self._pending_producers[kind].add(identity) + else: + getattr(self.producer_execution, 'mark_'+kind)(identity) + + def mark_generation(self, identity):self._mark_producer('generation', identity) + def mark_grounded(self, identity):self._mark_producer('grounded', identity) + def mark_caption(self, identity):self._mark_producer('caption', identity) + def mark_text_proposal(self, identity):self._mark_producer('text_proposal', identity) + + def _producer_authority(self, family, identity, admission_id, request_digest): + from project_producer_execution import JOB_KINDS, FINISH + from access import current_context + if family not in JOB_KINDS:raise WorkbenchError('Unsupported producer custody family.') + row=self.db.execute('SELECT * FROM access_admissions WHERE id=%s',(admission_id,)).fetchone() + if (row is None or row['catalog_id']!=self.db.dataset_id or row['kind']!=JOB_KINDS[family] + or row['job_id']!=identity or row['request_digest']!=request_digest): + raise AccessError('Producer custody requires its exact current local admission.') + context=current_context() + if context is not None and context.mode=='job': + self.runtime_access.require_job(JOB_KINDS[family],identity,admission_id,request_digest) + self.runtime_access.require('edit',FINISH[family],identity) + elif context is not None and context.mode=='system': + self.runtime_access.require('cancel',JOB_KINDS[family]+'.reconcile',identity) + else:self.runtime_access.require('inspect','producers.custody',identity) + + def register_producer(self, family, identity, admission_id, request_digest): + with self.db.lock: + self.session.check();self._producer_authority(family,identity,admission_id,request_digest) + if self.custody is not None: + with self._journal_guard(): + if not self.custody.has_pending:self.custody.acknowledge(self.session.snapshot) + self.custody.register_producer(family,identity,admission_id,request_digest) + + def producer_effect_intent(self, family, identity): + with self.db.lock: + self.session.check() + if self.custody is not None: + value=next((item for item in self.custody.snapshot()['producers'] + if (item['family'],item['producer_id'])==(family,identity)),None) + if value is None:raise AccessError('Provider dispatch has no original-host custody admission.') + self._producer_authority(family,identity,value['admission_id'],value['request_sha256']) + with self._journal_guard():self.custody.producer_effect_intent(family,identity) + + def update_producer_outcome(self, family, identity, provider_outcome, worker_drained): + with self.db.lock: + self.session.check() + if self.custody is not None: + value=next((item for item in self.custody.snapshot()['producers'] + if (item['family'],item['producer_id'])==(family,identity)),None) + if value is None:return + self._producer_authority(family,identity,value['admission_id'],value['request_sha256']) + with self._journal_guard():self.custody.producer_outcome(family,identity,provider_outcome,worker_drained) + + def _proof(self, run): + admission = self.db.execute('SELECT * FROM access_admissions WHERE id=%s', (run['admission_id'],)).fetchone() + if admission is None: + raise AccessError('Current local Run admission is unavailable.') + admission = dict(admission) + admission['effects'] = json.loads(admission.pop('effects_json')) + if (admission['catalog_id'] != self.db.dataset_id or admission['kind'] != 'runs' + or admission['job_id'] != run['id'] or admission['request_digest'] != run['request_sha256']): + raise AccessError('Current local admission differs from its Run.') + grant = consumption = None + if run.get('execution_kind') == 'external_recipe': + grant = self.db.execute('SELECT * FROM access_execution_grants WHERE id=%s', (run['approval_id'],)).fetchone() + consumption = self.db.execute('SELECT * FROM access_execution_consumptions WHERE grant_id=%s', (run['approval_id'],)).fetchone() + if grant is None or consumption is None: + raise AccessError('Current local execution consumption is unavailable.') + return dict(grant=dict(grant) if grant is not None else None, + consumption=dict(consumption) if consumption is not None else None, admission=admission) + + def _authorize_observation(self): + self.native.authorize_dirty() + context = self.runtime_access._context() if self._dirty_runs else None + for identity in self._dirty_runs: + row = self.db.execute('SELECT document FROM workspace_runs WHERE id=%s', (identity,)).fetchone() + if row is None: + raise AccessError('Current local Run is unavailable.') + run = json.loads(row['document']) + proof = self._proof(run) + if context.mode == 'job': + self.runtime_access.require_job('runs', identity, run['admission_id'], run['request_sha256']) + self.runtime_access.require('edit', 'runs.update', identity) + elif context.mode == 'system': + if self._runs is None and not self._constructing: + raise AccessError('Reconciliation requires the current local execution owner.') + if not self._runs.runtime_owner or not self.db.runtime_owned('workspace_runs'): + raise AccessError('Current local runtime custody ownership is unavailable.') + self.runtime_access.require('cancel', 'runs.reconcile', identity) + else: + # The domain owner has already checked submit/cancel authority; + # recheck live host credentials without inventing a worker owner. + self.runtime_access.require('inspect', 'runs.observe', identity) + if proof['admission']['catalog_id'] != self.session.snapshot['project_id']: + raise AccessError('Local custody belongs to another project.') + + def _capture_run(self, identity): + owner = self.runs + run = owner._load(identity) + proof = self._proof(run) + payloads = owner._capture_project_payloads(run, self.session.artifacts) + record = capture_run(run, admission_proof=proof, payloads=payloads) + records = [record, *declarations(record)] + if run.get('execution_kind') == 'external_recipe': + environment = run['material'].get('torch_runtime', run['material'].get('cpu_snapshot', run['material']['runtime'])) + descriptor = environment if environment.get('profile') in ('captured_cpu_python_v1','managed_torch_python_v1') else None + sha = digest(environment) + ref = dict(kind='runtime', catalog_id=run['dataset_id'], id=sha, sha256=sha) + from project_material import decode_records + if ('runtime_material', ref['catalog_id']+'.'+sha) not in decode_records(self.session.snapshot['records']): + raw = self.projection.runtime_manifest(descriptor) if descriptor is not None else None + manifest = None + if raw is not None: + from recipe_process import decode + manifest = decode(raw) + runtime = self.projection.materials.capture_runtime(dict(ref=ref, descriptor=descriptor, + environment=None if descriptor is not None else environment, manifest=manifest), raw) + records.extend([runtime, *self.projection.materials.declarations(runtime)]) + if self.custody is not None and run.get('execution_kind')=='external_recipe': + with self._journal_guard(): + if not self.custody.has_pending:self.custody.acknowledge(self.session.snapshot) + receipt=next((edge['blob']['sha256'] for edge in payloads if edge['name']=='custody-receipt.json'),None) + self.custody.observe_run(run,receipt_sha256=receipt) + elif self.custody is not None: + with self._journal_guard(): + if not self.custody.has_pending:self.custody.acknowledge(self.session.snapshot) + if run['state'] in ('queued','running','cancelling','unknown'): + self.custody.observe_native_admission(run) + return records + + def capture_dirty(self, before_records): + """The finite owner capture seam used by foreground and background TXs.""" + if self._reconstructing: + return [] + if self._pending_studies or any(self._pending_producers.values()): + raise WorkbenchError('The changed execution family has no installed capture owner.', 'unavailable', 409) + if self.study_execution is not None: + self.study_execution.settle_runs(self._dirty_runs) + self._authorize_observation() + records = [] + for identity in sorted(self._dirty_runs): + records.extend(self._capture_run(identity)) + if self.study_execution is not None: + records.extend(self.study_execution.capture_dirty(before_records)) + if self.producer_execution is not None: + records.extend(self.producer_execution.capture_dirty(before_records)) + records.extend(self.native.capture_dirty(before_records)) + # Shared immutable blobs can appear on several domain edges. + merged = {} + for record in records: + identity = record['family'], record['id'] + if identity in merged and merged[identity] != record: + raise WorkbenchError('Execution captures disagree on shared project facts.') + merged[identity] = record + return list(merged.values()) + + def before_commit(self): + if self._reconstructing or self.session.foreground_publication: + return + if not self._dirty_runs and not self._pending_studies and not any(self._pending_producers.values()) and not self.native.has_dirty and self.study_execution is None and self.producer_execution is None: + return + snapshot = self.session.snapshot + records = self.capture_dirty(snapshot['records']) + if records: + key = 'execution-observation-' + uuid.uuid4().hex + sha = hashlib.sha256(agent_api.encoded(records)).hexdigest() + self.session._publish_observation(records, key, sha, authorize=self._authorize_observation) + + def outcome(self, outcome): + if outcome not in ('committed','rolled_back','unknown'): + raise ValueError('Unsupported project SQL outcome.') + if self._reconstructing: + return + self.session._observation_outcome(outcome) + self.native.outcome(outcome) + if self.custody is not None and outcome=='committed': + try: + if self._journal_publication_pending:self.custody.acknowledge(self.session.snapshot) + for identity in sorted(self._dirty_runs): + run=self.runs._load(identity) + self.native.settle_run(run) + if run.get('execution_kind')!='external_recipe' and run['state'] in ('completed','failed','cancelled','interrupted'): + if any(row['run_id']==identity for row in self.custody.snapshot().get('native_admissions',[])): + self.custody.settle_native_admission(identity) + entry=next((item for item in self.custody.snapshot()['runs'] if item['run_id']==identity),None) + if entry is not None and run['state'] in ('completed','failed','cancelled','interrupted') and ( + run['custody_phase']=='drained' and entry['receipt_sha256'] is not None + or entry['phase']=='admitted' and run.get('custody_identity') is None): + self.custody.settle_run(identity) + except BaseException: + self.session.available=False + if self._runs is not None:self._runs._fence_coordinator() + raise + self._journal_publication_pending=False + self._dirty_runs.clear();self._pending_studies.clear() + for values in self._pending_producers.values():values.clear() + for adapter in (self.study_execution, self.producer_execution): + if adapter is not None:adapter.outcome(outcome) + if outcome == 'unknown':self.session.available = False + if not self.session.available: + if self._runs is not None:self._runs._fence_coordinator() + + def abandon_pending(self): + """Discard failed capture bookkeeping, never an admission or grant.""" + if not self._reconstructing: + raise RuntimeError('Pending execution capture may clear only during guarded inert reconstruction.') + self._dirty_runs.clear();self._pending_studies.clear() + self.native.abandon_pending() + for values in self._pending_producers.values():values.clear() + + def mutate(self, operation, target, body, expected_checkpoint, key, invoke, capture, *, scope='edit'): + try: + return self.session.mutate(operation, target, body, expected_checkpoint, key, invoke, capture, scope=scope) + except BaseException: + if not self.session.available and self._runs is not None:self._runs._fence_coordinator() + raise + + def preflight_external(self, graph_id, revision): + self.runtime_access.require('inspect', 'pipeline.preflight_external', graph_id) + with self.access.lock, self.session.lock, self.db.lock: + self.session.check() + return self.projection.pipelines.preflight_external(graph_id, revision) + + def native_preflight(self,graph_id,revision,expected_checkpoint,key): + self.runtime_access.require('execute','runs.native_preflight',graph_id,owner_only=True) + return self.projection.native_preparation.validate_graph(self.projection.pipelines, + graph_id,revision,expected_checkpoint,key) + + def _native_replay(self,operation,target,body,expected_checkpoint,key): + prior=operation_records(self.session.snapshot['records']).get(key) + if prior is None:return None + if (prior['operation']!=operation or self.db.execute('SELECT id FROM workspace_runs WHERE id=%s', + (prior['result'].get('run_id'),)).fetchone() is None): + raise WorkbenchError('A retained native receipt cannot create current local execution.','unavailable',409) + def forbidden():raise RuntimeError('Native admission replay attempted another qualification.') + return self.mutate(operation,target,body,expected_checkpoint,key,forbidden, + lambda value,before:dict(records=[],result=value),scope='execute') + + def native_submit(self,body,expected_checkpoint,key): + from pipeline import closed + closed(body,('graph_id','graph_revision','validation_token')) + actor=self.runtime_access.require('execute','runs.submit',body['graph_id'],owner_only=True) + effective=dict(body,actor_id=actor.actor_id) + with self.db.lock: + self.session.check() + replay=self._native_replay('runs.submit',body['graph_id'],effective,expected_checkpoint,key) + if replay is not None:return replay + with self.projection.native_preparation.current_graph(self.projection.pipelines,body['graph_id'], + body['graph_revision'],expected_checkpoint,key) as context: + if context.validation['validation_token']!=body['validation_token']: + raise WorkbenchError('Native validation is stale. Explicitly validate this saved graph.','stale',409) + def invoke(): + context.consume_admission() + owner=self._local_owner();public=owner.submit(dict(body,request_key=key)) + return _admission_result(owner._load(public['id'])) + return self.mutate('runs.submit',body['graph_id'],effective,self.session.snapshot['checkpoint'],key, + invoke,lambda value,before:dict(records=[],result=value),scope='execute') + + def native_retry(self,run_id,expected_checkpoint,key): + actor=self.runtime_access.require('execute','runs.retry',run_id,owner_only=True) + body=dict(actor_id=actor.actor_id) + with self.db.lock: + self.session.check() + replay=self._native_replay('runs.retry',run_id,body,expected_checkpoint,key) + if replay is not None:return replay + if self._runs is None:raise WorkbenchError('Retry requires the actual current local Run owner.','unavailable',409) + parent=self._runs._load(run_id) + if self._runs._external(parent) or parent['state'] not in ('failed','cancelled','interrupted'): + raise WorkbenchError('Choose a drained failed/cancelled/interrupted current native Run.','conflict',409) + with self.projection.native_preparation.current_graph(self.projection.pipelines,parent['graph']['id'], + parent['graph_revision'],expected_checkpoint,key) as context: + def invoke(): + context.consume_admission() + owner=self.runs;public=owner.retry(run_id,dict(request_key=key)) + return _admission_result(owner._load(public['id'])) + return self.mutate('runs.retry',run_id,body,self.session.snapshot['checkpoint'],key, + invoke,lambda value,before:dict(records=[],result=value),scope='execute') + + def native_reconcile(self,phase_id,expected_checkpoint): + return self.native.reconcile(phase_id,expected_checkpoint) + + def submit_external(self, body, expected_checkpoint, key): + authority = self.runtime_access.require_external_submission() + with self.access.lock, self.session.lock, self.db.lock: + self.session.check() + prior = operation_records(self.session.snapshot['records']).get(key) + if prior is None and expected_checkpoint != self.session.snapshot['checkpoint']: + raise WorkbenchError('Expected project checkpoint changed.', 'stale_project', 409) + if prior is not None: + # A retained request never recreates a local Run/admission or wakes + # imported queue state. A same-host local receipt can reconcile. + result = prior['result'] + if (prior['operation'] != 'runs.submit_external' or + self.db.execute('SELECT id FROM workspace_runs WHERE id=%s', (result.get('run_id'),)).fetchone() is None): + raise WorkbenchError('This saved request has no current local Run admission.', 'unavailable', 409) + request = dict(body, request_key=key) + def invoke(): + owner = self._local_owner() + public = owner.submit_external(request) + return _admission_result(owner._load(public['id'])) + effective = dict(body, actor_id=authority.actor_id) + return self.mutate('runs.submit_external', body.get('graph_id'), effective, expected_checkpoint, key, + invoke, lambda value,before:dict(records=[], result=value), scope='inspect') + + def approve_external(self, body, expected_checkpoint, key): + authority = self.runtime_access.require('approve-execution', 'runs.approve_external', owner_only=True) + with self.access.lock, self.session.lock, self.db.lock: + self.session.check() + prior = operation_records(self.session.snapshot['records']).get(key) + if prior is None and expected_checkpoint != self.session.snapshot['checkpoint']: + raise WorkbenchError('Expected project checkpoint changed.', 'stale_project', 409) + if prior is not None: + value = prior['result'] + if prior['operation'] != 'runs.approve_external' or self.db.execute( + 'SELECT id FROM access_execution_grants WHERE id=%s', (value.get('approval_id'),)).fetchone() is None: + raise WorkbenchError('This approval is historical; approve explicitly under current authority.', 'unavailable', 409) + def invoke(): + owner = self._local_owner() + grant = owner.approve_external(body) + return dict(format='project_execution_approval_v1', origin_catalog_id=grant['catalog_id'], + approval_id=grant['id'], **{key:grant[key] for key in ('material_sha256','consumer_actor_id', + 'approver_actor_id','created_at','expires_at')}) + effective = dict(body, approver_actor_id=authority.actor_id) + value = self.mutate('runs.approve_external', body.get('graph_id'), effective, expected_checkpoint, key, + invoke, lambda result,before:dict(records=[], result=result), scope='approve-execution') + grant = self.db.execute('SELECT * FROM access_execution_grants WHERE id=%s', (value['approval_id'],)).fetchone() + if grant is None: + raise WorkbenchError('Current local execution approval is unavailable.', 'unavailable', 409) + fields = ('material_sha256','consumer_actor_id','approver_actor_id','created_at','expires_at') + if (grant['catalog_id'] != value['origin_catalog_id'] or grant['id'] != value['approval_id'] + or any(grant[field] != value[field] for field in fields) + or grant['approver_actor_id'] != authority.actor_id or grant['revoked_at'] is not None + or grant['expires_at'] <= int(time.time())): + raise WorkbenchError('This historical approval has no exact current live grant. Use a fresh key.', 'unavailable', 409) + return dict(format='project_current_execution_approval_v1', current_local=True, grant=dict(grant)) + + def cancel_run(self, run_id, expected_checkpoint, key): + self.runtime_access.require('cancel', 'runs.cancel', run_id) + with self.access.lock, self.session.lock, self.db.lock: + if self._runs is None or self.db.execute('SELECT id FROM workspace_runs WHERE id=%s', (run_id,)).fetchone() is None: + raise WorkbenchError('A historical Run has no current local process to cancel.', 'unavailable', 409) + def invoke(): + self.runs.cancel(run_id) + return _admission_result(self.runs._load(run_id)) + return self.mutate('runs.cancel', run_id, {}, expected_checkpoint, key, invoke, + lambda result,before:dict(records=[], result=result), scope='cancel') + + def health(self, offset=0, limit=40): + self.access.require('inspect', 'project_execution.health') + natural(offset,'Execution offset',0,100000);natural(limit,'Execution limit',1,40) + with self.db.lock: + rows = [json.loads(row['document']) for row in self.db.execute('SELECT document FROM workspace_runs')] + active = {row['id'] for row in rows if row['state'] in ('queued','running','cancelling','unknown')} + with self._custody_lock:active.update(self._active_attempts) + native=self.custody.snapshot().get('native_phases',[]) if self.custody is not None else [] + native=sorted(native,key=lambda item:item['phase_id']) + phases=[dict(format='project_native_phase_v1',origin_catalog_id=self.db.dataset_id, + phase_id=item['phase_id'],phase=item['phase'],run_id=item['run_id'], + state='prepared' if item['state'] in ('launch_intent','launched') else item['state'], + spec_sha256=item['spec_sha256'],receipt_sha256=item['receipt_sha256']) for item in native[offset:offset+limit]] + return dict(format='project_execution_health_v1', active_run_ids=sorted(active)[offset:offset+limit], + active_count=len(active), offset=offset, limit=limit, + native_phases=dict(items=phases,total=len(native),offset=offset,limit=limit), + local_owner_active=self._runs is not None, + available=self.session.available and self.db.dataset_id != self.access.catalog_id, + drain_required=bool(self.custody is not None and self.custody.has_pending + or self.native.has_local_custody + or self._runtime_active or self._package_active + or self._runs is not None and self._runs._thread is not None and self._runs._thread.is_alive())) + + def assert_quiescent(self, *, _allow_file=None): + with self.db.lock: + if not self.session.available: + raise WorkbenchError('Current execution outcome requires explicit reconciliation.', 'projection_unavailable', 503) + if self._runtime_active or self._package_active: + raise WorkbenchError('A current runtime or package file writer is active.','busy',409) + for kind,pending in (('runtime',self._runtime_pending),('package',self._package_pending)): + if any(_allow_file!=(kind,key) for key in pending): + raise WorkbenchError('A current runtime or package publication requires exact reconciliation.','outcome_unknown',409) + self._assert_run_drained() + self.native.assert_quiescent() + for adapter in (self.study_execution,self.producer_execution): + if adapter is not None:adapter.assert_quiescent() + if self.custody is not None: + journal=self.custody.snapshot() + if journal['runs'] or journal['producers'] or journal.get('native_phases') or journal.get('native_admissions'): + raise WorkbenchError('Original local custody remains unresolved.','outcome_unknown',409) + + def _assert_run_drained(self): + """Check only actual current Run effects, including an idle supervisor.""" + rows = self.db.execute("SELECT id FROM workspace_runs WHERE document::jsonb->>'state' IN ('queued','running','cancelling','unknown')").fetchall() + if rows: + raise WorkbenchError('Drain or cancel current local Runs before closing or copying this project.', 'busy', 409) + with self._custody_lock: + if self._active_attempts or any(handle.process.poll() is None for handle in self._handles.values()): + raise WorkbenchError('Current local custodians are still draining.', 'busy', 409) + if self._runs is not None and self._runs._thread is not None and self._runs._thread.is_alive(): + raise WorkbenchError('Reconcile current local custody to stop its idle supervisor before closing.', 'busy', 409) + if self._runs is not None and (self._runs._publication_active is not None or self._runs._publication_lock.locked()): + raise WorkbenchError('Current Run publication has not drained.', 'busy', 409) + + def close(self): + if not self.has_local_custody: + return + self.assert_quiescent() + if self._runs is not None: + with self.runtime_access._reconcile():self._runs.close() + if self.producer_execution is not None:self.producer_execution.close() + + def shutdown(self): + """Explicit application shutdown, outside project command locks. + + Existing local continuations own cancellation and final observations. + This neither constructs an owner nor creates human execution authority. + """ + if self.db.lock.owned_by_current_thread or self.db.in_transaction: + raise WorkbenchError('Shutdown must drain outside the project command lock.', 'busy', 409) + try: + if self._runs is not None: + with self.runtime_access._reconcile():self._runs.close() + finally: + if self.producer_execution is not None:self.producer_execution.shutdown() + + def reconcile_current(self): + """Reconcile only retained current-local custody, never imported handles.""" + self.runtime_access.require('cancel', 'project_execution.reconcile', owner_only=True) + self._require_no_native_phase_reconciliation() + if self._runs is None: + raise WorkbenchError('No current local custody owner exists to reconcile.', 'unavailable', 409) + if self.db.lock.owned_by_current_thread or self.db.in_transaction: + raise WorkbenchError('Drain current custody outside the project command lock.', 'busy', 409) + owner = self._runs + owner._fence_coordinator() + with self._custody_lock:handles = dict(self._handles) + receipts = {} + # Only actual Handle objects retained by this application's launch path + # close control channels. Saved PID/spec/receipt text never selects one. + for identity, handle in handles.items(): + receipt = handle.close_control() + if not receipt or not receipt.get('drained'): + raise WorkbenchError('Current custodian drainage is still unconfirmed.', 'unknown', 409) + receipts[identity] = receipt + deadline = time.monotonic() + 60 + thread = owner._thread + if thread is not None: + thread.join(max(0, deadline-time.monotonic())) + if thread.is_alive(): + raise WorkbenchError('Current execution supervisor has not drained.', 'busy', 409) + with self._custody_lock: + if self._active_attempts: + raise WorkbenchError('Current execution attempts have not drained.', 'busy', 409) + if self.producer_execution is not None:self.producer_execution.assert_quiescent() + with self.access.lock, self.session.lock, self.db.lock: + self.runtime_access.require('cancel','project_execution.reconcile',owner_only=True) + self._require_no_native_phase_reconciliation() + snapshot = self.session.store.recover_checkpoint() + self.projection.reconcile_inert(snapshot) + canonical = {item['document']['run']['id']:item['document']['run'] for item in snapshot['records'] + if item['family']=='run_history' and item['document']['origin_catalog_id']==self.db.dataset_id} + reconciled = [] + try: + with self.runtime_access._reconcile(), self.db.transaction(): + for row in self.db.execute('SELECT id FROM workspace_runs'): + local = owner._load(row['id']) + if local['state'] not in ('queued','running','cancelling','unknown'): + continue + self._proof(local) + retained = canonical.get(local['id']) + if retained is None or any(retained.get(key)!=local.get(key) for key in ( + 'dataset_id','id','request_sha256','admission_id','approval_id','material_sha256','spec')): + raise WorkbenchError('Current local admission differs from canonical custody.', 'unknown', 409) + run = copy.deepcopy(retained) + handle = handles.get(run['id']) + if run.get('execution_kind')!='external_recipe': + # No native phase can launch without first entering + # the private phase journal. A drained original + # supervisor plus a never-started native admission + # may therefore terminate without inventing receipt + # or interpreting an external custody specification. + self._native_never_started(local,retained) + owner._event(run,'interrupted','Original native admission ended before phase dispatch; execution was not replayed.') + from runs import LOCAL_RUN_FIELDS + run.update({key:copy.deepcopy(value) for key,value in local.items() if key in LOCAL_RUN_FIELDS}) + owner._save(run);reconciled.append(run['id']);continue + if handle is None: + if run['custody_phase']!='prepared' or run.get('custody_identity') is not None: + raise WorkbenchError('Canonical execution has no current local custody proof.', 'unknown', 409) + state = 'cancelled' + else: + receipt = receipts[run['id']] + if (handle.expected_identity['attempt_id']!=run['id'] or + handle.expected_identity['request_sha256']!=run['request_sha256'] or + run.get('custody_identity') not in (None,handle.expected_identity)): + raise WorkbenchError('Current custodian differs from its canonical admission.', 'unknown', 409) + run['custody_identity'] = copy.deepcopy(handle.expected_identity) + run['custody_phase'] = 'drained' + if run['state']=='completed': + owner._verify_external_artifacts(run) + state = 'completed' + else: + state = 'cancelled' if receipt['outcome']=='cancelled' else 'interrupted' + owner._event(run,state,'Explicit current-host reconciliation confirmed custody drainage; execution was not replayed.') + owner._save(run) + reconciled.append(run['id']) + # A foreground canonical admission may have survived a rolled + # back SQL admission. Clearing that original non-effect intent + # neither restores SQL authority nor rewrites historical state. + for entry in self.custody.snapshot().get('native_admissions',[]) if self.custody is not None else []: + if self.db.execute('SELECT id FROM workspace_runs WHERE id=%s',(entry['run_id'],)).fetchone() is None: + self._native_unacknowledged(entry,canonical.get(entry['run_id'])) + with self._journal_guard():self.custody.settle_native_admission(entry['run_id']) + self.assert_quiescent() + except BaseException: + self.session.available = False + owner._fence_coordinator() + raise + return dict(format='project_execution_reconciliation_v1', origin_catalog_id=self.db.dataset_id, + run_ids=sorted(reconciled), available=self.session.available) + + def _require_no_native_phase_reconciliation(self): + if self.native.has_local_custody or self.custody is not None and self.custody.snapshot().get('native_phases'): + raise WorkbenchError('Reconcile the exact current native phase with native_reconcile before generic Run reconciliation.','unavailable',409) + + def _native_never_started(self,local,retained): + """Prove absence of phase dispatch from the original private journal.""" + entry=next((row for row in self.custody.snapshot().get('native_admissions',[]) + if row['run_id']==local['id']),None) if self.custody is not None else None + if (entry is None or entry['phase_started'] or local.get('process_phase')!='not_started' + or local.get('worker') is not None or local.get('execution_kind')=='external_recipe' + or any(retained.get(key)!=local.get(key) for key in + ('id','dataset_id','admission_id','request_sha256','graph_sha256','release_sha256','runtime')) + or entry['admission_id']!=local['admission_id'] or entry['request_sha256']!=local['request_sha256'] + or entry['graph_sha256']!=local['graph_sha256'] or entry['release_sha256']!=local['release_sha256'] + or entry['runtime_sha256']!=digest(local['runtime'])): + raise WorkbenchError('Native admission lacks original never-dispatched phase proof.','unknown',409) + + def _native_unacknowledged(self,entry,retained): + if entry['phase_started']: + raise WorkbenchError('A native phase may have dispatched; its current receipt is required.','unknown',409) + if self.db.execute('SELECT id FROM access_admissions WHERE id=%s',(entry['admission_id'],)).fetchone() is not None: + raise WorkbenchError('An incomplete original native SQL admission needs investigation.','unknown',409) + if retained is not None and (retained.get('execution_kind')=='external_recipe' or retained['state']!='queued' + or any(entry[key]!=retained[key] for key in ('admission_id','request_sha256','graph_sha256','release_sha256')) + or entry['runtime_sha256']!=digest(retained['runtime'])): + raise WorkbenchError('Original unacknowledged native admission differs from canonical history.','unknown',409) + + def reconcile_original(self, *, expected_checkpoint, timeout=15): + """Explicit original-host receipt reconciliation, without PID control. + + The Workspace supplies only an indexed existing physical projection. + Imported history never selects that projection or creates admissions. + No Run owner exists until all journal/local-SQL/receipt proofs pass. + """ + self.runtime_access.require('cancel','project_execution.reconcile_original',owner_only=True) + self._require_no_native_phase_reconciliation() + if self.custody is None or self._runs is not None: + raise WorkbenchError('Original custody requires its indexed inactive physical projection.','local_custody_unavailable',409) + if self.db.lock.owned_by_current_thread or self.db.in_transaction: + raise WorkbenchError('Original custody must drain outside command locks.','busy',409) + if type(timeout) not in (int,float) or not 0<=timeout<=30: + raise WorkbenchError('Original receipt wait must be zero through thirty seconds.') + journal=self.custody.snapshot();snapshot=self.session.store.recover_checkpoint() + if snapshot['checkpoint']!=expected_checkpoint: + raise WorkbenchError('Expected original project checkpoint changed.','stale_project',409) + self.custody.validate_selection(snapshot) + canonical={item['document']['run']['id']:item['document']['run'] for item in snapshot['records'] + if item['family']=='run_history' and item['document']['origin_catalog_id']==self.db.dataset_id} + plans=[];unacknowledged=[];receipts={} + with self.db.lock: + if (journal['runs'] or journal.get('native_admissions')) and not self.db.claim_runtime('workspace_runs'): + raise WorkbenchError('The original Run application still owns this physical projection.','busy',409) + from project_producer_execution import TABLES as PRODUCER_TABLES + for producer in journal['producers']: + if not self.db.claim_runtime(PRODUCER_TABLES[producer['family']]): + raise WorkbenchError('An original producer application still owns this physical projection.','busy',409) + if producer['phase']!='admitted' or producer['provider_outcome']!='not_admitted': + raise WorkbenchError('Original provider outcome remains unknown; a closed local worker is not cessation proof.','outcome_unknown',409) + for entry in journal['runs']: + row=self.db.execute('SELECT document FROM workspace_runs WHERE id=%s',(entry['run_id'],)).fetchone() + if row is None: + if entry['phase']!='admitted': + raise WorkbenchError('Launched custody has no original local admission.','local_custody_unavailable',409) + unacknowledged.append(entry['run_id']);continue + local=json.loads(row['document']);retained=canonical.get(entry['run_id']) + self._proof(local) + from recipe_process import encoded + if (retained is None or local['id']!=entry['run_id'] or local['admission_id']!=entry['admission_id'] + or local['request_sha256']!=entry['request_sha256'] or local['dataset_id']!=self.db.dataset_id + or hashlib.sha256(encoded(local['spec'])).hexdigest()!=entry['spec_sha256'] + or any(retained.get(key)!=local.get(key) for key in ( + 'id','dataset_id','admission_id','request_sha256','approval_id','material_sha256','spec')) + or local['spec']['cwd']!=str(self.db.path/'workspace-runs'/('.external-'+local['id']))): + raise WorkbenchError('Original local admission/specification differs from its indexed canonical observation.','local_custody_unavailable',409) + if entry['phase']!='admitted' and entry['custody_identity'] is None: + raise WorkbenchError('Original spawn window lacks an observed helper identity; cessation remains unknown.','unknown',409) + plans.append((entry,copy.deepcopy(retained))) + native_plans=[] + for entry in journal.get('native_admissions',[]): + row=self.db.execute('SELECT document FROM workspace_runs WHERE id=%s',(entry['run_id'],)).fetchone() + if row is None: + self._native_unacknowledged(entry,canonical.get(entry['run_id'])) + unacknowledged.append(entry['run_id']);continue + local=json.loads(row['document']);retained=canonical.get(entry['run_id']) + self._proof(local) + if retained is None:raise WorkbenchError('Original native Run is missing its canonical admission.','unknown',409) + self._native_never_started(local,retained) + native_plans.append((local,copy.deepcopy(retained))) + # Poll only exact original local receipt files. Never signal a stored PID, + # import a controller handle, replay GO or execute a project script. + if any(entry['custody_identity'] is not None for entry,run in plans): + from projects import Projects + from recipe_process import validate_receipt + reader=Projects(self.projection.context,{'original_runs':self.db.path/'workspace-runs'}) + deadline=time.monotonic()+timeout + try: + for entry,run in plans: + if entry['custody_identity'] is None:continue + while True: + try: + raw=reader._read('original_runs','.external-'+run['id']+'/custody/receipt.json',16384) + receipt=validate_receipt(raw,entry['custody_identity']) + if not receipt['drained']: + raise WorkbenchError('Original custodian has not established drainage.','unknown',409) + receipts[run['id']]=receipt;break + except FileNotFoundError: + if time.monotonic()>=deadline: + raise WorkbenchError('Original exact drain receipt is not available yet.','unknown',409) from None + time.sleep(min(.1,max(0,deadline-time.monotonic()))) + finally:reader.close() + with self.access.lock,self.session.lock,self.db.lock: + self.runtime_access.require('cancel','project_execution.reconcile_original',owner_only=True) + self.custody.validate_selection(self.session.store.snapshot()) + self.projection.reconcile_inert(snapshot) + owner=self._local_owner(recover=False) if plans or native_plans else None + if owner is not None:owner._fence_coordinator() + try: + if owner is not None: + # Verification uses the current human's existing disclosure + # policy. The following system scope owns only terminal SQL. + for entry,run in plans: + if run['state']=='completed':owner._verify_external_artifacts(run) + with self.runtime_access._reconcile(),self.db.transaction(): + for entry,run in plans: + if entry['custody_identity'] is not None: + run['custody_identity']=copy.deepcopy(entry['custody_identity']);run['custody_phase']='drained' + state=run['state'] if run['state']=='completed' else ( + 'cancelled' if receipts[run['id']]['outcome']=='cancelled' else 'interrupted') + else:state='cancelled' + owner._event(run,state,'Original-host exact local custody reconciled; execution was not resumed.') + owner._save(run) + for local,run in native_plans: + from runs import LOCAL_RUN_FIELDS + run.update({key:copy.deepcopy(value) for key,value in local.items() if key in LOCAL_RUN_FIELDS}) + owner._event(run,'interrupted','Original native admission ended before phase dispatch; execution was not resumed.') + owner._save(run) + for identity in unacknowledged: + if any(row['run_id']==identity for row in journal.get('native_admissions',[])): + self.custody.settle_native_admission(identity) + else:self.custody.settle_run(identity) + for producer in journal['producers']: + self.custody.producer_outcome(producer['family'],producer['producer_id'],'not_admitted',True) + self.custody.acknowledge(self.session.snapshot) + except BaseException: + self.session.available=False + if owner is not None:owner._fence_coordinator() + raise + return dict(format='project_execution_reconciliation_v1',origin_catalog_id=self.db.dataset_id, + run_ids=sorted([run['id'] for entry,run in plans]+[run['id'] for local,run in native_plans]),available=self.session.available) diff --git a/project_extraction.py b/project_extraction.py new file mode 100644 index 00000000..79872c6d --- /dev/null +++ b/project_extraction.py @@ -0,0 +1,828 @@ +"""Finite read-only legacy capture for an explicitly selected project. + +The caller owns the repeatable-read PostgreSQL transaction, pinned directory +capabilities and stopped-source proof. This module never opens a catalog, +constructs runtime owners, enrolls records or writes source metadata. Scientific +shapes are admitted by their existing project/domain codecs. Missing observations +are reported; they are never replaced with invented history or regenerated bytes. +""" +import copy +from collections import deque +from datetime import datetime, timezone +import hashlib +import json +from types import SimpleNamespace + +from workbench import Workbench, WorkbenchError +import project_data as data_codec +import project_material as material_codec +import project_history as history_codec +import project_producers as producer_codec +from projects import decode_retained_snapshot, manifest, decode as decode_manifest, relative_path +from project_files import file_id, MAX_FILE_BYTES + +MAX_PAYLOAD = 40 * 1024 * 1024 +MAX_RECORDS = 100000 +ROOT_FAMILIES = { + 'records': 'workbench_record', 'responses': 'workbench_response', + 'preferences': 'workbench_preference', 'selections': 'saved_selection', + 'searches': 'saved_search', 'graphs': 'pipeline_graph', + 'recipe_drafts': 'recipe_definition', 'model_definitions': 'model_definition', + 'studies': 'study_history', 'runs': 'run_history', 'releases': 'dataset_release', + 'exposures': 'dataset_exposure', 'model_materials': 'model_material', + 'recipe_materials': 'recipe_material', 'runtime_materials': 'runtime_material', +} +PRODUCERS = {'generation': 'generation_history', 'grounded': 'grounded_history', + 'caption': 'caption_proposal_history', 'text_proposal': 'text_proposal_history'} +NAMESPACED = frozenset(material_codec.FAMILIES) | frozenset(history_codec.FAMILY_FORMATS) | frozenset(producer_codec.FAMILIES) + + +def _json(value): + return json.loads(value) if isinstance(value, str) else copy.deepcopy(value) + + +def _missing(detail): + raise WorkbenchError(detail, 'missing_source', 409) + + +class _ReadAccess: + """An internal read-view guard, never an imported actor or grant.""" + def require(self, scope, *args, **kwargs): + if scope != 'inspect': + raise WorkbenchError('Extraction source views are read-only.', 'forbidden', 403) + def audit(self, *args, **kwargs): + raise WorkbenchError('Extraction cannot append source audit.', 'forbidden', 403) + + +class _Capture: + def __init__(self, source): + self.source, self.db = source, source.db + self.origin = data_codec.identity(self.db.dataset_id) + self.records, self.inventory, self.dependencies = {}, {}, {} + self.blockers, self.warnings, self.working_files = [], [], [] + self.anchor = None + self.queue, self.visited, self.queued = deque(), set(), set() + self.snapshot_by_id = {} + self.raw_cache = {} + self.payload_gaps = [] + self.deleted_intents = set() + self.session = SimpleNamespace(snapshot=source.snapshot, db=self.db, + artifacts=source.artifacts, check=lambda: None) + # Workbench and each metadata-view constructor only assign references; + # none runs SQL, creates directories or constructs Dataset/Jobs/Runs. + # Never call get/history: those public owners may enroll images. + dataset = SimpleNamespace(db=self.db, lock=self.db.lock, access=_ReadAccess(), + path=self.db.path, project_sources={}, project_availability=self._available) + self.reader = Workbench(dataset) + # Scientific metadata must use the caller's pinned catalog FD reader. + self.reader.scientific._capture = lambda identity: self._catalog_source('field_sequence', identity) + + def locator(self, family, identity): + if type(identity) is not str: + raise WorkbenchError('Source identities must be exact strings.') + bare = identity + if family in NAMESPACED and identity.startswith(self.origin + '.'): + bare = identity[len(self.origin) + 1:] + if family in ('project_artifact', 'dataset_release', 'project_file', 'model_material', + 'recipe_material', 'runtime_material', 'project_snapshot'): + data_codec.sha(bare) + else: + data_codec.identity(bare) + return self.origin + '.' + bare if family in NAMESPACED else bare + + def bare(self, family, identity): + if family in NAMESPACED: + prefix = self.origin + '.' + if not identity.startswith(prefix): + raise WorkbenchError('Source dependency belongs to another catalog.', 'unsupported', 409) + return identity[len(prefix):] + return identity + + def observe(self, family, identity, revision=None): + key = family, self.locator(family, identity) + previous = self.inventory.get(key) + value = dict(family=key[0], id=key[1], revision=revision) + if previous is not None and previous != value and revision is not None: + raise WorkbenchError('Source inventory identity has conflicting revisions.', 'invalid', 409) + if previous is None or revision is not None: + if previous is None and len(self.inventory) >= MAX_RECORDS: + raise WorkbenchError('Legacy source exceeds the supported finite inventory.', 'too_large', 413) + self.inventory[key] = value + return key + + def need(self, family, identity, parent=None, reason='required scientific dependency'): + target = self.observe(family, identity) + if parent is not None: + source = dict(family=parent[0], id=parent[1]) + edge = dict(source=source, target=dict(family=target[0], id=target[1]), reason=reason) + self.dependencies[(parent, target, reason)] = edge + if target not in self.visited and target not in self.queued: + self.queue.append(target);self.queued.add(target) + return target + + def issue(self, key, code, detail, *, warning=False): + value = dict(family=key[0], id=key[1], code=code, detail=str(detail)[:1000]) + destination = self.warnings if warning else self.blockers + if value not in destination: + destination.append(value) + + def add(self, record): + key = record['family'], record['id'] + if key in self.records and self.records[key] != record: + raise WorkbenchError('Two source owners disagree about the same retained fact.', 'invalid', 409) + self.records[key] = copy.deepcopy(record) + self.observe(*key) + + def intrinsic(self, parent, blob, *, preservation='unique', role='source', raw=None): + sha, size = blob['sha256'], blob['bytes'] + data_codec.sha(sha) + if type(size) is not int or not 0 <= size <= 2**63 - 1: + raise WorkbenchError('Invalid source payload size.') + declaration = dict(format='project_artifact_v1', sha256=sha, bytes=size) + self.add(dict(family='project_artifact', id=sha, document=declaration)) + self.need('project_artifact', sha, parent, preservation + ':' + role) + try: + if raw is None: + if sha in self.raw_cache: + raw = self.raw_cache[sha] + elif size > MAX_PAYLOAD and hasattr(self.source, 'payload_fd'): + with self.source.payload_fd(sha, size) as handle: + descriptor = handle[0] if isinstance(handle, tuple) else handle + self.source.artifacts.publish_fd(descriptor, declaration, maximum_bytes=size) + return + else: + raw = self.source.resolve_payload(sha, size) + if type(raw) is not bytes or len(raw) != size or hashlib.sha256(raw).hexdigest() != sha: + raise WorkbenchError('Original payload differs from its declared byte identity.', 'corrupt_payload', 409) + self.source.artifacts.publish_bytes(raw, declaration, maximum_bytes=max(size, 1)) + except (OSError, WorkbenchError) as error: + self.payload_gaps.append((parent, sha, 'missing_regenerable' if preservation == 'regenerable' else getattr(error, 'code', 'missing_payload'), role + ': ' + str(error), preservation)) + + def raw(self, parent, raw, role, *, media='application/octet-stream', kind=None): + blob = dict(sha256=hashlib.sha256(raw).hexdigest(), bytes=len(raw)) + self.raw_cache[blob['sha256']] = raw + self.intrinsic(parent, blob, raw=raw, role=role) + if kind is None: + return blob + return dict(artifact=dict(namespace=self.origin, sha256=blob['sha256']), media_type=media, + kind=kind, role=role, preservation='unique', required_for=['inspect', 'release']) + + def _catalog_source(self, kind, identity): + folder, name = {'audio': ('audio', 'source.wav'), 'field_sequence': ('field_sequence', 'source.zip')}[kind] + return self.source.read_catalog(folder + '/' + identity + '/' + name, MAX_PAYLOAD) + + def _available(self, identity): + row = self.db.execute('SELECT kind FROM workbench_records WHERE id=%s', (identity,)).fetchone() + if row is None: + return False + try: + self._catalog_source(row[0], identity) + return True + except (OSError, WorkbenchError): + return False + + def _audit(self, target, actions, *, include_denied=False): + values = [] + for row in self.db.execute('SELECT actor_id,action,target_json,details_json,result,created_at FROM access_audit WHERE target_json::jsonb=%s::jsonb ORDER BY created_at,id', (json.dumps(target),)): + if row['action'] not in actions or row['result'] != 'success' and not include_denied: + continue + values.append(dict(actor_id=row['actor_id'], action=row['action'], target=_json(row['target_json']), + details=_json(row['details_json']), result=row['result'], + occurred_at=datetime.fromtimestamp(row['created_at'], timezone.utc).isoformat())) + return values + + def discover(self): + # Only identity/revision columns are read for unrelated global rows. + for row in self.db.execute('SELECT id,revision FROM workbench_records ORDER BY id'): + self.observe('workbench_record', row[0], row[1]) + for family, statement in ( + ('workbench_response', 'SELECT id,revision FROM workbench_responses ORDER BY id'), + ('workbench_preference', 'SELECT id,revision FROM workbench_preferences ORDER BY id'), + ('saved_selection', 'SELECT id,revision FROM saved_selections ORDER BY id'), + ('saved_search', 'SELECT id,revision FROM saved_searches ORDER BY id'), + ('pipeline_graph', 'SELECT id,revision FROM workspace_graphs ORDER BY id'), + ('recipe_definition', 'SELECT id,revision FROM workspace_recipe_drafts ORDER BY id'), + ('model_definition', 'SELECT id,revision FROM model_definition_drafts ORDER BY id'), + ('study_history', 'SELECT id,revision FROM model_studies ORDER BY id'), + ('run_history', 'SELECT id,NULL FROM workspace_runs ORDER BY id'), + ('trial_history', 'SELECT id,NULL FROM model_trials ORDER BY id'), + ('candidate_history', 'SELECT id,NULL FROM model_candidates ORDER BY id'), + ('model_material', 'SELECT sha256,revision FROM model_materials ORDER BY sha256'), + ('recipe_material', 'SELECT sha256,recipe_revision FROM workspace_recipe_materials ORDER BY sha256'), + ('dataset_exposure', 'SELECT id,NULL FROM workspace_exposures ORDER BY id'), + ('generation_history', 'SELECT id,NULL FROM generation_jobs ORDER BY id'), + ('grounded_history', 'SELECT id,NULL FROM grounded_jobs ORDER BY id'), + ('caption_proposal_history', 'SELECT id,NULL FROM caption_proposals ORDER BY id'), + ('text_proposal_history', 'SELECT id,NULL FROM text_classification_proposals ORDER BY id')): + for row in self.db.execute(statement): + self.observe(family, row[0], row[1]) + # These two owners remove the current row on deletion. Their closed + # successful audit facts retain a known identity, not its lost contents. + # Keep those identities visible in the global selection report. + for row in self.db.execute("SELECT action,target_json,details_json FROM access_audit WHERE result='success' AND action IN ('saved_selections.delete','saved_searches.delete') ORDER BY created_at,id"): + family = 'saved_selection' if row[0] == 'saved_selections.delete' else 'saved_search' + try: + identity, details = _json(row[1]), _json(row[2]) + data_codec.identity(identity) + if type(details) is not dict or set(details) != {'revision', 'retired_sha256'} or type(details['revision']) is not int or details['revision'] < 1: + continue + data_codec.sha(details['retired_sha256']) + except (WorkbenchError, ValueError, TypeError): + continue + key = family, identity + if key not in self.inventory: + self.observe(family, identity, details['revision']) + self.deleted_intents.add(key) + if len(self.inventory) > MAX_RECORDS: + raise WorkbenchError('Legacy source exceeds the supported finite inventory.', 'too_large', 413) + + def capture_record(self, identity, key): + current = Workbench._get(self.reader, identity) + history = [_json(row[0]) for row in self.db.execute('SELECT snapshot FROM workbench_history WHERE id=%s ORDER BY revision', (identity,))] + origin = _json(self.db.execute('SELECT provenance_json FROM workbench_records WHERE id=%s', (identity,)).fetchone()[0]) + kind = current['kind'] + if kind == 'text': + original = self.db.execute('SELECT original_text FROM workbench_records WHERE id=%s', (identity,)).fetchone()[0] + source = dict(kind=kind, original_text=original) + elif kind == 'image': + row = self.db.execute('SELECT * FROM samples WHERE id=%s', (identity,)).fetchone() + if row is None: + _missing('The deleted image has no preserved original source row or source geometry.') + sample = dict(row); sample['annotation'] = _json(sample['annotation']) + original = self.source.read_catalog('images/' + identity + '/source', MAX_PAYLOAD) + oriented = self.source.read_catalog('images/' + identity + '/image.png', MAX_PAYLOAD) + if hashlib.sha256(original).hexdigest() != sample['sha256'] or hashlib.sha256(oriented).hexdigest() != current['content_hash']: + raise WorkbenchError('Original image bytes changed outside their recorded identity.', 'corrupt_payload', 409) + source = dict(kind=kind, original=self.raw(key, original, 'original', kind=kind), + oriented=self.raw(key, oriented, 'oriented', media='image/png', kind=kind), revisions=[sample], deleted=False) + if sample['revision'] != 1: + self.issue(key, 'source_history_unavailable', 'Earlier image source revisions were not retained by the legacy catalog.') + return + elif kind in ('sequence', 'mesh', 'pointcloud', 'volume'): + statements = {'sequence': 'SELECT bundle FROM workbench_sequence_assets WHERE id=%s', + 'mesh': 'SELECT bundle FROM workbench_mesh_assets WHERE id=%s', + 'pointcloud': 'SELECT bundle FROM workbench_pointcloud_assets WHERE id=%s', + 'volume': 'SELECT bundle FROM workbench_volume_assets WHERE id=%s'} + row = self.db.execute(statements[kind], (identity,)).fetchone() + if row is None: + _missing('Original immutable BYTEA bundle is missing.') + raw = bytes(row[0]) + if hashlib.sha256(raw).hexdigest() != current['content_hash']: + raise WorkbenchError('Original immutable BYTEA identity differs.', 'corrupt_payload', 409) + source = dict(kind=kind, asset=self.raw(key, raw, 'original', media='application/zip', kind=kind), metadata=copy.deepcopy(current[kind])) + elif kind in ('audio', 'field_sequence'): + raw = self._catalog_source(kind, identity) + if hashlib.sha256(raw).hexdigest() != current['content_hash']: + raise WorkbenchError('Original scientific/audio source identity differs.', 'corrupt_payload', 409) + source = dict(kind=kind, asset=self.raw(key, raw, 'original', kind=kind, + media='audio/wav' if kind == 'audio' else 'application/zip'), metadata=copy.deepcopy(current[kind])) + else: + raise WorkbenchError('Unsupported source data kind.', 'unsupported', 409) + doc = dict(format='workbench_record_v1', origin=origin, source=source, current=current, + history=history, events=[], audit=self._audit(identity, data_codec.DATA_ACTIONS)) + self.add(dict(family='workbench_record', id=identity, document=doc)) + for parent in current['parents']: + self.need('workbench_record', parent, key, 'immutable data parent') + for response in self.db.execute('SELECT id FROM workbench_responses WHERE prompt_id=%s ORDER BY id', (identity,)): + self.need('workbench_response', response[0], key, 'captured response to selected prompt') + for preference in self.db.execute('SELECT id FROM workbench_preferences WHERE prompt_id=%s ORDER BY id', (identity,)): + self.need('workbench_preference', preference[0], key, 'captured preference for selected prompt') + provenance = origin + generation = provenance.get('generation') + if generation is not None: + self.need('generation_history', generation['job_id'], key, 'exact generating producer') + for field, family in (('caption_proposal', 'caption_proposal_history'), ('text_classification_proposal', 'text_proposal_history'), ('grounded', 'grounded_history')): + if field in provenance and isinstance(provenance[field], dict) and 'job_id' in provenance[field]: + self.need(family, provenance[field]['job_id'], key, 'exact source producer evidence') + + def capture_response(self, identity, key): + current = Workbench._response(self.reader, identity) + history = [_json(row[0]) for row in self.db.execute('SELECT snapshot FROM workbench_response_history WHERE id=%s ORDER BY revision', (identity,))] + self.add(dict(family='workbench_response', id=identity, document=dict(format='workbench_response_v1', + current=current, history=history, audit=self._audit(identity, data_codec.DATA_ACTIONS)))) + self.need('workbench_record', current['prompt_id'], key, 'exact captured response prompt') + + def capture_preference(self, identity, key): + from preferences import Preferences + current = Preferences._get(SimpleNamespace(db=self.db), identity) + history = [_json(row[0]) for row in self.db.execute('SELECT snapshot FROM workbench_preference_history WHERE id=%s ORDER BY revision', (identity,))] + self.add(dict(family='workbench_preference', id=identity, document=dict(format='workbench_preference_v1', + current=current, history=history, audit=self._audit(identity, data_codec.DATA_ACTIONS)))) + self.need('workbench_record', current['prompt_id'], key, 'exact captured preference prompt') + for response in (current['left_id'], current['right_id']): + self.need('workbench_response', response, key, 'exact captured preference answer') + + def capture_saved(self, family, identity, key): + from saved_selections import SavedSelections + from saved_searches import SavedSearches + if key in self.deleted_intents: + self.issue(key, 'intent_history_unavailable', 'A successful legacy deletion retains this identity and retired hash, but its original name and criteria/membership history are unavailable.') + return + current = (SavedSelections if family == 'saved_selection' else SavedSearches)._get(SimpleNamespace(db=self.db), identity) + if family == 'saved_selection': + for member in current['items']: + self.need('workbench_record', member['id'], key, 'saved exact membership') + if current['revision'] != 1: + self.issue(key, 'intent_history_unavailable', 'Legacy storage retains only the current saved name and criteria/membership, not its earlier revisions.') + return + self.add(dict(family=family, id=identity, document=dict(format=data_codec.FAMILIES[family], + current=current, history=[copy.deepcopy(current)], deleted=False, audit=self._audit(identity, data_codec.DATA_ACTIONS)))) + + def capture_definition(self, family, identity, key): + statement = {'pipeline_graph': 'SELECT document FROM workspace_graphs WHERE id=%s', + 'model_definition': 'SELECT document FROM model_definition_drafts WHERE id=%s', + 'recipe_definition': 'SELECT document FROM workspace_recipe_drafts WHERE id=%s'}[family] + row = self.db.execute(statement, (identity,)).fetchone() + if row is None: + _missing('Selected editable definition is missing.') + value = material_codec._definition(family, _json(row[0]), self.origin) + audits = [] + for fact in self._audit(identity, material_codec.AUDIT_ACTIONS): + try: + material_codec._definition_audit(family, value, fact, self.origin) + except WorkbenchError: + continue + audits.append(fact) + if len(audits) != 1: + _missing('The current editable definition has no unique exact original audit fact.') + record = dict(family=family, id=key[1], document=dict(format=material_codec.FAMILIES[family], + origin_catalog_id=self.origin, current=value, revisions=[copy.deepcopy(value)], audit=audits)) + material_codec.decode_records([record]);self.add(record) + if value['revision'] > 1: + self.issue(key, 'observed_history_incomplete', 'Only the actual retained current definition and its exact audit are available; earlier definition documents were not stored.', warning=True) + selector = value.get('source_selector') + if selector is not None: + self.snapshot_dependency(selector['project_id'], key) + if family == 'pipeline_graph': + # Graph-specific dependency extraction is delegated to the existing + # graph grammar below rather than inferred from arbitrary JSON. + for node in value['nodes']: + if node['type'] == 'release': + self.need('dataset_release', node['parameters']['release_id'], key, 'frozen graph input release') + elif node['type'] == 'input' and value['recipe'] == 'external_recipe': + self.snapshot_dependency(node['parameters']['project_id'], key) + + def snapshot_dependency(self, identity, parent): + row = self.db.execute('SELECT document FROM workspace_projects WHERE id=%s', (identity,)).fetchone() + if row is None: + _missing('Required registered project snapshot is missing.') + saved = decode_retained_snapshot(_json(row[0]));self.snapshot_by_id[identity] = saved + self.need('project_snapshot', saved['manifest_sha256'], parent, 'exact retained source registration') + + def capture_snapshot(self, sha, key): + matches = [value for value in self.snapshot_by_id.values() if value['manifest_sha256'] == sha] + if len(matches) != 1: + _missing('An exact selected project snapshot is not available in this pinned source.') + saved = matches[0] + root = self.source.roots.get(saved['root']) + if root is None or list(root[1]) != saved['root_identity']: + _missing('The retained registration has no matching current explicit source-root capability.') + raw = self.source.read_root(saved['root'], saved['manifest_path'], 65536) + if hashlib.sha256(raw).hexdigest() != sha or manifest(decode_manifest(raw)) != saved['manifest']: + raise WorkbenchError('Original registered manifest bytes differ.', 'corrupt_payload', 409) + blob = self.raw(key, raw, 'project_manifest') + record = dict(family='project_snapshot', id=key[1], document=dict(format=material_codec.FAMILIES['project_snapshot'], + origin_catalog_id=self.origin, value=saved, artifacts=material_codec._edges([dict(name=saved['manifest_path'], **blob)], 'project_snapshot'), audit=[])) + material_codec.decode_records([record]);self.add(record) + + def capture_material(self, family, sha, key): + statement = ('SELECT document FROM model_materials WHERE sha256=%s' if family == 'model_material' + else 'SELECT document FROM workspace_recipe_materials WHERE sha256=%s') + row = self.db.execute(statement, (sha,)).fetchone() + if row is None: + _missing('Required immutable material is missing.') + value = _json(row[0]);inventory = material_codec._inventory(family, value) + target = value['ref']['id'] if family == 'model_material' else sha + action = 'models.publish' if family == 'model_material' else 'recipes.material' + expected = (dict(revision=value['ref']['revision'], material_sha256=sha) if family == 'model_material' + else dict(recipe_id=value['material']['recipe_id'], revision=value['material']['recipe_revision'])) + audits = [fact for fact in self._audit(target, {action}) if fact['details'] == expected] + record = dict(family=family, id=key[1], document=dict(format=material_codec.FAMILIES[family], + origin_catalog_id=self.origin, value=value, artifacts=material_codec._edges(inventory, family), audit=audits)) + material_codec.decode_records([record]);self.add(record) + folder = 'model-materials' if family == 'model_material' else 'workspace-recipe-materials' + for item in inventory: + try: + raw = self.source.read_catalog(folder + '/' + sha + '/' + item['name'], min(MAX_PAYLOAD, max(item['bytes'], 1))) + except (OSError, WorkbenchError): + raw = None + self.intrinsic(key, item, role=item['name'], raw=raw) + material = value['material'] + if family == 'model_material': + self.need('model_definition', material['model_definition_id'], key, 'original editable model definition') + if material['source_selector'] is not None: + self.snapshot_dependency(material['source_selector']['project_id'], key) + else: + self.need('model_material', material['model']['sha256'], key, 'exact composed model material') + self.need('pipeline_graph', material['recipe_id'], key, 'original editable composition graph') + for release in material['data']: + self.need('dataset_release', release['id'], key, 'exact frozen composition data') + recipe = material['recipe_document'] + raw = self.source.resolve_payload(recipe['sha256'], recipe['bytes']) + snapshot = _json(raw.decode('utf-8')) + if snapshot['format'] == 'builtin_recipe_snapshot_v1': + environment = snapshot['validation']['runtime'] + else: + execution = snapshot['preflight']['material'] + environment = execution.get('torch_runtime', execution.get('cpu_snapshot', execution['runtime'])) + self.capture_runtime(environment, key) + + def capture_runtime(self, environment, parent): + from pipeline import digest + from recipe_process import decode + sha = digest(environment);key = self.need('runtime_material', sha, parent, 'original measured runtime material') + if key in self.records: + return + descriptor = environment if environment.get('profile') in ('captured_cpu_python_v1', 'managed_torch_python_v1') else None + raw = self.source.resolve_payload(descriptor['manifest_sha256'], descriptor['manifest_bytes']) if descriptor else None + decoded = decode(raw) if raw is not None else None + value = dict(ref=dict(kind='runtime', catalog_id=self.origin, id=sha, sha256=sha), descriptor=descriptor, + environment=environment if descriptor is None else None, manifest=decoded) + value = material_codec.decode_runtime_material(value, catalog_id=self.origin) + inventory = ([dict(name='manifest.json', sha256=descriptor['manifest_sha256'], bytes=descriptor['manifest_bytes'])] + + [dict(name=item['path'], sha256=item['sha256'], bytes=item['bytes']) for item in decoded['files']]) if descriptor else [] + record = dict(family='runtime_material', id=key[1], document=dict(format=material_codec.FAMILIES['runtime_material'], + origin_catalog_id=self.origin, value=value, artifacts=material_codec._edges(inventory, 'runtime_material'), audit=[])) + material_codec.decode_records([record]);self.add(record) + for item in record['document']['artifacts']: + self.intrinsic(key, item['blob'], preservation=item['preservation'], role=item['name'], raw=raw if item['name'] == 'manifest.json' else None) + + def capture_study(self, identity, key): + row = self.db.execute('SELECT document FROM model_studies WHERE id=%s', (identity,)).fetchone() + if row is None:_missing('Selected Study is missing.') + study = _json(row[0]);ref = study['policy'] + policy_row = self.db.execute('SELECT document FROM model_selection_policies WHERE sha256=%s', (ref['sha256'],)).fetchone() + if policy_row is None:_missing('Exact Study policy is missing.') + memberships = [dict(revision=row[0], trial_ids=_json(row[1])['trial_ids']) for row in self.db.execute( + 'SELECT revision,document FROM model_study_memberships WHERE study_id=%s ORDER BY revision', (identity,))] + reservations = [_json(row[0]) for row in self.db.execute('SELECT document FROM model_attempt_reservations WHERE study_id=%s ORDER BY id', (identity,))] + doc = dict(format=history_codec.FAMILY_FORMATS['study_history'], origin_catalog_id=self.origin, study=study, + memberships=memberships, policies=[dict(ref=ref, policy=_json(policy_row[0]))], reservations=reservations, + operations=[], revisions=[copy.deepcopy(study)], audit=[], revision_history_complete=study['revision'] == 1) + # Native operation receipts contain scientific results, not replay + # instructions in the destination. Classify them through the existing + # Study owner's finite result-to-Study projection. + from project_study_execution import ProjectStudyExecution + def original_trial(trial_id): + selected = self.db.execute('SELECT document FROM model_trials WHERE id=%s', (trial_id,)).fetchone() + if selected is None:_missing('Native lineage operation lost its original Trial.') + return _json(selected[0]) + classifier = SimpleNamespace(studies=SimpleNamespace(trial=original_trial)) + observed_revisions = {study['revision']: copy.deepcopy(study)} + policies = {ref['sha256']: doc['policies'][0]} + for row in self.db.execute('SELECT actor_id,operation_id,request_key,request_sha256,document FROM model_operation_receipts ORDER BY actor_id,operation_id,request_key'): + if row['operation_id'] not in history_codec.OPERATIONS: + continue + try: + result = _json(row['document']) + belongs = ProjectStudyExecution._operation_study(classifier, row['operation_id'], result) + except (WorkbenchError, KeyError, TypeError, ValueError): + self.issue(key, 'unassigned_operation_unreadable', 'A native operation result cannot be assigned to a Study: ' + row['operation_id'] + '/' + row['request_key'], warning=True) + continue + if belongs != identity: + continue + fact = dict(actor_id=row['actor_id'], operation_id=row['operation_id'], request_key=row['request_key'], + request_sha256=row['request_sha256'], result=result) + history_codec._operations([fact], self.origin) + doc['operations'].append(fact) + if row['operation_id'] in ('studies.create', 'studies.save', 'studies.continue'): + previous = observed_revisions.get(result['revision']) + if previous is not None and previous != result: + raise WorkbenchError('Native Study receipts disagree about an observed revision.') + observed_revisions[result['revision']] = copy.deepcopy(result) + policy_ref = result['policy'] + policy_row = self.db.execute('SELECT document FROM model_selection_policies WHERE sha256=%s', (policy_ref['sha256'],)).fetchone() + if policy_row is None:_missing('Observed Study revision lost its exact policy.') + policies[policy_ref['sha256']] = dict(ref=policy_ref, policy=_json(policy_row[0])) + doc['revisions'] = [observed_revisions[revision] for revision in sorted(observed_revisions)] + doc['policies'] = [policies[sha] for sha in sorted(policies)] + doc['revision_history_complete'] = sorted(observed_revisions) == list(range(1, study['revision'] + 1)) + for trial in study['trial_ids']: + self.need('trial_history', trial, key, 'immutable Study Trial membership') + for reservation in reservations: + self.need('run_history', reservation['run_id'], key, 'spent or reserved scientific attempt') + candidate_ids = [] + for row in self.db.execute('SELECT id,document FROM model_candidates WHERE study_id=%s ORDER BY id', (identity,)): + self.need('candidate_history', row[0], key, 'retained scientific candidate') + candidate_ids.append(row[0]);candidate = _json(row[1]);policy_ref = candidate['policy'] + if policy_ref['sha256'] not in policies: + policy_row = self.db.execute('SELECT document FROM model_selection_policies WHERE sha256=%s', (policy_ref['sha256'],)).fetchone() + if policy_row is None:_missing('Candidate lost its exact historical selection policy.') + policies[policy_ref['sha256']] = dict(ref=policy_ref, policy=_json(policy_row[0])) + doc['policies'] = [policies[sha] for sha in sorted(policies)] + targets = {identity, *study['trial_ids'], *candidate_ids, *(item['run_id'] for item in reservations)} + for target in sorted(targets): + doc['audit'].extend(self._audit(target, history_codec.OPERATIONS | {'studies.admit', 'studies.approve', 'studies.reconcile', 'studies.candidate_selected', 'studies.lineage.record', 'runs.admit', 'runs.admit_external', 'runs.update', 'runs.reconcile', 'runs.expose_test'})) + history_codec._audit(doc['audit']) + self.add(history_codec.envelope('study_history', self.origin, identity, doc)) + if not doc['revision_history_complete']: + self.issue(key, 'observed_history_incomplete', 'The legacy catalog retained only this Study revision; immutable memberships, policies, Trials and reservations are preserved.', warning=True) + + def capture_trial(self, identity, key): + row = self.db.execute('SELECT document FROM model_trials WHERE id=%s', (identity,)).fetchone() + if row is None:_missing('Required historical Trial is missing.') + trial = _json(row[0]);lineage_row = self.db.execute('SELECT document FROM model_trial_lineage WHERE trial_id=%s', (identity,)).fetchone() + lineage = _json(lineage_row[0]) if lineage_row is not None else None + self.add(history_codec.envelope('trial_history', self.origin, identity, dict(format=history_codec.FAMILY_FORMATS['trial_history'], + origin_catalog_id=self.origin, trial=trial, lineage=lineage))) + self.need('study_history', trial['study_id'], key, 'original Trial Study') + self.need('recipe_material', trial['material']['sha256'], key, 'exact immutable Trial composition') + if lineage is not None: + for relation in ('parent', 'baseline'): + ref = lineage['intent'][relation] + if ref is not None: + if ref['catalog_id'] != self.origin: + self.issue(key, 'unsupported_origin', 'Lineage crosses a source catalog not supplied by this read capability.') + else:self.need('trial_history', ref['trial_id'], key, 'explicit ' + relation + ' lineage') + + def capture_candidate(self, identity, key): + row = self.db.execute('SELECT document FROM model_candidates WHERE id=%s', (identity,)).fetchone() + if row is None:_missing('Required candidate observation is missing.') + candidate = _json(row[0]) + self.add(history_codec.envelope('candidate_history', self.origin, identity, dict(format=history_codec.FAMILY_FORMATS['candidate_history'], + origin_catalog_id=self.origin, candidate=candidate))) + self.need('study_history', candidate['study_id'], key, 'original candidate Study') + self.need('trial_history', candidate['trial_id'], key, 'original candidate Trial') + self.need('run_history', candidate['run_id'], key, 'original candidate Run evidence') + + def capture_run(self, identity, key): + row = self.db.execute('SELECT document FROM workspace_runs WHERE id=%s', (identity,)).fetchone() + if row is None:_missing('Selected historical Run is missing.') + run = _json(row[0]);history_codec.project_run_projection(run) + admission = self.db.execute('SELECT * FROM access_admissions WHERE id=%s', (run['admission_id'],)).fetchone() + grant = consumption = None + if run.get('approval_id') is not None: + grant = self.db.execute('SELECT * FROM access_execution_grants WHERE id=%s', (run['approval_id'],)).fetchone() + consumption = self.db.execute('SELECT * FROM access_execution_consumptions WHERE grant_id=%s', (run['approval_id'],)).fetchone() + proof = None + if admission is not None: + admission = dict(admission);admission['effects'] = _json(admission.pop('effects_json')) + proof = dict(grant=dict(grant) if grant is not None else None, consumption=dict(consumption) if consumption is not None else None, admission=admission) + history_codec._approval(proof, run) + payloads = [] + def payload(name, blob, role, population='unknown', raw=None, preservation='unique'): + self.intrinsic(key, blob, role=name, raw=raw, preservation=preservation) + payloads.append(dict(name=name, blob={field:blob[field] for field in ('sha256', 'bytes')}, population=population, + role=role, preservation=preservation, required_for=['scientific_inspection', 'execution_proof'])) + external = run.get('execution_kind') == 'external_recipe' + if external: + for name, item in run['material']['captures'].items(): + try:raw = self.source.read_catalog('workspace-runs/.external-' + identity + '/' + name, min(MAX_PAYLOAD, max(item['bytes'], 1))) + except (OSError, WorkbenchError):raw = None + payload(name, item, 'execution_source', raw=raw) + environment = run['material'].get('torch_runtime', run['material'].get('cpu_snapshot', run['material']['runtime'])) + self.capture_runtime(environment, key) + for ordinal, (name, item) in enumerate(sorted(run['material']['runtime'].get('files', {}).items())): + payload('runtime-files/' + str(ordinal), item, 'historical_runtime', preservation='regenerable') + for name, sha in sorted(run['material']['host_files'].items()): + declared = self.records.get(('project_artifact', sha)) + candidates = {declared['document']['bytes']} if declared is not None else { + item['bytes'] for item in self.source.external_inventory if item['sha256'] == sha} + if len(candidates) != 1: + self.issue(key, 'source_byte_count_unavailable', 'Original host source ' + name + ' retains a hash without original byte-count/custody evidence.') + else: + payload('host-sources/' + name, dict(sha256=sha, bytes=candidates.pop()), 'historical_host_source') + if run['state'] == 'completed': + receipt = self.source.read_catalog('workspace-runs/' + identity + '/custody-receipt.json', 16384) + inventory = self.source.read_catalog('workspace-runs/' + identity + '/artifact-manifest.json', 65536) + from runs import validate_external_artifact_inventory + validate_external_artifact_inventory(run, receipt, inventory) + payload('custody-receipt.json', dict(sha256=hashlib.sha256(receipt).hexdigest(), bytes=len(receipt)), 'custody_proof', raw=receipt) + payload('artifact-manifest.json', dict(sha256=hashlib.sha256(inventory).hexdigest(), bytes=len(inventory)), 'custody_proof', raw=inventory) + else: + payload('release.zip', dict(sha256=run['release_sha256'], bytes=run['release_bytes']), 'frozen_input') + self.need('dataset_release', run['release_id'], key, 'exact native frozen input') + for source in run['source_records']: + self.need('workbench_record', source['id'], key, 'retained native scientific input') + from pipeline import adapter_for_graph, decode_probe_observation + import release_validation + # Whole-asset integrity runs execute in-process and do not create + # the training source staging directory. Trainable native runs do; + # only those original, hash-bound copies prove their implementation. + if adapter_for_graph(run['graph']) is not release_validation: + if 'definition_files' not in run['runtime']: + self.issue(key, 'source_identity_unavailable', 'Native training Run has no retained original source inventory.') + else: + observation = decode_probe_observation(run['graph'].get('recipe', 'image_classification'), + run['runtime'], with_definition=True) + for name, expected in sorted(observation['definition_files'].items()): + try: + raw = self.source.read_catalog('workspace-runs/.staging-' + identity + '/' + name, MAX_PAYLOAD) + except (OSError, WorkbenchError): + self.issue(key, 'missing_source', 'Original native training source ' + name + ' is unavailable.') + continue + if hashlib.sha256(raw).hexdigest() != expected: + self.issue(key, 'corrupt_source', 'Original native training source ' + name + ' differs from its retained hash.') + continue + payload('source/' + name, dict(sha256=expected, bytes=len(raw)), 'execution_source', raw=raw) + for item in run['artifacts']: + try:raw = self.source.read_catalog('workspace-runs/' + identity + '/' + item['name'], min(MAX_PAYLOAD, max(item['bytes'], 1))) + except (OSError, WorkbenchError):raw = None + payload(item['name'], item, item.get('role', 'output'), item.get('split', 'unknown'), raw=raw) + if run.get('approval_material_sha256') is not None: + self.need('recipe_material', run['approval_material_sha256'], key, 'original approved composition') + if run.get('parent_run_id') is not None:self.need('run_history', run['parent_run_id'], key, 'original retry parent') + for association in self.db.execute('SELECT trial_id,study_id FROM model_attempt_reservations WHERE run_id=%s', (identity,)): + self.need('study_history', association[1], key, 'original budget association') + self.need('trial_history', association[0], key, 'original attempt Trial') + if external: + for item in run['material']['inputs'].values() if isinstance(run['material'].get('inputs'), dict) else []: + source = item.get('source', {}) + if item.get('kind') == 'run_artifact': + if source.get('catalog_id') != self.origin: + self.issue(key, 'unsupported_origin', 'Run artifact input crosses a source catalog not supplied by this read capability.') + else: + self.need('run_history', source['run_id'], key, 'exact producer Run input') + publication = None + if run.get('publication_continuation') is not None: + publication = dict(continuation=copy.deepcopy(run['publication_continuation']), source=None, result=None) + if run.get('publication_source') is not None: + from run_publication import validate_publication_source, validate_publication_result, MAX_DOCUMENT_BYTES + raw = self.source.read_catalog('workspace-runs/.external-' + identity + '/publication-source-v1.json', MAX_DOCUMENT_BYTES) + source_proof = validate_publication_source(raw, run=run) + blob = dict(sha256=hashlib.sha256(raw).hexdigest(), bytes=len(raw)) + payload('publication-source-v1.json', blob, 'publication_proof', raw=raw) + if publication is not None: + publication['source'] = blob + continuation = publication['continuation'];result_ref = continuation.get('result_ref') + if result_ref is not None: + result_raw = self.source.read_catalog('workspace-runs/.external-' + identity + '/publication-result-v1.json', MAX_DOCUMENT_BYTES) + validate_publication_result(result_raw, run=run, source=source_proof, ref=result_ref, continuation=continuation) + result_blob = dict(sha256=hashlib.sha256(result_raw).hexdigest(), bytes=len(result_raw)) + payload('publication-result-v1.json', result_blob, 'publication_result', raw=result_raw) + publication['result'] = result_blob + self.add(history_codec.capture_run(run, admission_proof=proof, payloads=payloads, publication=publication)) + + def capture_producer(self, family, identity, key): + statements = {'generation_history': 'SELECT data,ordinal FROM generation_jobs WHERE id=%s', + 'grounded_history': 'SELECT data,ordinal FROM grounded_jobs WHERE id=%s', + 'caption_proposal_history': 'SELECT data,ordinal FROM caption_proposals WHERE id=%s', + 'text_proposal_history': 'SELECT data,ordinal FROM text_classification_proposals WHERE id=%s'} + row = self.db.execute(statements[family], (identity,)).fetchone() + if row is None:_missing('Selected producer history is missing.') + original = _json(row[0]);entries = [] + if original.get('id') != identity:raise WorkbenchError('Producer row identity differs.') + if family == 'generation_history': + for entry in self.db.execute('SELECT data,storage_order FROM generation_entries WHERE job_id=%s ORDER BY storage_order', (identity,)): + entries.append((_json(entry[0]), entry[1])) + owner = SimpleNamespace(_origin=self.origin, _pending_payloads={}, _artifacts={}, session=self.session) + record = producer_codec._capture_job(owner, family, original, row[1], entries) + self.add(record) + for sha, declaration in owner._artifacts.items():self.intrinsic(key, declaration, role='producer_evidence', raw=owner._pending_payloads[sha]) + source = original.get('source') + if source is not None:self.need('workbench_record', source['id'], key, 'exact captured producer source') + if family == 'generation_history': + for entry, _ in entries: + if entry.get('sample_id') is not None:self.need('workbench_record', entry['sample_id'], key, 'retained generated source') + + def capture_release(self, identity, key): + # A release is immutable original ZIP evidence, never re-exported from + # current SQL. Its hash is its identity; inventory supplies original size. + from dataset_releases import Releases + raw = self.source.read_catalog('releases/' + identity + '.zip', MAX_PAYLOAD) + if hashlib.sha256(raw).hexdigest() != identity: + raise WorkbenchError('Original frozen release hash differs.', 'corrupt_payload', 409) + manifest_value = Releases.project_manifest(raw) + edge = self.raw(key, raw, 'frozen_archive', kind='release', media='application/zip') + self.add(dict(family='dataset_release', id=identity, document=dict(format='dataset_release_v1', asset=edge, + manifest=manifest_value, release_format=manifest_value.get('format', 'canonical_v1'), audit=self._audit(identity, data_codec.DATA_ACTIONS)))) + for item in manifest_value.get('records', manifest_value.get('prompts', [])): + self.need('workbench_record', item['id'], key, 'original frozen release record') + for item in manifest_value.get('responses', []): + self.need('workbench_response', item['id'], key, 'original frozen response') + for item in manifest_value.get('judgments', []): + self.need('workbench_preference', item['id'], key, 'original frozen preference') + + def capture_exposure(self, identity, key): + row = self.db.execute('SELECT * FROM workspace_exposures WHERE id=%s', (identity,)).fetchone() + if row is None:_missing('Selected disclosure observation is missing.') + value = dict(row);self.add(dict(family='dataset_exposure', id=identity, document=dict(format='dataset_exposure_v1', **value))) + self.need('workbench_record', value['record_id'], key, 'original exposed data') + self.need('run_history', value['run_id'], key, 'original disclosure Run') + self.need('dataset_release', value['release_id'], key, 'original exposed input Release') + + def working(self, item, saved): + kind, path = item['kind'], item['path'] + identity = file_id(self.origin, kind, path);key = self.observe('project_file', identity) + relative = saved['manifest_path'].rsplit('/', 1)[0] + '/' + path if '/' in saved['manifest_path'] else path + root = self.source.roots.get(saved['root']) + if root is None or list(root[1]) != saved['root_identity']: + _missing('Working files require the exact current registered root capability.') + raw = self.source.read_root(saved['root'], relative, MAX_FILE_BYTES) + text = raw.decode('utf-8', 'strict');blob = self.raw(key, raw, 'selected_working_file') + self.working_files.append(dict(kind=kind, path=path, sha256=blob['sha256'], bytes=blob['bytes'], text=text)) + + def execute(self, key): + family, locator = key;identity = self.bare(family, locator) + if key in self.records:return + if family == 'project_artifact': + match = [item for item in self.source.external_inventory if item['sha256'] == identity] + if not match:_missing('Selected artifact has no original byte-count inventory.') + sizes = {item['bytes'] for item in match} + if len(sizes) != 1:raise WorkbenchError('Selected artifact byte-count observations disagree.') + self.intrinsic(key, dict(sha256=identity, bytes=sizes.pop()), role='explicit_selected_artifact') + return + if family == 'workbench_record':return self.capture_record(identity, key) + if family == 'workbench_response':return self.capture_response(identity, key) + if family == 'workbench_preference':return self.capture_preference(identity, key) + if family in ('saved_selection', 'saved_search'):return self.capture_saved(family, identity, key) + if family in material_codec.EDITABLE:return self.capture_definition(family, identity, key) + if family == 'project_snapshot':return self.capture_snapshot(identity, key) + if family in ('model_material', 'recipe_material'):return self.capture_material(family, identity, key) + if family == 'project_file':_missing('Working-file adoption requires its explicit selected kind and path, not an inferred identifier.') + if family == 'runtime_material':_missing('Runtime material has no exact owning recipe/Run descriptor in the selected source closure.') + if family == 'study_history':return self.capture_study(identity, key) + if family == 'trial_history':return self.capture_trial(identity, key) + if family == 'candidate_history':return self.capture_candidate(identity, key) + if family == 'run_history':return self.capture_run(identity, key) + if family in producer_codec.FAMILIES:return self.capture_producer(family, identity, key) + if family == 'dataset_release':return self.capture_release(identity, key) + if family == 'dataset_exposure':return self.capture_exposure(identity, key) + raise WorkbenchError('Unsupported extraction family.', 'unsupported', 409) + + def validate(self): + for parent, sha, code, detail, preservation in self.payload_gaps: + declaration = self.records[('project_artifact', sha)]['document'] + if self.source.artifacts.status(declaration)['state'] != 'verified': + self.issue(parent, code, detail, warning=preservation == 'regenerable') + records = list(self.records.values()) + # Failed dependencies remain explicit blockers. Do not turn their + # absence into a fabricated valid empty closure. + if self.blockers:return + try: + data_codec.decode_data_records(records) + material_codec.decode_records(records) + materials = material_codec.ProjectMaterial(SimpleNamespace(snapshot=dict(project_id=self.origin, records=records), check=lambda: None)) + history_codec.decode_records(records, materials=materials) + producer_codec.decode_records(records, artifacts=self.source.artifacts) + except WorkbenchError as error: + self.issue(self.anchor, error.code, str(error)) + + +def capture_legacy(source, selection): + """Capture selected facts from a caller-owned immutable read capability. + + All returned locators use exact canonical envelope identities. Global + inventory is distinct from selected closure. Source credentials, current + admissions and runtime owners are never reconstructed in the destination. + """ + if source.db.schema_version != 10: + raise WorkbenchError('Only the qualified legacy schema 10 can be extracted.', 'unsupported', 409) + if type(selection) is not dict or set(selection) - {'project_snapshot', 'roots', 'decisions'}: + raise WorkbenchError('Invalid finite extraction selection.') + snapshot = selection.get('project_snapshot') + if type(snapshot) is not dict or set(snapshot) != {'id', 'manifest_sha256'}: + raise WorkbenchError('Select one exact registered project snapshot.') + roots = selection.get('roots', {}) + if type(roots) is not dict or set(roots) - set(ROOT_FAMILIES) - {'producer_jobs', 'working_files'}: + raise WorkbenchError('Unknown extraction root family.') + source.require() + proof = source.db.execute("SELECT current_setting('transaction_read_only'),current_setting('transaction_isolation')").fetchone() + if tuple(proof) != ('on', 'repeatable read'): + raise WorkbenchError('Extraction requires a pinned repeatable-read READ ONLY source.', 'unavailable', 409) + capture = _Capture(source) + with source.db.lock: + capture.discover() + capture.snapshot_dependency(snapshot['id'], None) + saved = capture.snapshot_by_id[snapshot['id']] + if saved['manifest_sha256'] != snapshot['manifest_sha256']: + raise WorkbenchError('Selected registered snapshot digest changed.', 'stale', 409) + capture.anchor = capture.need('project_snapshot', saved['manifest_sha256']) + for root, family in ROOT_FAMILIES.items(): + values = roots.get(root, []) + if type(values) is not list or len(values) > MAX_RECORDS: + raise WorkbenchError('Extraction roots must be bounded lists.') + for identity in values: + if not isinstance(identity, str):raise WorkbenchError('Invalid selected identity.') + capture.need(family, identity) + decisions = selection.get('decisions', []) + if type(decisions) is not list or len(decisions) > MAX_RECORDS: + raise WorkbenchError('Ownership decisions must be bounded lists.') + supported = set(ROOT_FAMILIES.values()) | NAMESPACED | {'project_artifact', 'project_file', 'trial_history', 'candidate_history'} + for decision in decisions: + if (type(decision) is not dict or set(decision) != {'family', 'id', 'include', 'reason'} + or decision['family'] not in supported or type(decision['include']) is not bool): + raise WorkbenchError('Invalid explicit ownership decision.') + if decision['include']: + capture.need(decision['family'], decision['id']) + for item in roots.get('producer_jobs', []): + if type(item) is not dict or set(item) != {'kind', 'id'} or item['kind'] not in PRODUCERS: + raise WorkbenchError('Invalid selected producer family.') + capture.need(PRODUCERS[item['kind']], item['id']) + for item in roots.get('working_files', []): + try:capture.working(item, saved) + except (OSError, WorkbenchError, UnicodeError) as error: + capture.issue(('project_file', file_id(capture.origin, item['kind'], item['path'])), getattr(error, 'code', 'missing_source'), str(error)) + while capture.queue: + key = capture.queue.popleft();capture.queued.discard(key) + if key in capture.visited:continue + capture.visited.add(key) + if len(capture.visited) > MAX_RECORDS:raise WorkbenchError('Extraction closure exceeds its bound.', 'too_large', 413) + try:capture.execute(key) + except (OSError, WorkbenchError, KeyError, TypeError, ValueError) as error: + capture.issue(key, getattr(error, 'code', 'invalid_source'), str(error)) + capture.validate() + return dict(records=[capture.records[key] for key in sorted(capture.records)], + inventory=[capture.inventory[key] for key in sorted(capture.inventory)], + dependencies=[capture.dependencies[key] for key in sorted(capture.dependencies)], + blockers=capture.blockers, working_files=capture.working_files, warnings=capture.warnings) diff --git a/project_files.py b/project_files.py new file mode 100644 index 00000000..a1de61fc --- /dev/null +++ b/project_files.py @@ -0,0 +1,425 @@ +"""Project-owned scripts and notes: inert working files, explicit adoption. + +Captured UTF-8 text is immutable checkpoint history. Inspecting a working tree +never rewrites it, restores a missing file, executes a script, or installs tools. +""" +import copy +from datetime import datetime +import errno +import hashlib +import os +import stat + +from project_store import DIR_FLAGS +from workbench import WorkbenchError, timestamp + +FAMILY='project_file' +FORMAT='project_file_v1' +DELETION_FORMAT='project_file_v2' +MAX_FILE_BYTES=1024*1024 +MAX_FILES=1000 +KINDS={'script':'scripts','note':'notes'} +VERSION_FIELDS={'revision','sha256','bytes','text','adopted_at','actor_id'} +RESULT_FIELDS={'id','project_id','kind','path','revision','sha256','bytes','adopted_at','changed'} + + +def fail(message,code='invalid',status=400): + raise WorkbenchError(message,code,status) + + +def closed(value,fields,label): + if type(value) is not dict or set(value)!=fields:fail('Invalid '+label+' fields.') + + +def digest(raw):return hashlib.sha256(raw).hexdigest() + + +def sha(value): + if type(value) is not str or len(value)!=64 or any(c not in '0123456789abcdef' for c in value):fail('Invalid working-file digest.') + + +def _path(kind,path): + if type(kind) is not str or kind not in KINDS or type(path) is not str or len(path)>512 or '\\' in path or any(ord(c)<32 for c in path): + fail('Choose a bounded relative script or note path.') + try:path.encode('utf-8') + except UnicodeEncodeError:fail('Working-file path must be strict UTF-8.') + parts=path.split('/') + if not 2<=len(parts)<=16 or parts[0]!=KINDS[kind] or any(part in ('','.','..') or len(part.encode('utf-8'))>255 for part in parts): + fail('Working-file path must remain inside its scripts or notes root.') + return parts + + +def _project_id(value): + if type(value) is not str or len(value)!=32 or any(c not in '0123456789abcdef' for c in value):fail('Invalid working-file project identity.') + + +def file_id(project_id,kind,path): + _project_id(project_id) + _path(kind,path) + return digest((project_id+'\0'+kind+'\0'+path).encode('utf-8')) + + +def _date(value): + try: + if type(value) is not str or len(value)>80:raise ValueError() + parsed=datetime.fromisoformat(value) + if parsed.utcoffset() is None:raise ValueError() + return parsed + except (ValueError,TypeError):fail('Invalid adoption timestamp.') + + +def _version(value,*,allow_deleted=False): + deleted=allow_deleted and type(value) is dict and value.get('deleted') is True + closed(value,VERSION_FIELDS|{'deleted'} if deleted else VERSION_FIELDS,'adopted working-file revision') + if type(value['revision']) is not int or not 1<=value['revision']<=2**53-1:fail('Invalid adoption revision.') + if deleted: + if value['text'] is not None or value['sha256'] is not None or type(value['bytes']) is not int or value['bytes']!=0: + fail('A deleted note revision must retain an exact absence marker.') + else: + if type(value['text']) is not str:fail('Adopted source must be UTF-8 text.') + try:raw=value['text'].encode('utf-8') + except UnicodeEncodeError:fail('Adopted source must be strict UTF-8.') + sha(value['sha256']) + if type(value['bytes']) is not int or value['bytes']!=len(raw) or len(raw)>MAX_FILE_BYTES or digest(raw)!=value['sha256']: + fail('Adopted text bytes differ from their exact identity.') + if type(value['actor_id']) is not str or len(value['actor_id'])!=32 or any(c not in '0123456789abcdef' for c in value['actor_id']):fail('Invalid historical adoption actor.') + _date(value['adopted_at']) + + +def decode_records(records): + if type(records) is not list:fail('Working-file inventory must be a list.') + owned=[];seen=set() + for envelope in records: + if type(envelope) is not dict:fail('Invalid working-file envelope.') + if envelope.get('family')!=FAMILY:continue + closed(envelope,{'family','id','document'},'working-file envelope') + doc=envelope['document'] + if type(doc) is not dict or doc.get('format') not in (FORMAT,DELETION_FORMAT):fail('Unsupported working-file format.','unsupported',409) + closed(doc,{'format','project_id','kind','path','current','history','origin'},'working-file document') + expected=file_id(doc['project_id'],doc['kind'],doc['path']) + if envelope['id']!=expected or expected in seen:fail('Duplicate or mismatched working-file identity.') + seen.add(expected) + if type(doc['history']) is not list or not doc['history']:fail('Working-file history is missing.') + previous=None;previous_deleted=False;has_deletion=False + for revision,value in enumerate(doc['history'],1): + _version(value,allow_deleted=doc['format']==DELETION_FORMAT) + deleted=value.get('deleted') is True + if deleted and (doc['kind']!='note' or revision==1 or previous_deleted):fail('Only an adopted, present note can acquire a deleted revision.') + if value['revision']!=revision or previous is not None and _date(value['adopted_at'])MAX_FILES for count in counts.values()):fail('Project tracks more than 1,000 working files.','too_large',413) + _validate_forks(owned,records) + return owned + + +def _result(envelope,changed): + doc=envelope['document'];value=doc['current'] + result=dict(id=envelope['id'],project_id=doc['project_id'],kind=doc['kind'],path=doc['path'],changed=changed, + **{key:value[key] for key in ('revision','sha256','bytes','adopted_at')}) + if value.get('deleted') is True:result['deleted']=True + return result + + +def validate_operation_result(operation,result,records=None): + if operation not in ('files.adopt','files.adopt_deletion'):return False + deleted=operation=='files.adopt_deletion' + closed(result,RESULT_FIELDS|{'deleted'} if deleted else RESULT_FIELDS,'adoption result') + if result['id']!=file_id(result['project_id'],result['kind'],result['path']) or type(result['changed']) is not bool:fail('Invalid adoption result identity.') + if deleted: + if result['deleted'] is not True or result['kind']!='note' or result['sha256'] is not None or result['bytes']!=0 or result['changed'] is not True or type(result['revision']) is not int or result['revision']<2: + fail('Invalid deleted note result.') + else:sha(result['sha256']) + _date(result['adopted_at']) + if type(result['revision']) is not int or not 1<=result['revision']<=2**53-1 or type(result['bytes']) is not int or not 0<=result['bytes']<=MAX_FILE_BYTES:fail('Invalid adoption result bounds.') + if records is not None: + owned=decode_records(records) + envelope=next((r for r in owned if r['id']==result['id']),None) + if envelope is None or not any((value.get('deleted') is True)==deleted and all(value[key]==result[key] for key in ('revision','sha256','bytes','adopted_at')) for value in envelope['document']['history']): + fail('Adoption result is not an exact retained working-file revision.') + return True + + +class _WorkingError(Exception): + def __init__(self,state):self.state=state + + +def _missing(root,parts): + """Confirm absence beneath the actual root, never through an unsafe alias.""" + parents=[os.dup(root)];missing=None + try: + for part in parts[:-1]: + try:child=os.open(part,DIR_FLAGS,dir_fd=parents[-1]) + except FileNotFoundError: + missing=part;break + parents.append(child) + if missing is None: + try: + entry=os.stat(parts[-1],dir_fd=parents[-1],follow_symlinks=False) + if not stat.S_ISREG(entry.st_mode) or entry.st_nlink!=1:raise _WorkingError('unsafe_path') + except FileNotFoundError:missing=parts[-1] + for index in range(1,len(parents)): + entry=os.stat(parts[index-1],dir_fd=parents[index-1],follow_symlinks=False) + if not os.path.samestat(entry,os.fstat(parents[index])):raise _WorkingError('unstable') + if missing is not None: + try:os.stat(missing,dir_fd=parents[-1],follow_symlinks=False) + except FileNotFoundError:return True + raise _WorkingError('unstable') + return False + except OSError as error: + state='unsafe_path' if error.errno in (errno.ELOOP,errno.ENOTDIR) else 'unstable' if error.errno==errno.ENOENT else 'unreadable' + raise _WorkingError(state) from None + finally: + for descriptor in reversed(parents):os.close(descriptor) + + +def _read(root,parts): + parent=os.dup(root);descriptor=None + try: + for part in parts[:-1]: + child=os.open(part,DIR_FLAGS,dir_fd=parent);os.close(parent);parent=child + descriptor=os.open(parts[-1],os.O_RDONLY|os.O_NOFOLLOW|os.O_CLOEXEC|os.O_NONBLOCK,dir_fd=parent) + before=os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode) or before.st_nlink!=1:raise _WorkingError('unsafe_path') + if before.st_size>MAX_FILE_BYTES:raise _WorkingError('too_large') + chunks=[];total=0 + while True: + block=os.read(descriptor,min(65536,MAX_FILE_BYTES+1-total)) + if not block:break + chunks.append(block);total+=len(block) + if total>MAX_FILE_BYTES:raise _WorkingError('too_large') + after=os.fstat(descriptor);entry=os.stat(parts[-1],dir_fd=parent,follow_symlinks=False) + fields=lambda value:(value.st_dev,value.st_ino,value.st_mode,value.st_nlink,value.st_size,value.st_mtime_ns,value.st_ctime_ns) + if fields(before)!=fields(after) or fields(after)!=fields(entry):raise _WorkingError('unstable') + current=os.dup(root) + try: + for part in parts[:-1]: + child=os.open(part,DIR_FLAGS,dir_fd=current);os.close(current);current=child + if (os.fstat(current).st_dev,os.fstat(current).st_ino)!=(os.fstat(parent).st_dev,os.fstat(parent).st_ino): + raise _WorkingError('unstable') + finally:os.close(current) + raw=b''.join(chunks) + try:text=raw.decode('utf-8') + except UnicodeDecodeError:raise _WorkingError('invalid_utf8') from None + return text,digest(raw),len(raw) + except OSError as error: + state='missing' if error.errno==errno.ENOENT else 'unsafe_path' if error.errno in (errno.ELOOP,errno.ENOTDIR) else 'unreadable' + raise _WorkingError(state) from None + finally: + if descriptor is not None:os.close(descriptor) + os.close(parent) + + +class ProjectFiles: + def __init__(self,session): + self.session,self.access,self.db,self.store=session,session.access,session.db,session.store + + def inspect(self): + with self.access.lock,self.session.lock,self.db.lock: + self.access.require('inspect','files.inspect');snapshot=self.session.check() + owned=[r for r in decode_records(snapshot['records']) if r['document']['project_id']==snapshot['project_id']];items=[] + with self.store.root_directory() as root: + for envelope in sorted(owned,key=lambda r:r['document']['path']): + doc=envelope['document'];value=doc['current'] + observation=dict(id=envelope['id'],kind=doc['kind'],path=doc['path'],revision=value['revision'], + expected_sha256=value['sha256'],expected_bytes=value['bytes'],observed_sha256=None,observed_bytes=None,state='missing',reason=None) + try: + parts=_path(doc['kind'],doc['path']) + if value.get('deleted') is True and _missing(root,parts): + observation['state']='deleted' + else: + if value.get('deleted') is True:observation['state']='changed' + _,observed,size=_read(root,parts) + observation.update(observed_sha256=observed,observed_bytes=size,state='unchanged' if observed==value['sha256'] else 'changed') + except _WorkingError as error: + if value.get('deleted') is True: + observation.update(state='changed' if error.state in ('invalid_utf8','too_large') else 'invalid',reason=error.state) + else:observation.update(state='missing' if error.state=='missing' else 'invalid',reason=error.state) + items.append(observation) + self.session.check() + return dict(items=items,project_id=snapshot['project_id'],checkpoint=snapshot['checkpoint']) + + def read(self,record_id,revision=None): + with self.access.lock,self.session.lock,self.db.lock: + self.access.require('inspect','files.read',record_id);snapshot=self.session.check() + envelope=next((r for r in decode_records(snapshot['records']) if r['id']==record_id),None) + if envelope is None:fail('Adopted working file is unavailable.','unavailable',404) + values=envelope['document']['history'] + if revision is None:value=values[-1] + elif type(revision) is int and 1<=revision<=len(values):value=values[revision-1] + else:fail('Adopted working-file revision is unavailable.','unavailable',404) + return dict(id=record_id,project_id=envelope['document']['project_id'],kind=envelope['document']['kind'],path=envelope['document']['path'],version=copy.deepcopy(value)) + + def history(self,record_id): + with self.access.lock,self.session.lock,self.db.lock: + self.access.require('inspect','files.history',record_id);snapshot=self.session.check() + envelope=next((r for r in decode_records(snapshot['records']) if r['id']==record_id),None) + if envelope is None:fail('Adopted working file is unavailable.','unavailable',404) + return copy.deepcopy(envelope['document']['history']) + + def require_clean(self): + """Save As must not silently discard unadopted or missing source files.""" + report=self.inspect() + if any(item['state'] not in ('unchanged','deleted') for item in report['items']): + error=WorkbenchError('Adopt or restore changed, missing or invalid working files before Save As.','working_files_changed',409) + error.report=report + raise error + return report + + @staticmethod + def materialize_captured(records,project_id,root_fd): + return materialize_captured(records,project_id,root_fd) + + def adopt(self,body,expected_checkpoint,key): + closed(body,{'kind','path','expected_revision','expected_sha256'},'working-file adoption') + parts=_path(body['kind'],body['path']);project_id=self.session.snapshot['project_id'];record_id=file_id(project_id,body['kind'],body['path']);sha(body['expected_sha256']) + if type(body['expected_revision']) is not int or not 0<=body['expected_revision']<=2**53-1:fail('Invalid expected adoption revision.') + pending=[] + with self.access.lock: + self.access.require('edit','files.adopt',record_id) + def invoke(): + snapshot=self.session.snapshot;owned=[r for r in decode_records(snapshot['records']) if r['document']['project_id']==project_id] + prior=next((r for r in owned if r['id']==record_id),None) + revision=prior['document']['current']['revision'] if prior else 0 + if revision!=body['expected_revision']:fail('Working-file adoption revision changed.','conflict',409) + if prior is None and len(owned)>=MAX_FILES:fail('Project already tracks 1,000 working files.','too_large',413) + with self.store.root_directory() as root: + try:text,observed,size=_read(root,parts) + except _WorkingError as error:fail('Working file is '+error.state+'.','unavailable' if error.state=='missing' else 'invalid',404 if error.state=='missing' else 400) + if observed!=body['expected_sha256']:fail('Working file changed after review.','conflict',409) + if prior is not None and observed==prior['document']['current']['sha256']:return _result(prior,False) + actor=self.access.require('edit','files.adopt',record_id) + value=dict(revision=revision+1,sha256=observed,bytes=size,text=text,adopted_at=timestamp(),actor_id=actor.actor_id) + history=copy.deepcopy(prior['document']['history']) if prior else [];history.append(value) + envelope=dict(family=FAMILY,id=record_id,document=dict(format=prior['document']['format'] if prior else FORMAT,project_id=project_id,kind=body['kind'],path=body['path'],current=copy.deepcopy(value),history=history,origin=copy.deepcopy(prior['document']['origin']) if prior else None)) + decode_records([r for r in snapshot['records'] if not (r['family']==FAMILY and r['id']==record_id)]+[envelope]);pending.append(envelope) + return _result(envelope,True) + return self.session.mutate('files.adopt',record_id,body,expected_checkpoint,key,invoke, + lambda result,before:dict(records=copy.deepcopy(pending),result=copy.deepcopy(result))) + + def adopt_deletion(self,body,expected_checkpoint,key): + closed(body,{'path','expected_revision','expected_sha256'},'note deletion adoption') + parts=_path('note',body['path']);sha(body['expected_sha256']) + if type(body['expected_revision']) is not int or not 1<=body['expected_revision']<=2**53-1:fail('Invalid expected note revision.') + project_id=self.session.snapshot['project_id'];record_id=file_id(project_id,'note',body['path']);pending=[] + with self.access.lock: + self.access.require('edit','files.adopt_deletion',record_id) + def invoke(): + snapshot=self.session.snapshot + prior=next((r for r in decode_records(snapshot['records']) if r['id']==record_id),None) + if prior is None:fail('Only an already adopted note can acquire a deletion.','unavailable',404) + current=prior['document']['current'] + if current['revision']!=body['expected_revision'] or current['sha256']!=body['expected_sha256'] or current.get('deleted') is True: + fail('Canonical note revision or digest changed.','conflict',409) + with self.store.root_directory() as root: + try:missing=_missing(root,parts) + except _WorkingError as error:fail('Note path is '+error.state+'.','invalid',400) + if not missing:fail('The reviewed note still exists; deletion cannot be adopted.','conflict',409) + actor=self.access.require('edit','files.adopt_deletion',record_id) + value=dict(revision=current['revision']+1,deleted=True,sha256=None,bytes=0,text=None, + adopted_at=timestamp(),actor_id=actor.actor_id) + envelope=copy.deepcopy(prior);doc=envelope['document'] + doc['format']=DELETION_FORMAT;doc['history'].append(value);doc['current']=copy.deepcopy(value) + decode_records([r for r in snapshot['records'] if not (r['family']==FAMILY and r['id']==record_id)]+[envelope]);pending.append(envelope) + return _result(envelope,True) + return self.session.mutate('files.adopt_deletion',record_id,body,expected_checkpoint,key,invoke, + lambda result,before:dict(records=copy.deepcopy(pending),result=copy.deepcopy(result))) + + +def _validate_forks(owned,records): + indexed={r['id']:r for r in owned} + overlays=[r['document'] for r in records if r.get('family')=='project_fork'] + if overlays: + from project_fork import decode_fork + decode_fork(records) + for envelope in owned: + doc=envelope['document'];origin=doc['origin'] + if origin is None:continue + closed(origin,{'project_id','checkpoint','id','revision'},'working-file fork origin') + _project_id(origin['project_id']);sha(origin['checkpoint']);sha(origin['id']) + if type(origin['revision']) is not int or not 1<=origin['revision']<=2**53-1:fail('Invalid original working-file revision.') + original=indexed.get(origin['id']) + if original is None:fail('Working-file fork original is unavailable.') + source=original['document'] + if source['project_id']!=origin['project_id'] or source['kind']!=doc['kind'] or source['path']!=doc['path'] or source['current']['revision']!=origin['revision']: + fail('Working-file fork origin differs from its exact archived source.') + mapping=dict(family=FAMILY,source_id=origin['id'],source_revision=origin['revision'],destination_id=envelope['id']) + matches=[o for o in overlays if o['source']==dict(project_id=origin['project_id'],checkpoint=origin['checkpoint']) and o['destination_project_id']==doc['project_id'] and mapping in o['mapping']] + if len(matches)!=1 or doc['history'][:origin['revision']]!=source['history']: + fail('Working-file fork requires its exact archived history and origin mapping.') + + +def fork_records(records,source_project_id,source_checkpoint,destination_project_id): + """Independent file identity; preserve actual historical adoptions exactly.""" + _project_id(source_project_id);_project_id(destination_project_id);sha(source_checkpoint) + if source_project_id==destination_project_id:fail('An independent working-file fork needs a new project.') + decoded=decode_records(records) + from project_fork import active_records + active={(r['family'],r['id']) for r in active_records(records,project_id=source_project_id)} + owned=[r for r in decoded if (r['family'],r['id']) in active] + made=[];mapping=[] + for envelope in owned: + doc=envelope['document'] + if doc['project_id']!=source_project_id:fail('Current working-file scope differs from the selected project.') + new_id=file_id(destination_project_id,doc['kind'],doc['path']) + if any(r['family']==FAMILY and r['id']==new_id for r in records):fail('Working-file destination identity already exists.') + fresh=copy.deepcopy(envelope);fresh['id']=new_id + fresh['document'].update(project_id=destination_project_id,origin=dict(project_id=source_project_id,checkpoint=source_checkpoint,id=envelope['id'],revision=doc['current']['revision'])) + made.append(fresh);mapping.append(dict(family=FAMILY,source_id=envelope['id'],source_revision=doc['current']['revision'],destination_id=new_id)) + made.sort(key=lambda r:r['id']);mapping.sort(key=lambda m:(m['family'],m['source_id'],m['source_revision'],m['destination_id'])) + return dict(records=made,mapping=mapping) + + +def materialize_captured(records,project_id,root_fd): + """Write exact declared text only into the caller's new staging capability. + + The fork owner verifies destination non-overlap and owns all staging cleanup + and publication. O_EXCL never replaces an existing working file. A partial + failed staging tree is not a published fork and must not be acknowledged. + """ + _project_id(project_id) + if type(root_fd) is not int or not stat.S_ISDIR(os.fstat(root_fd).st_mode):fail('A staging directory capability is required.') + owned=[r for r in decode_records(records) if r['document']['project_id']==project_id] + copied=[] + try: + for envelope in sorted(owned,key=lambda r:r['document']['path']): + doc=envelope['document'];value=doc['current'];parts=_path(doc['kind'],doc['path']) + if value.get('deleted') is True:continue + parent=os.dup(root_fd);descriptor=None + try: + for part in parts[:-1]: + try:os.mkdir(part,0o700,dir_fd=parent);os.fsync(parent) + except FileExistsError:pass + child=os.open(part,DIR_FLAGS,dir_fd=parent);os.close(parent);parent=child + descriptor=os.open(parts[-1],os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW|os.O_CLOEXEC,0o600,dir_fd=parent) + captured=os.fstat(descriptor) + if not stat.S_ISREG(captured.st_mode) or captured.st_nlink!=1:fail('Unsafe staging working file.') + raw=value['text'].encode('utf-8');remaining=memoryview(raw) + while remaining: + count=os.write(descriptor,remaining) + if count<=0:raise OSError(errno.EIO,'Working-file staging write failed') + remaining=remaining[count:] + os.fsync(descriptor) + entry=os.stat(parts[-1],dir_fd=parent,follow_symlinks=False) + after=os.fstat(descriptor) + if (entry.st_dev,entry.st_ino,entry.st_nlink,entry.st_size)!=(captured.st_dev,captured.st_ino,1,len(raw)) or after.st_size!=len(raw):fail('Working-file staging entry changed.','stale_project',409) + check=os.dup(root_fd) + try: + for part in parts[:-1]: + child=os.open(part,DIR_FLAGS,dir_fd=check);os.close(check);check=child + if (os.fstat(check).st_dev,os.fstat(check).st_ino)!=(os.fstat(parent).st_dev,os.fstat(parent).st_ino):fail('Working-file staging directory changed.','stale_project',409) + finally:os.close(check) + os.fsync(parent) + copied.append(dict(id=envelope['id'],path=doc['path'],sha256=value['sha256'],bytes=value['bytes'])) + finally: + if descriptor is not None:os.close(descriptor) + os.close(parent) + os.fsync(root_fd) + except OSError: + fail('Working-file staging failed; no complete fork was published.','storage',500) + return copied diff --git a/project_fork.py b/project_fork.py new file mode 100644 index 00000000..ff75fec4 --- /dev/null +++ b/project_fork.py @@ -0,0 +1,424 @@ +"""Independent fork publication and its explicit inert-origin overlay. + +Domain owners construct new editable values and their revision-specific origin +mappings. This owner never rewrites domain documents. It preserves source +records, verifies declared payload custody, and atomically publishes a complete +new directory. Runtime quiescence and current authorization belong to the caller. +""" +import copy +import errno +import hashlib +import os +import re +import stat +import uuid + +from project_artifacts import ProjectArtifacts, validate_declaration +from project_store import (ProjectStore, ProjectStoreError, REQUEST_KEY, anchored_directory, + DIR_FLAGS, _encode, _identifier, _physical, _relative_parts, + _rename_noreplace, _walk) + +FAMILY = 'project_fork' +FORMAT = 'project_fork_v1' +# Immutable scientific evidence stays origin-scoped and inspectable. Only these +# formerly active values/receipts become inactive through the fork overlay. +ARCHIVE_FAMILIES = frozenset(('workbench_text', 'workbench_record', 'workbench_response', + 'workbench_preference', 'saved_selection', 'saved_search', 'model_definition', + 'pipeline_graph', 'recipe_definition', 'project_file', 'project_operation', FAMILY)) +ID = re.compile(r'^[a-f0-9]{32}$') +SHA = re.compile(r'^[a-f0-9]{64}$') + + +class ProjectForkError(ProjectStoreError): + pass + + +def _fail(message, code='invalid', status=400): + raise ProjectForkError(message, code, status) + + +def _shape(value, fields, label): + if type(value) is not dict or set(value) != set(fields): + _fail('Invalid fork ' + label + ' fields.') + + +def _uuid(value): + if type(value) is not str or not ID.fullmatch(value): + _fail('Fork project identity must be a UUID.') + + +def _sha(value): + if type(value) is not str or not SHA.fullmatch(value): + _fail('Fork checkpoint must be a SHA256 digest.') + + +def _input(source, directory, name, key): + return dict(source=source, directory=directory, name=name, request_key=key) + + +def _digest(value): + return hashlib.sha256(_encode(value)).hexdigest() + + +def _locator(value): + _shape(value, ('family', 'id'), 'archive locator') + _identifier(value['family'], 'Fork record family') + _identifier(value['id'], 'Fork record ID') + return value['family'], value['id'] + + +def _mapping_key(value): + return value['family'], value['source_id'], value['source_revision'] or 0, value['destination_id'] + + +def _decode_document(value, identity, indexed): + _shape(value, ('format', 'source', 'destination_project_id', 'request_key', 'request_sha256', + 'directory', 'name', 'mapping', 'archived', 'artifacts'), 'origin overlay') + if value['format'] != FORMAT: + _fail('Unsupported fork overlay format.', 'unsupported', 422) + _shape(value['source'], ('project_id', 'checkpoint'), 'source identity') + _uuid(value['source']['project_id']) + _sha(value['source']['checkpoint']) + _uuid(value['destination_project_id']) + if identity != value['destination_project_id'] or identity == value['source']['project_id']: + _fail('Fork source and destination identities must be independent.') + if type(value['request_key']) is not str or not REQUEST_KEY.fullmatch(value['request_key']): + _fail('Invalid fork request key.') + _sha(value['request_sha256']) + _relative_parts(value['directory']) + if type(value['name']) is not str or not value['name'].strip() or len(value['name']) > 200: + _fail('Invalid fork display name.') + if value['request_sha256'] != _digest(_input(value['source'], value['directory'], value['name'], value['request_key'])): + _fail('Fork request receipt differs from its exact input.') + if type(value['archived']) is not list: + _fail('Fork archive inventory must be a list.') + archived = [_locator(item) for item in value['archived']] + if archived != sorted(set(archived)) or any(key not in indexed for key in archived): + _fail('Fork archive must be sorted, unique and refer to retained records.') + if (FAMILY, identity) in archived: + _fail('Current fork overlay cannot archive itself.') + archived = set(archived) + if type(value['mapping']) is not list: + _fail('Fork mappings must be a list.') + sources, destinations, keys = set(), set(), [] + for item in value['mapping']: + _shape(item, ('family', 'source_id', 'source_revision', 'destination_id'), 'editable mapping') + _identifier(item['family'], 'Fork mapping family') + _identifier(item['source_id'], 'Fork source record ID') + _identifier(item['destination_id'], 'Fork destination record ID') + revision = item['source_revision'] + if revision is not None and (type(revision) is not int or revision < 1): + _fail('Fork source revision must be positive or null.') + source = (item['family'], item['source_id']) + destination_family = 'workbench_record' if item['family'] == 'workbench_text' else item['family'] + destination = (destination_family, item['destination_id']) + if (source not in archived or destination not in indexed or destination in archived or + item['source_id'] == item['destination_id'] or (source, revision) in sources or destination in destinations): + _fail('Fork mapping must bind an archived source revision to a unique new current record.') + sources.add((source, revision)) + destinations.add(destination) + keys.append(_mapping_key(item)) + if keys != sorted(keys): + _fail('Fork mappings must be sorted.') + _shape(value['artifacts'], ('copied', 'missing_regenerable'), 'artifact completeness') + listed = set() + for field in ('copied', 'missing_regenerable'): + items = value['artifacts'][field] + if type(items) is not list: + _fail('Fork artifact completeness must use lists.') + for digest in items: + _sha(digest) + if items != sorted(set(items)) or listed.intersection(items): + _fail('Fork artifact completeness must be disjoint, sorted and unique.') + if any(('project_artifact', digest) not in indexed for digest in items): + _fail('Fork payload completeness has no intrinsic declaration.') + listed.update(items) + return archived + + +def decode_fork(records, *, project_id=None): + """Decode the one current overlay; originals remain independently validated. + + Pure owner decoders consume all records for historical closure. Projection + and receipt owners use active_records only when constructing current state. + """ + if type(records) is not list: + _fail('Fork records must be a list.') + indexed, overlays = {}, {} + for item in records: + _shape(item, ('family', 'id', 'document'), 'record envelope') + _identifier(item['family'], 'Fork record family') + _identifier(item['id'], 'Fork record ID') + key = item['family'], item['id'] + if key in indexed: + _fail('Duplicate fork record identity.') + indexed[key] = item + if item['family'] == FAMILY: + overlays[key] = item['document'] + if project_id is not None: + _uuid(project_id) + if not overlays: + return None + archives = {key: _decode_document(doc, key[1], indexed) for key, doc in overlays.items()} + archived_overlays = {key for values in archives.values() for key in values if key[0] == FAMILY} + candidates = set(overlays) - archived_overlays + if len(candidates) != 1: + _fail('Fork history must have exactly one current origin overlay.') + current = next(iter(candidates)) + if project_id is not None and current[1] != project_id: + _fail('Fork overlay belongs to a different current project.') + archive = archives[current] + for key, previous in archives.items(): + if key != current and (key not in archive or not previous <= archive): + _fail('Current fork must preserve all prior archived dispositions.') + return copy.deepcopy(overlays[current]) + + +def active_records(records, *, project_id=None): + overlay = decode_fork(records, project_id=project_id) + archived = set() if overlay is None else {(item['family'], item['id']) for item in overlay['archived']} + return [copy.deepcopy(item) for item in records if (item['family'], item['id']) not in archived] + + +def _inside(candidate_fd, ancestor_fd): + """Compare physical ancestry, including directory aliases; never string-prefix.""" + current = os.dup(candidate_fd) + try: + expected = _physical(ancestor_fd) + while True: + if _physical(current) == expected: + return True + parent = os.open('..', DIR_FLAGS, dir_fd=current) + if _physical(parent) == _physical(current): + os.close(parent) + return False + os.close(current) + current = parent + finally: + os.close(current) + + +def _declarations(records): + result = {} + for item in records: + if item['family'] == 'project_artifact': + value = validate_declaration(item['document']) + if item['id'] != value['sha256'] or item['id'] in result: + _fail('Fork artifact declaration identity differs or repeats.') + result[item['id']] = value + return result + + +def _artifact_plan(items, declarations): + if type(items) is not list: + _fail('Fork requires an explicit payload preservation plan.') + plan = {} + for item in items: + _shape(item, ('declaration', 'preservation'), 'payload preservation edge') + value = validate_declaration(item['declaration']) + if item['preservation'] not in ('unique', 'regenerable') or declarations.get(value['sha256']) != value: + _fail('Fork payload policy refers to an undeclared or changed artifact.') + previous = plan.get(value['sha256']) + if previous is None or item['preservation'] == 'unique': + plan[value['sha256']] = item['preservation'] + if set(plan) != set(declarations): + _fail('Every retained artifact declaration needs an explicit fork preservation disposition.') + return plan + + +def _source_current(store, source): + current = store.snapshot() + if current['project_id'] != source['project_id'] or current['checkpoint'] != source['checkpoint']: + _fail('Source project changed during fork preparation.', 'stale', 409) + + +def _result(snapshot, overlay, reconciled): + return dict(snapshot=copy.deepcopy(snapshot), fork=copy.deepcopy(overlay), reconciled=reconciled) + + +def _reconcile_existing(source_store, source, trusted_root_fd, relative, root_path, + expected_input, request_key, digest, validate_candidate): + with ProjectStore.open_at(trusted_root_fd, relative, root_path=root_path) as destination: + snapshot = destination.snapshot() + overlay = decode_fork(snapshot['records'], project_id=snapshot['project_id']) + if (overlay is None or _input(overlay['source'], overlay['directory'], overlay['name'], overlay['request_key']) != expected_input + or overlay['request_sha256'] != digest): + _fail('Existing destination does not belong to this exact fork request.', 'conflict', 409) + if destination.reconcile(request_key, digest) is None: + _fail('Existing destination lacks the canonical fork publication receipt.', 'conflict', 409) + validate_candidate(copy.deepcopy(snapshot['records']), snapshot['project_id']) + declarations = _declarations(snapshot['records']) + with ProjectArtifacts(destination) as artifacts: + for identity in overlay['artifacts']['copied']: + if artifacts.status(declarations[identity])['state'] != 'verified': + _fail('Previously copied fork payloads are missing or corrupt.', 'unavailable', 409) + _source_current(source_store, source) + # Rereading reconciles identity; this barrier resolves a lost parent + # fsync acknowledgement without guessing durability from mere presence. + destination.save(snapshot['checkpoint']) + return _result(destination.snapshot(), overlay, True) + + +def publish_fork(source_store, source_artifacts, trusted_root_fd, relative, *, root_path, + name, expected_checkpoint, request_key, quiescent, build_candidate, + validate_candidate): + """Publish a complete independent directory while preserving the source. + + Caller must hold its lifecycle/authorization guard and prove quiescence. + build_candidate(source_snapshot, destination_project_id) is one pure owned + composition callback returning exactly records/mapping/archived/artifacts. + It preserves every original envelope and appends fresh domain-constructed + values. validate_candidate(records, project_id) owns semantic closure. + No temporary or final destination is deleted after failed/unknown outcomes. + """ + if quiescent is not True: + _fail('Fork requires proven quiescence under the workspace lifecycle guard.', 'busy', 409) + if not callable(build_candidate) or not callable(validate_candidate): + _fail('Fork requires pure domain construction and complete validation.', 'unavailable', 503) + if type(request_key) is not str or not REQUEST_KEY.fullmatch(request_key): + _fail('Invalid fork request key.') + _sha(expected_checkpoint) + if type(name) is not str or not name.strip() or len(name) > 200: + _fail('Fork name must contain 1–200 characters.') + _encode(name) + parts = _relative_parts(relative) + snapshot = source_store.snapshot() + if snapshot['checkpoint'] != expected_checkpoint: + _fail('Expected source checkpoint changed.', 'stale', 409) + validate_candidate(copy.deepcopy(snapshot['records']), snapshot['project_id']) + source = dict(project_id=snapshot['project_id'], checkpoint=snapshot['checkpoint']) + expected_input = _input(source, relative, name, request_key) + digest = _digest(expected_input) + source_root = staged = artifacts = None + staging_relative = None + overlay = committed = None + published = False + try: + with source_store.root_directory() as borrowed: + source_root = os.dup(borrowed) + with anchored_directory(trusted_root_fd, '/'.join(parts[:-1]), root_path=root_path) as parent: + if _inside(parent, source_root): + _fail('Fork destination cannot overlap the source project.') + try: + existing = os.stat(parts[-1], dir_fd=parent, follow_symlinks=False) + except FileNotFoundError: + existing = None + if existing is not None: + if not stat.S_ISDIR(existing.st_mode): + _fail('Fork destination is an existing or unsafe filesystem object.', 'conflict', 409) + selected = os.open(parts[-1], DIR_FLAGS, dir_fd=parent) + try: + if _inside(source_root, selected) or _inside(selected, source_root): + _fail('Fork destination aliases or overlaps the source project.') + finally: + os.close(selected) + return _reconcile_existing(source_store, source, trusted_root_fd, relative, root_path, + expected_input, request_key, digest, validate_candidate) + staging_name = '.tuldok-fork-' + uuid.uuid4().hex + staging_relative = '/'.join([*parts[:-1], staging_name]) + staged = ProjectStore.create_at(trusted_root_fd, staging_relative, name, + 'fork-stage.' + hashlib.sha256(request_key.encode()).hexdigest(), digest, root_path=root_path) + destination_id = staged.snapshot()['project_id'] + if destination_id == source['project_id']: + _fail('Fork destination identity must be new.') + candidate = build_candidate(copy.deepcopy(snapshot), destination_id) + _shape(candidate, ('records', 'mapping', 'archived', 'artifacts'), 'owned candidate') + if type(candidate['records']) is not list: + _fail('Fork candidate records must be a list.') + records = copy.deepcopy(candidate['records']) + indexed = {} + for item in records: + _shape(item, ('family', 'id', 'document'), 'candidate envelope') + _identifier(item['family'], 'Fork candidate family') + _identifier(item['id'], 'Fork candidate record ID') + identity = item['family'], item['id'] + if identity in indexed: + _fail('Fork candidate repeats a record identity.') + indexed[identity] = item + source_index = {(item['family'], item['id']): item for item in snapshot['records']} + for identity, original in source_index.items(): + if identity not in indexed or _encode(indexed[identity]) != _encode(original): + _fail('Fork must preserve every original canonical envelope exactly.') + archived = [_locator(item) for item in candidate['archived']] + if not set(archived) <= set(source_index): + _fail('Fork may archive only retained source records.') + required = {identity for identity in source_index if identity[0] in ARCHIVE_FAMILIES} + previous = decode_fork(snapshot['records'], project_id=source['project_id']) + if previous is not None: + required.update(_locator(item) for item in previous['archived']) + if not required <= set(archived): + _fail('Fork must archive every source editable value and operation receipt.') + declarations = _declarations(records) + if declarations != _declarations(snapshot['records']): + _fail('Fork must retain the source payload inventory without regenerating bytes.') + plan = _artifact_plan(candidate['artifacts'], declarations) + copied, missing = [], [] + for identity in sorted(plan): + status = source_artifacts.status(declarations[identity])['state'] + if status == 'corrupt': + _fail('Source fork payload is corrupt.', 'invalid', 400) + if status == 'missing': + if plan[identity] == 'unique': + _fail('Irreplaceable source bytes are missing; complete fork is unavailable.', 'unavailable', 409) + missing.append(identity) + elif status == 'verified': + copied.append(identity) + else: + _fail('Fork requires verified payload custody.', 'unavailable', 409) + overlay = dict(format=FORMAT, source=source, destination_project_id=destination_id, + request_key=request_key, request_sha256=digest, directory=relative, name=name, + mapping=copy.deepcopy(candidate['mapping']), archived=copy.deepcopy(candidate['archived']), + artifacts=dict(copied=copied, missing_regenerable=missing)) + records.append(dict(family=FAMILY, id=destination_id, document=overlay)) + decode_fork(records, project_id=destination_id) + validate_candidate(copy.deepcopy(records), destination_id) + _source_current(source_store, source) + artifacts = ProjectArtifacts(staged) + for identity in copied: + value = declarations[identity] + with source_artifacts.open_verified(value, maximum_bytes=value['bytes']) as source_fd: + artifacts.publish_fd(source_fd, value, maximum_bytes=value['bytes']) + # Ordinary authored files are explicitly adopted text. Only a new + # task-owned fork stage materializes them; Open never calls this. + from project_files import materialize_captured + with staged.root_directory() as destination_root: + materialize_captured(records, destination_id, destination_root) + _source_current(source_store, source) + validate_candidate(copy.deepcopy(records), destination_id) + committed = staged.commit(records, staged.snapshot()['checkpoint'], request_key, digest) + staged.save(committed['checkpoint']) + _source_current(source_store, source) + # All named content and its one canonical pointer are durable. The + # destination appears in one atomic no-overwrite directory rename. + try: + _rename_noreplace(parent, staging_name, parts[-1]) + except FileExistsError as error: + raise ProjectForkError('Fork destination appeared during publication; it was not overwritten.', 'conflict', 409) from error + published = True + os.fsync(parent) + _source_current(source_store, source) + return _result(committed, overlay, False) + except BaseException as error: + if published: + failure = ProjectForkError('Fork destination was published but acknowledgement is uncertain; retry this exact request.', + 'unknown', 503) + if committed is not None: + failure.receipt = dict(committed=True, selected=False, project_id=committed['project_id'], + checkpoint=committed['checkpoint'], request_key=request_key, directory=relative) + raise failure from error + if isinstance(error, OSError): + code = 'conflict' if error.errno == errno.EEXIST else 'unavailable' + failure = ProjectForkError('Fork publication is unavailable; retained staging has not been selected.', code, + 409 if code == 'conflict' else 503) + failure.staging_directory = staging_relative + raise failure from error + if staging_relative is not None: + error.staging_directory = staging_relative + raise + finally: + if artifacts is not None: + artifacts.close() + if staged is not None: + staged.close() + if source_root is not None: + os.close(source_root) diff --git a/project_fork_composition.py b/project_fork_composition.py new file mode 100644 index 00000000..6c72643f --- /dev/null +++ b/project_fork_composition.py @@ -0,0 +1,170 @@ +"""Pure fixed-owner composition for an independent project fork. + +The lifecycle owner supplies a validated source checkpoint, creation fact and +fresh ID allocator. This module never opens files, SQL, runtimes or permissions. +The publication owner adds the fork overlay and verifies actual payload custody. +""" +import copy + +from project_artifacts import validate_declaration +from project_data_fork import fork_records as fork_data +from project_material_fork import fork_records as fork_material, OWNED as MATERIAL_EDITABLE +from project_files import fork_records as fork_files +from project_fork import ARCHIVE_FAMILIES, ProjectForkError, decode_fork, ID, _uuid, _sha +from project_store import _records + + +def _fail(message, code='invalid', status=400): + raise ProjectForkError(message, code, status) + + +def artifact_plan(records): + """Give every intrinsic declaration a conservative explicit disposition. + + Runtime files alone can be regenerable. Any unique owner edge to the same + bytes wins, including archived sources and past producer wire observations. + Unclassified declarations remain unique; no filename/path guesses are used. + """ + try: + declarations = {} + for item in records: + if item['family'] != 'project_artifact': continue + declaration = validate_declaration(item['document']) + sha = declaration['sha256'] + if item['id'] != sha or sha in declarations: + _fail('Fork artifact declaration identity differs or repeats.') + declarations[sha] = declaration + unique, regenerable, missing = set(), set(), [] + + def mark(sha, record, role, name, size=None, *, policy='unique'): + _sha(sha) + declaration = declarations.get(sha) + if declaration is None: + if policy == 'unique': + missing.append(dict(family=record['family'],id=record['id'],role=role,name=name, + sha256=sha,bytes=size)) + return + if size is not None and declaration['bytes'] != size: + _fail('Fork owner byte count differs from its intrinsic declaration.') + (unique if policy == 'unique' else regenerable).add(sha) + + # Finite Workbench source fields. Both original and oriented image bytes + # are historical identity; re-encoding does not reproduce their hashes. + for record in records: + family, doc = record['family'], record['document'] + edges = [] + if family == 'workbench_record': + source = doc['source'] + if source['kind'] == 'image': edges = [source['original'],source['oriented']] + elif source['kind'] != 'text': edges = [source['asset']] + elif family == 'dataset_release': edges = [doc['asset']] + for edge in edges: + mark(edge['artifact']['sha256'],record,edge['role'],edge['role']) + + # The material owner has already closed and correlated these inventories + # with its hashed model, recipe, snapshot and runtime definitions. + from project_material import decode_records as material_records + for (family, locator), doc in material_records(records).items(): + record = dict(family=family,id=locator) + for edge in doc.get('artifacts',[]): + runtime_file = family == 'runtime_material' and edge['role'] == 'runtime_file' and edge['preservation'] == 'regenerable' + mark(edge['blob']['sha256'],record,edge['role'],edge['name'],edge['blob']['bytes'], + policy='regenerable' if runtime_file else 'unique') + + # Run outputs, exact execution captures, publication proofs and measured + # host module bytes remain unique regardless of a weaker edge label. + from project_history import declarations as run_declarations + for record in records: + if record['family'] not in ('run_history', 'run_observation'): continue + doc = (record['document']['observation'] if record['family'] == 'run_observation' + else record['document']) + by_sha = {edge['blob']['sha256']:edge['name'] for edge in doc['payloads']} + publication = doc['publication'] + if publication is not None: + for name in ('source','result'): + if publication[name] is not None: by_sha[publication[name]['sha256']] = 'publication.'+name + for declaration in run_declarations(dict(family='run_history', id=record['id'], document=doc)): + blob = declaration['document'] + mark(blob['sha256'],record,'run_payload',by_sha[blob['sha256']],blob['bytes']) + run = doc['run'] + if run.get('execution_kind') == 'external_recipe': + for name, sha in run['material']['host_files'].items(): + mark(sha,record,'host_source',name) + + # Native measured proof/source/output bytes retain unique custody even + # when identical bytes also occur in a regenerable runtime dependency. + for record in records: + if record['family'] != 'native_execution_history': continue + for edge in record['document']['payloads']: + mark(edge['blob']['sha256'],record,edge['role'],edge['name'],edge['blob']['bytes']) + + # Existing pure getter covers the four producer families, entry history + # payload maps, and variation prompt-batch request/response observations. + from project_producers import FAMILIES as PRODUCER_FAMILIES, _referenced_artifacts + for record in records: + if record['family'] in PRODUCER_FAMILIES: + for sha in _referenced_artifacts([record]): + mark(sha,record,'producer_wire','producer_wire') + if missing: + failure = ProjectForkError('Complete fork requires declarations for unique historical payloads.', + 'missing_unique',409) + failure.missing_unique = sorted(missing,key=lambda item:(item['family'],item['id'],item['role'],item['name'],item['sha256'])) + raise failure + return [dict(declaration=copy.deepcopy(declarations[sha]), + preservation='regenerable' if sha in regenerable and sha not in unique else 'unique') + for sha in sorted(declarations)] + except ProjectForkError: raise + except (KeyError, TypeError, ValueError, AttributeError, OverflowError, RecursionError): + _fail('Malformed fixed-owner fork artifact closure.') + + +def build_candidate(snapshot, destination_project_id, *, created_at, id_factory): + """Compose originals, independent editable drafts and complete byte policy. + + id_factory(family, source_envelope_id, source_revision) must return fresh UUID + strings. Domain owners retain their own version, provenance and binding rules. + No historical ID, origin catalog or hash-bearing document is rewritten. + """ + if type(snapshot) is not dict or set(snapshot) != {'project_id','name','checkpoint','records'}: + _fail('Fork requires one complete canonical source snapshot.') + _uuid(snapshot['project_id']); _uuid(destination_project_id); _sha(snapshot['checkpoint']) + if destination_project_id == snapshot['project_id'] or not callable(id_factory): + _fail('Fork requires an independent destination identity and explicit ID allocator.') + _records(snapshot['records']) + source = copy.deepcopy(snapshot['records']) + previous = decode_fork(source,project_id=snapshot['project_id']) + used = {snapshot['project_id'],destination_project_id} + used.update(r['id'] for r in source if ID.fullmatch(r['id'])) + used.update(r['document']['current']['id'] for r in source if r['family'] in MATERIAL_EDITABLE) + allocated = {} + + def allocate(family, source_id, revision): + key = family,source_id,revision + if key not in allocated: + identity = id_factory(*key) + _uuid(identity) + if identity in used: _fail('Fork allocator reused an existing or allocated editable identity.') + used.add(identity); allocated[key] = identity + return allocated[key] + + parts = [fork_data(source,snapshot['project_id'],snapshot['checkpoint'],destination_project_id, + id_factory=allocate,created_at=created_at), + fork_material(source,snapshot['project_id'],snapshot['checkpoint'],destination_project_id, + id_factory=allocate,created_at=created_at), + fork_files(source,snapshot['project_id'],snapshot['checkpoint'],destination_project_id)] + indexed = {(r['family'],r['id']):r for r in source} + mapping = [] + for part in parts: + mapping.extend(copy.deepcopy(part['mapping'])) + for record in part['records']: + key = record['family'],record['id'] + if key in indexed: _fail('Fork owner generated a colliding canonical record.') + indexed[key] = copy.deepcopy(record) + records = [indexed[key] for key in sorted(indexed)] + _records(records) + archived = {(r['family'],r['id']) for r in source if r['family'] in ARCHIVE_FAMILIES} + if previous is not None: archived.update((item['family'],item['id']) for item in previous['archived']) + mapping.sort(key=lambda item:(item['family'],item['source_id'],item['source_revision'] or 0,item['destination_id'])) + return dict(records=records,mapping=mapping, + archived=[dict(family=family,id=identity) for family,identity in sorted(archived)], + artifacts=artifact_plan(source)) diff --git a/project_history.py b/project_history.py new file mode 100644 index 00000000..6388052c --- /dev/null +++ b/project_history.py @@ -0,0 +1,1184 @@ +"""Inert project Study, lineage and Run facts, with owner-shared validation. + +Historical identity is always (origin catalog, original ID). No imported fact is +inserted into a worker, admission, grant or request replay table. The optional +SQL connection is only a query engine for bounded supplied immutable JSON. +""" +import base64 +import copy +import hashlib +import re +import uuid + +import agent_api + +from experiments import (DIMENSIONS, Experiments, budget, request_key, + select_retained_checkpoint, reservation_totals, validate_candidate_evidence, new_study_document, edit_study_document, new_trial_document, new_candidate_document, validate_retained_candidate, + validate_retained_policy, validate_retained_reservation, + validate_retained_study, validate_retained_trial) +from experiment_lineage import (INTENT_LIMIT, validate_retained_ancestry, + validate_retained_intent, validate_retained_lineage) +from model_definitions import checked_json, natural, reference, text +from pipeline import closed, digest, identifier, model_aware_graph, source_content_hash +from project_session import operation_documents, operation_records +from projects import relative_path +from runs import (ACTIVE, TERMINAL, TEST_FILES, MAX_ARTIFACT_BYTES, validate_retained_run, + project_run_projection, public_run_projection, artifact_capture_source, validate_external_artifact_inventory) +from workbench import WorkbenchError, timestamp + +FAMILY_FORMATS = { + 'study_history': 'project_study_history_v1', + 'trial_history': 'project_trial_history_v1', + 'run_history': 'project_run_history_v1', + 'run_observation': 'project_run_observation_v1', + 'candidate_history': 'project_candidate_history_v1', +} +MAX_HISTORY_BYTES = 40 * 1024**2 +MAX_HISTORY_RECORDS = 100_000 +NAME = re.compile(r'^[A-Za-z0-9][A-Za-z0-9_.-]{0,119}$') +SHA = re.compile(r'^[a-f0-9]{64}$') +RUN_OBSERVATION_IDENTITY = ('dataset_id','id','request_key','request_sha256','graph_sha256','graph_document_sha256', + 'material_sha256','release_id','release_sha256','release_bytes','admission_id','approval_id', + 'approval_material_sha256','actor_id','parent_run_id','attempt','created_at','execution_kind','result_kind') + +OPERATIONS = frozenset(('studies.create','studies.save','studies.prepare_trial','studies.record_lineage', + 'studies.branch_trial','studies.prepare_branch','studies.select_candidate','studies.prepare_package', + 'studies.prepare_verifier','studies.continue','studies.stop')) + + +def _invalid(message, code='invalid'): + raise WorkbenchError(message, code, 409) + + +def _shape(value, fields, name): + if type(value) is not dict or set(value) != set(fields): + _invalid('Invalid '+name+' fields.') + + +def _items(value, name, maximum=10000): + if type(value) is not list or len(value)>maximum: + _invalid(name+' must be a bounded list.') + return value + + +def _sha(value): + if type(value) is not str or not SHA.fullmatch(value): + _invalid('Historical digest must be an exact SHA256.') + return value + + +def envelope(family, origin, identity, document): + identifier(origin,'Origin catalog');identifier(identity,'Historical identity') + return {'family':family,'id':origin+'.'+identity,'document':document} + + +def _audit(facts): + """Retain classified scientific audit facts, never arbitrary access payloads.""" + for fact in _items(facts,'Scientific audit',100000): + _shape(fact,('actor_id','action','target','details','result','occurred_at'),'scientific audit') + if fact['actor_id'] is not None:identifier(fact['actor_id'],'Historical actor') + action=fact['action'];details=fact['details'] + text(fact['occurred_at'],'Audit time',80);identifier(fact['target'],'Audit target') + allowed=OPERATIONS|{'studies.admit','studies.approve','studies.reconcile','studies.candidate_selected','studies.lineage.record', + 'runs.admit','runs.admit_external','runs.update','runs.reconcile','runs.expose_test'} + if action not in allowed:_invalid('Unclassified historical scientific audit action.','unsupported') + if fact['result'] in ('denied','failed'): + closed(details,('code',)) + if details['code'] not in ('invalid','unavailable','unsupported','stale','conflict','not_found','denied', + 'forbidden','unknown','failed','cancelled','busy','interrupted'):_invalid('Unclassified historical failure code.') + continue + if fact['result'] not in ('success','interrupted'):_invalid('Unsupported historical audit result.') + keys=set(details) if type(details) is dict else set() + if action=='studies.create': + closed(details,('policy_sha256','limits'));_sha(details['policy_sha256']);budget(details['limits']) + elif action in ('studies.save','studies.continue','studies.stop'): + if keys not in ({'revision'},{'revision','epoch'}):_invalid('Unclassified Study transition audit.') + elif action=='studies.reconcile': + if keys not in ({'revision','epoch'},{'state'}):_invalid('Unclassified Study recovery audit.') + elif action=='studies.prepare_trial': + if keys not in ({'trial_id'},{'trial_id','revision','epoch'}):_invalid('Unclassified Trial preparation audit.') + elif action=='studies.admit':closed(details,('run_id','reservation_id','request_sha256','revision','epoch')) + elif action=='studies.approve': + if keys not in ({'trial_id','material_sha256','approval_id'}, + {'trial_id','material_sha256','approval_id','consumer_actor_id','created_at','expires_at'}): + _invalid('Unclassified Study approval audit.') + if 'created_at' in details: + from access import MAX_APPROVAL_TTL + if (type(details['created_at']) is not int or type(details['expires_at']) is not int or + not details['created_at']=64:_invalid('Historical Run ancestry exceeds its bound.','unsupported') + run=facts.runs[key]['run'];next_path=path|{key} + if run.get('parent_run_id') is not None:visit_run((key[0],run['parent_run_id']),next_path) + if run.get('execution_kind')=='external_recipe': + for item in run['material']['inputs'].values(): + if item.get('kind')=='run_artifact': + source=item['source'];visit_run((source['catalog_id'],source['run_id']),next_path) + for key in facts.runs:visit_run(key,set()) + for (origin,_identity),reservation in facts.reservations.items(): + trial=facts.trials[(origin,reservation['trial_id'])]['trial'] + run=facts.runs.get((origin,reservation['run_id'])) + if run is None:_invalid('Historical reservation Run is missing.','unavailable') + validate_retained_reservation(reservation,trial=trial,composition=materials.recipe(trial['material']),run=run['run']) + for (origin,_identity),doc in facts.candidates.items(): + value=doc['candidate'];trial=facts.trials.get((origin,value.get('trial_id'))) + reservation=facts.reservations.get((origin,value.get('run_id'))) + if trial is None or reservation is None:_invalid('Candidate dependency closure is missing.','unavailable') + ref=value['policy'];policy=facts.policies.get((origin,ref['id'],ref['revision'],ref['sha256'])) + if policy is None:_invalid('Candidate policy closure is missing.','unavailable') + validate_retained_candidate(value,catalog_id=origin,trial=trial['trial'],policy=policy,reservation=reservation) + for (origin,_),doc in facts.studies.items(): + for operation in doc['operations']: + validate_operation_result(operation['operation_id'],operation['result'],records) + validate_data_exposures(records,facts) + for record in records: + if record.get('family')=='project_operation': + value=record['document'];validate_operation_result(value['operation'],value['result'],records) + return facts + + +def decode_records(records, *, materials=None, artifacts=None): + try: + return _decode_records(records,materials=materials,artifacts=artifacts) + except WorkbenchError: + raise + except (KeyError,TypeError,ValueError,AttributeError,RecursionError): + _invalid('Invalid or incomplete project history document.') + + +def _validate_operation_shape(operation,result): + if operation=='history.disclose': + validate_disclosure_fact(result);return True + if operation in ('studies.create','studies.save'): + closed(result,('format','id','revision','name','admission_epoch','open','state','limits','policy', + 'membership_revision','trial_ids','stop_receipt_id')) + if result['format']!='study_v1':_invalid('Historical Study operation result differs.') + identifier(result['id']);natural(result['revision'],'Study revision',1);budget(result['limits']) + reference(result['policy'],'selection_policy',result['policy']['catalog_id']) + return True + if operation in ('studies.prepare_trial','studies.prepare_branch'): + validate_retained_trial(result,catalog_id=result['material']['catalog_id']);return True + if operation=='studies.record_lineage': + closed(result,('format','trial','intent','actor_id','recorded_at','timing')) + if result['format']!='trial_lineage_v1':_invalid('Historical lineage operation result differs.') + return True + if operation=='studies.select_candidate': + closed(result,('ref','candidate','run_id','checkpoint')) + ref=reference(result['ref'],'candidate',result['ref']['catalog_id']) + if (ref['sha256']!=digest(result['candidate']) or ref['id']!=result['candidate']['id'] or + result['run_id']!=result['candidate']['run_id'] or result['checkpoint']['sha256']!=result['candidate']['checkpoint_sha256']): + _invalid('Historical candidate operation identity differs.') + return True + if operation=='studies.continue': + return _validate_operation_shape('studies.save',result) + if operation=='studies.stop': + closed(result,('study_id','stop_receipt_id','admission_epoch','run_ids')) + identifier(result['study_id']);identifier(result['stop_receipt_id']);natural(result['admission_epoch'],'Admission epoch') + for run_id in _items(result['run_ids'],'Stopped Run ids'):identifier(run_id) + return True + if operation in ('studies.prepare_package','studies.prepare_verifier'): + from candidate_packages import decode_preparation_receipt + origin=result['trial']['material']['catalog_id'] + decode_preparation_receipt(operation,result,catalog_id=origin, + validate_trial=lambda value:validate_retained_trial(value,catalog_id=origin)) + return True + if operation=='studies.branch_trial': + from pipeline import decode_graph + closed(result,('request_key','graph','model','parent','intent','study_id','source')) + request_key(result['request_key']);decode_graph(result['graph']);identifier(result['study_id']) + reference(result['model'],'model',result['model']['catalog_id']) + closed(result['source'],('id','revision','material'));identifier(result['source']['id']) + natural(result['source']['revision'],'Source graph revision',1) + reference(result['source']['material'],'recipe_material',result['model']['catalog_id']) + closed(result['parent'],('catalog_id','trial_id','material_sha256')) + identifier(result['parent']['catalog_id']);identifier(result['parent']['trial_id']);_sha(result['parent']['material_sha256']) + if result['intent']['parent']!=result['parent']:_invalid('Branch parent differs from retained intent.') + # Exact lineage intent is checked with the complete Trial closure below. + def ref(value): + closed(value,('catalog_id','trial_id','material_sha256')) + identifier(value['catalog_id']);identifier(value['trial_id']);_sha(value['material_sha256']) + validate_retained_intent(result['intent'],resolve=ref) + return True + if operation=='studies.admit': + # The legacy owner replays admission from reservations and never calls + # _save_receipt for this operation. M3 must own any future publication + # receipt; an invented public Run snapshot is not a portable v1 fact. + _invalid('Portable admission operation receipts are unsupported; retain exact reservation and Run proof.','unsupported') + return False + + +def _bind_operation_result(operation,result,records): + def document(family,origin,identity): + matches=[item['document'] for item in records if item.get('family')==family and item.get('id')==origin+'.'+identity] + if len(matches)!=1:_invalid('Operation result has no unique canonical history fact.') + return matches[0] + if operation=='history.disclose': + doc=document('run_history',result['origin_catalog_id'],result['run_id']) + if result['format']=='project_disclosure_v2': + matches=[item for item in records if item.get('family')=='run_observation' + and item.get('id')==result['observation_sha256']] + if len(matches)!=1:_invalid('Disclosure has no unique retained Run observation.') + saved=_observation_document(matches[0]) + if (saved['origin_catalog_id']!=result['origin_catalog_id'] or saved['run_id']!=result['run_id'] + or saved['sha256']!=result['observation_sha256']): + _invalid('Disclosure Run observation identity differs.') + observation=saved['observation'] + for field in RUN_OBSERVATION_IDENTITY: + if observation['run'].get(field)!=doc['run'].get(field): + _invalid('Disclosure Run observation immutable identity differs.') + declarations={item['id']:item['document'] for item in records if item.get('family')=='project_artifact'} + for edge in _payloads(observation['payloads']).values(): + if declarations.get(edge['blob']['sha256'])!={'format':'project_artifact_v1',**edge['blob']}: + _invalid('Disclosure observation artifact declaration is missing or contradictory.','unavailable') + doc=observation + if result['payload_set_sha256']!=payload_set_sha256(doc['origin_catalog_id'],doc['run']['id'],doc['payloads']): + _invalid('Disclosure observation does not identify the retained Run payload set.') + elif operation in ('studies.create','studies.save'): + doc=document('study_history',result['policy']['catalog_id'],result['id']) + if result not in doc['revisions']:_invalid('Study operation result has no retained revision.') + elif operation in ('studies.prepare_trial','studies.prepare_branch'): + doc=document('trial_history',result['material']['catalog_id'],result['id']) + if doc['trial']!=result:_invalid('Trial operation result has no retained Trial.') + elif operation=='studies.record_lineage': + ref=result['trial'];doc=document('trial_history',ref['catalog_id'],ref['trial_id']) + if doc['lineage']!=result:_invalid('Lineage operation result has no retained sidecar.') + elif operation=='studies.select_candidate': + ref=result['ref'];doc=document('candidate_history',ref['catalog_id'],ref['id']) + if doc['candidate']!=result['candidate']:_invalid('Candidate operation result has no retained decision.') + run=document('run_history',ref['catalog_id'],result['run_id'])['run'] + checkpoint=result['checkpoint'] + if not any(all(item.get(key)==checkpoint.get(key) for key in ('name','sha256','bytes','media_type')) + for item in run['artifacts']): + _invalid('Candidate operation checkpoint differs from its canonical Run artifact.') + + elif operation=='studies.continue': + _bind_operation_result('studies.save',result,records) + elif operation=='studies.stop': + docs=[item['document'] for item in records if item.get('family')=='study_history' + and item['document']['study']['id']==result['study_id']] + matches=[doc for doc in docs if any(value['stop_receipt_id']==result['stop_receipt_id'] + and value['admission_epoch']==result['admission_epoch'] for value in doc['revisions'])] + if len(matches)!=1:_invalid('Stop receipt has no unique retained epoch fact.') + members={value['run_id'] for value in matches[0]['reservations']} + if not set(result['run_ids'])<=members:_invalid('Stop receipt references an unrelated Run.') + elif operation in ('studies.prepare_package','studies.prepare_verifier'): + origin=result['trial']['material']['catalog_id'] + _bind_operation_result('studies.prepare_trial',result['trial'],records) + document('candidate_history',origin,result['candidate_id']) + graph_docs=[item['document'] for item in records if item.get('family')=='pipeline_graph' + and item['document']['origin_catalog_id']==origin] + if not any(result['graph'] in doc['revisions'] for doc in graph_docs): + _invalid('Preparation graph has no retained revision.') + if operation=='studies.prepare_verifier':document('run_history',origin,result['package_run_id']) + elif operation=='studies.branch_trial': + ref=result['parent'];origin=ref['catalog_id'];parent=document('trial_history',origin,ref['trial_id'])['trial'] + if parent['material']['sha256']!=ref['material_sha256']:_invalid('Branch parent material differs.') + resolve=lambda value:document('trial_history',value['catalog_id'],value['trial_id'])['trial'] + validate_retained_intent(result['intent'],resolve=resolve) + document('study_history',origin,result['study_id']) + graph_docs=[item['document'] for item in records if item.get('family')=='pipeline_graph' + and item['document']['origin_catalog_id']==origin] + if not any(result['graph'] in doc['revisions'] for doc in graph_docs):_invalid('Branch graph has no retained revision.') + +def validate_operation_result(operation,result,records=None): + """Validate exact returned meaning and, when supplied, its canonical fact.""" + try: + claimed=_validate_operation_shape(operation,result) + if claimed and records is not None:_bind_operation_result(operation,result,records) + return claimed + except WorkbenchError: + raise + except (KeyError,TypeError,ValueError,AttributeError): + _invalid('Invalid historical operation result or fact binding.') + + +def validate_data_exposures(records,history): + """Exposure is a historical Run/source fact, never present permission.""" + try: + for item in records: + if item.get('family')!='dataset_exposure':continue + doc=item['document'] + _shape(doc,('format','id','record_id','source_hash','reason','run_id','release_id','created_at'),'dataset exposure') + if doc['format']!='dataset_exposure_v1':_invalid('Unsupported dataset exposure.','unsupported') + matches=[] + for (origin,run_id),saved in history.runs.items(): + run=saved['run'] + if run_id!=doc['run_id'] or run.get('release_id')!=doc['release_id']:continue + sources=[source for source in run.get('source_records',[]) if source.get('id')==doc['record_id'] + and source.get('split')=='test' and source_content_hash(source)==doc['source_hash']] + events=[event for event in run.get('exposure_events',[]) if event.get('created_at')==doc['created_at'] + and event.get('reason')==doc['reason']] + if len(sources)==1 and events:matches.append(origin) + if len(matches)!=1:_invalid('Dataset exposure has no unique exact historical Run/source/release binding.') + return True + except WorkbenchError: + raise + except (KeyError,TypeError,ValueError,AttributeError): + _invalid('Invalid historical dataset exposure binding.') + + +class ProjectHistory: + """Read-only imported facts plus canonical, non-executing Study metadata edits.""" + def __init__(self,session,*,materials=None,authorize_disclosure=None): + self.session=session;self.access=session.access;self.artifacts=session.artifacts + if materials is None: + from project_material import ProjectMaterial + materials=ProjectMaterial(session) + self.materials=materials;self.authorize_disclosure=authorize_disclosure + + def _facts(self): + self.session.check() + return decode_records(self.session.snapshot['records'],materials=self.materials,artifacts=self.artifacts) + + def _authorize(self,action,target=None): + self.access.require('inspect',action,target) + + def _origin(self,origin): + return self.session.snapshot['project_id'] if origin is None else origin + + def _record(self,collection,identity,origin=None): + origin=self._origin(origin);identifier(origin,'Origin catalog');identifier(identity) + result=collection.get((origin,identity)) + if result is None:_invalid('Historical record is unavailable.','not_found') + return result + + def study(self,study_id,*,origin_catalog_id=None,offset=0,limit=100,revision=None): + self._authorize('studies.get',study_id) + natural(offset,'Reservation offset');natural(limit,'Reservation limit',1,100) + facts=self._facts();doc=self._record(facts.studies,study_id,origin_catalog_id) + if offset and revision is None:_invalid('Later reservation pages require a Study revision.') + if revision is not None and revision!=doc['study']['revision']:_invalid('Study changed between pages.','stale') + totals=reservation_totals(doc['reservations']) + self._authorize('studies.get',study_id) + end=min(offset+limit,len(doc['reservations'])) + return {'study':copy.deepcopy(doc['study']),'reservations':copy.deepcopy(doc['reservations'][offset:end]), + 'charged':totals,'revision_history_complete':doc['revision_history_complete'], + 'page':{'offset':offset,'limit':limit,'total':len(doc['reservations']), + 'study_revision':doc['study']['revision'],'next_offset':end if endlen(raw):_invalid('Run document offset exceeds its size.') + end=min(offset+limit,len(raw)) + result={'format':'project_run_document_segment_v1','origin_catalog_id':doc['origin_catalog_id'], + 'run_id':run_id,**descriptor,'offset':offset,'limit':limit, + 'base64':base64.b64encode(raw[offset:end]).decode('ascii'), + 'next_offset':end if end=64:_invalid('Historical Run input lineage exceeds its bound.','unsupported') + seen=seen|{key} + if run.get('execution_kind')!='external_recipe':return + for item in run['material']['inputs'].values(): + if item.get('kind')!='run_artifact':continue + source=item['source'];parent=facts.runs[(source['catalog_id'],source['run_id'])] + self._authorize('runs.artifact',source['run_id']) + edge=next(edge for edge in parent['payloads'] if edge['name']==source['artifact_name']) + self._disclose(parent['run'],edge['population'],edge['name']);self._transitive(facts,parent,seen) + + def _read(self,doc,name,maximum): + edges=_payloads(doc['payloads']);edge=edges.get(name) + if edge is None:_invalid('Historical artifact is unavailable.','not_found') + self._authorize('runs.artifact',doc['run']['id']);self._disclose(doc['run'],edge['population'],name) + raw=self.artifacts.read_blob(edge['blob'],maximum_bytes=maximum) + self._authorize('runs.artifact',doc['run']['id']);self._disclose(doc['run'],edge['population'],name) + return raw + + def artifact(self,run_id,name,*,origin_catalog_id=None,maximum_bytes=40*1024**2): + facts=self._facts();doc=self._record(facts.runs,run_id,origin_catalog_id) + self._transitive(facts,doc) + return self._read(doc,name,maximum_bytes) + + def authorize_material_inputs(self,material): + """Bind retained producer receipts under present disclosure authority.""" + from recipe_contract import validate_material + material=validate_material(copy.deepcopy(material)) + for name,item in material['inputs'].items(): + if item.get('kind')!='run_artifact':continue + source=item['source'] + _raw,actual=self.capture_artifact(source['run_id'],source['artifact_name'],source['measured_sha256'], + max_bytes=material['recipe']['inputs'][name]['max_bytes'],origin_catalog_id=source['catalog_id']) + if actual!=source:_invalid('Retained producer proof differs from the captured input receipt.','unavailable') + + def capture_artifact(self,run_id,name,expected_sha256,*,max_bytes=MAX_ARTIFACT_BYTES,origin_catalog_id=None): + """Capture an exact completed input without live recovery or admissions.""" + self._authorize('runs.capture_artifact',run_id);_sha(expected_sha256) + natural(max_bytes,'Artifact input byte bound',1,MAX_ARTIFACT_BYTES) + facts=self._facts();doc=self._record(facts.runs,run_id,origin_catalog_id);run=doc['run'] + self._transitive(facts,doc) + if run['state']!='completed':_invalid('Only completed Runs provide artifact inputs.','unavailable') + external=run.get('execution_kind')=='external_recipe' + if not external and (run.get('graph',{}).get('recipe')!='image_classification_model_v2' or name=='release.zip'): + _invalid('Capture requires qualified classifier evidence or an external Run.','unsupported') + item=next((item for item in run['artifacts'] if item['name']==name),None) + if item is None or item['bytes']>max_bytes:_invalid('Artifact input is unavailable or exceeds its bound.','unavailable') + if item['sha256']!=expected_sha256:_invalid('Artifact differs from expected SHA256.','conflict') + if external: + self._disclose(run,'unknown') + receipt=self._read(doc,'custody-receipt.json',16384) + manifest=self._read(doc,'artifact-manifest.json',65536) + try:validate_external_artifact_inventory(run,receipt,manifest) + except (ValueError,TypeError,KeyError) as error: + raise WorkbenchError('Retained external inventory differs.','unavailable',409) from error + if run.get('extension_context') is not None:self.evidence(run_id,origin_catalog_id=doc['origin_catalog_id']) + if not run['material']['recipe']['resources'].get('profile')=='captured_cpu_large_outputs_v1': + for artifact in run['artifacts']:self._read(doc,artifact['name'],MAX_ARTIFACT_BYTES) + proof=receipt + else: + # Shared model evidence verifies the full frozen prediction/checkpoint closure. + self.evidence(run_id,origin_catalog_id=doc['origin_catalog_id']) + proof=self._read(doc,'artifact-manifest.json',65536) + raw=self._read(doc,name,max_bytes) + source=artifact_capture_source(run,item,raw,proof) + self._authorize('runs.capture_artifact',run_id) + return raw,source + + def evidence(self,run_id,selectors=None,*,origin_catalog_id=None): + self._authorize('runs.model_evidence',run_id) + facts=self._facts();doc=self._record(facts.runs,run_id,origin_catalog_id) + self._transitive(facts,doc) + if doc['run'].get('execution_kind')=='external_recipe':self._disclose(doc['run'],'unknown') + result=self.materials.evidence(doc['run'],selectors or {}, + read_blob=lambda name,maximum:self._read(doc,name,maximum)) + self._authorize('runs.model_evidence',run_id) + return result + + def candidate(self,candidate_id,*,origin_catalog_id=None,verify=False): + self._authorize('studies.candidate',candidate_id) + facts=self._facts();doc=self._record(facts.candidates,candidate_id,origin_catalog_id);value=doc['candidate'] + result={'ref':{'kind':'candidate','catalog_id':doc['origin_catalog_id'],'id':candidate_id,'sha256':digest(value)}, + 'candidate':copy.deepcopy(value),'run_id':value['run_id'],'evidence':'unverified'} + if verify: + evidence=self.evidence(value['run_id'],{'checkpoint_sha256':value['checkpoint_sha256']}, + origin_catalog_id=doc['origin_catalog_id']) + trial=facts.trials[(doc['origin_catalog_id'],value['trial_id'])]['trial'] + validate_candidate_evidence(evidence,trial) + ref=value['policy'];policy=facts.policies[(doc['origin_catalog_id'],ref['id'],ref['revision'],ref['sha256'])] + checkpoint=select_retained_checkpoint(evidence,policy,value['checkpoint_sha256']) + run=facts.runs[(doc['origin_catalog_id'],value['run_id'])]['run'] + evaluation='model-evidence.json' if run.get('extension_context') is not None else 'metrics.json' + if not any(item['name']==evaluation and item['sha256']==value['evaluation_sha256'] for item in run['artifacts']): + _invalid('Candidate evaluation identity differs.') + result.update(checkpoint=checkpoint,evidence='verified') + return result + + def package_state(self,candidate_id,*,origin_catalog_id=None): + from candidate_packages import retained_package_state + self._authorize('studies.package_state',candidate_id) + facts=self._facts();origin=self._origin(origin_catalog_id) + candidate=self.candidate(candidate_id,origin_catalog_id=origin,verify=True) + preparations=[] + for (namespace,_),doc in facts.studies.items(): + if namespace!=origin:continue + for operation in doc['operations']: + if operation['operation_id'] in ('studies.prepare_package','studies.prepare_verifier') and operation['result'].get('candidate_id')==candidate_id: + preparations.append({'operation_id':operation['operation_id'],'result':operation['result']}) + def runs_for_trial(trial_id): + result=[] + for (namespace,run_id),reservation in sorted(facts.reservations.items()): + if namespace==origin and reservation['trial_id']==trial_id: + self._authorize('runs.get',run_id) + doc=facts.runs[(origin,run_id)];self._transitive(facts,doc);result.append(copy.deepcopy(doc['run'])) + return result + result=retained_package_state(candidate,preparations,runs_for_trial=runs_for_trial, + read_artifact=lambda run_id,name:self.artifact(run_id,name,origin_catalog_id=origin)) + self._authorize('studies.package_state',candidate_id) + return result + + def retained_analysis(self,origin_catalog_id,body,*,export=False): + from retained_analysis import query_history + run_id=body['run_id'];doc=self._record(self._facts().runs,run_id,origin_catalog_id) + evidence=self.evidence(run_id,{'checkpoint_sha256':body['checkpoint_sha256']},origin_catalog_id=origin_catalog_id) + return query_history(self.session.db,doc['run'],evidence,body,authorize=self.access.require,export=export) + + def _mutate(self,operation,target,body,expected_checkpoint,key,build): + prepared={} + def invoke(): + self.access.require('edit',operation,target) + facts=self._facts();records,result=build(facts) + prepared['records']=records + return result + def capture(result,before_records): + adapter=getattr(self,'study_execution',None) + if adapter is not None: + # Authoring rows are a disposable projection of THIS pending + # canonical change. Update them inside the same SQL boundary so + # a background local Run cannot later publish stale Study intent. + merged={(item['family'],item['id']):item for item in before_records} + merged.update(((item['family'],item['id']),item) for item in prepared['records']) + adapter.refresh_authoring(list(merged.values())) + return {'records':prepared['records'],'result':result} + self.access.require('edit',operation,target,owner_only=operation in ('studies.create','studies.save')) + return self.session.mutate(operation,target,body,expected_checkpoint,key,invoke,capture) + + def _audit_fact(self,action,target,details): + return {'actor_id':self.access._context().actor_id,'action':action,'target':target,'details':copy.deepcopy(details), + 'result':'success','occurred_at':timestamp()} + + def create_study(self,body,expected_checkpoint,request_key): + closed(body,('name','limits','policy'));body=checked_json(body) + text(body['name'],'Study name');limits=budget(body['limits']);policy=validate_retained_policy(body['policy']) + def build(facts): + origin=self.session.snapshot['project_id'];identity=uuid.uuid4().hex;sha=digest(policy) + prior=next((key for key,value in facts.policies.items() if key[0]==origin and key[3]==sha),None) + policy_id=prior[1] if prior else uuid.uuid4().hex + ref={'kind':'selection_policy','catalog_id':origin,'id':policy_id,'revision':1,'sha256':sha} + study=new_study_document(body,catalog_id=origin,study_id=identity,policy_id=policy_id) + doc={'format':FAMILY_FORMATS['study_history'],'origin_catalog_id':origin,'study':study, + 'memberships':[{'revision':1,'trial_ids':[]}],'policies':[{'ref':ref,'policy':policy}], + 'reservations':[],'operations':[],'revisions':[copy.deepcopy(study)], + 'audit':[self._audit_fact('studies.create',identity,{'policy_sha256':sha,'limits':limits})], + 'revision_history_complete':True} + return [envelope('study_history',origin,identity,doc)],study + return self._mutate('studies.create',None,body,expected_checkpoint,request_key,build) + + def save_study(self,study_id,body,expected_checkpoint,request_key): + closed(body,('revision','name','limits','policy'));body=checked_json(body) + text(body['name'],'Study name');limits=budget(body['limits']) + def build(facts): + origin=self.session.snapshot['project_id'];doc=copy.deepcopy(self._record(facts.studies,study_id,origin));study=doc['study'] + if body['revision']!=study['revision']:_invalid('Study changed. Reload.','stale') + ref=reference(body['policy'],'selection_policy',origin) + policy=facts.policies.get((origin,ref['id'],ref['revision'],ref['sha256'])) + if policy is None:_invalid('Selection policy is unavailable.','unavailable') + totals=reservation_totals(doc['reservations']) + study=edit_study_document(study,body,policy=policy,charged=totals,catalog_id=origin) + study['revision']=natural(study['revision']+1,'Study revision',1);doc['study']=study + if not any(item['ref']==ref for item in doc['policies']):doc['policies'].append({'ref':ref,'policy':policy}) + doc['revisions'].append(copy.deepcopy(study));doc['audit'].append(self._audit_fact('studies.save',study_id,{'revision':study['revision']})) + return [envelope('study_history',origin,study_id,doc)],study + return self._mutate('studies.save',study_id,body,expected_checkpoint,request_key,build) + + def prepare_trial_from_material(self,study_id,body,expected_checkpoint,request_key): + """Attach a fully retained composition without capture, approval or execution.""" + closed(body,('revision','material','replicate'));body=checked_json(body) + natural(body['replicate'],'Replicate') + self.access.require('edit','studies.prepare_trial',study_id) + if request_key not in operation_records(self.session.snapshot['records']): + self.materials.recipe(body['material'],verify=True) + def build(facts): + origin=self.session.snapshot['project_id'];doc=copy.deepcopy(self._record(facts.studies,study_id,origin)) + study=doc['study'] + if body['revision']!=study['revision']:_invalid('Study changed. Reload.','stale') + ref=reference(body['material'],'recipe_material',origin);self.materials.recipe(ref) + for (namespace,_),other in facts.trials.items(): + trial=other['trial'] + if namespace==origin and trial['study_id']==study_id and trial['material']==ref and trial['replicate']==body['replicate']: + _invalid('Material/replicate already belongs to this study.','conflict') + trial=new_trial_document(study,ref,body['replicate'],trial_id=uuid.uuid4().hex) + study['trial_ids'].append(trial['id']);study['membership_revision']+=1 + study['revision']=natural(study['revision']+1,'Study revision',1) + doc['memberships'].append({'revision':study['membership_revision'],'trial_ids':copy.deepcopy(study['trial_ids'])}) + doc['revisions'].append(copy.deepcopy(study)) + doc['audit'].append(self._audit_fact('studies.prepare_trial',study_id,{'trial_id':trial['id']})) + trial_doc={'format':FAMILY_FORMATS['trial_history'],'origin_catalog_id':origin,'trial':trial,'lineage':None} + return [envelope('study_history',origin,study_id,doc),envelope('trial_history',origin,trial['id'],trial_doc)],trial + return self._mutate('studies.prepare_trial',study_id,body,expected_checkpoint,request_key,build) + + def select_candidate(self,study_id,body,expected_checkpoint,request_key): + closed(body,('revision','trial_id','run_id'),('checkpoint_sha256',));body=checked_json(body) + identifier(body['trial_id']);identifier(body['run_id']) + # Expensive semantic proof precedes the write transaction. All exact + # associations and selected policy are checked again under publication. + origin=self.session.snapshot['project_id'] + self.access.require('edit','studies.select_candidate',study_id) + evidence=(self.evidence(body['run_id'],{},origin_catalog_id=origin) + if request_key not in operation_records(self.session.snapshot['records']) else None) + def build(facts): + doc=copy.deepcopy(self._record(facts.studies,study_id,origin));study=doc['study'] + if body['revision']!=study['revision']:_invalid('Study changed. Reload.','stale') + trial=self._record(facts.trials,body['trial_id'],origin)['trial'] + reservation=facts.reservations.get((origin,body['run_id'])) + if reservation is None or reservation['trial_id']!=trial['id']:_invalid('Run does not belong to the selected trial.') + validate_candidate_evidence(evidence,trial) + ref=study['policy'];policy=facts.policies[(origin,ref['id'],ref['revision'],ref['sha256'])] + checkpoint=select_retained_checkpoint(evidence,policy,body.get('checkpoint_sha256')) + run=facts.runs[(origin,body['run_id'])]['run'] + name='model-evidence.json' if run.get('extension_context') is not None else 'metrics.json' + evaluation=next((item for item in run['artifacts'] if item['name']==name),None) + candidate=new_candidate_document(study,trial,run,checkpoint,evaluation,candidate_id=uuid.uuid4().hex) + study['revision']=natural(study['revision']+1,'Study revision',1) + doc['revisions'].append(copy.deepcopy(study));doc['audit'].append(self._audit_fact( + 'studies.select_candidate',candidate['id'],{'study_id':study_id,'run_id':run['id'],'checkpoint_sha256':checkpoint['sha256']})) + candidate_doc={'format':FAMILY_FORMATS['candidate_history'],'origin_catalog_id':origin,'candidate':candidate} + result={'ref':{'kind':'candidate','catalog_id':origin,'id':candidate['id'],'sha256':digest(candidate)}, + 'candidate':candidate,'run_id':run['id'],'checkpoint':checkpoint} + return [envelope('study_history',origin,study_id,doc), + envelope('candidate_history',origin,candidate['id'],candidate_doc)],result + return self._mutate('studies.select_candidate',study_id,body,expected_checkpoint,request_key,build) + + def record_lineage(self,body,expected_checkpoint,request_key): + closed(body,('trial','intent'));body=checked_json(body,INTENT_LIMIT+4096) + def build(facts): + origin=self.session.snapshot['project_id'];trial=facts.trial_ref(body['trial']) + if body['trial']['catalog_id']!=origin:_invalid('Inherited Trials remain immutable historical evidence.','conflict') + doc=copy.deepcopy(facts.trials[(origin,trial['id'])]) + if doc['lineage'] is not None:_invalid('Trial lineage is already recorded and immutable.','conflict') + intent=validate_retained_intent(body['intent'],resolve=facts.trial_ref) + value={'format':'trial_lineage_v1','trial':copy.deepcopy(body['trial']),'intent':intent, + 'actor_id':self.access._context().actor_id,'recorded_at':timestamp(), + 'timing':'retrospective' if any(item['trial_id']==trial['id'] for item in facts.reservations.values()) else 'before_admission'} + validate_retained_lineage(value,trial=trial,resolve=facts.trial_ref) + parent=(intent['parent'] or {}).get('trial_id') + validate_retained_ancestry(trial['id'],parent,parent_of=lambda identity: + (facts.trials[(origin,identity)]['lineage']['intent']['parent'] or {}).get('trial_id') + if facts.trials[(origin,identity)]['lineage'] else None) + doc['lineage']=value + study_doc=copy.deepcopy(facts.studies[(origin,trial['study_id'])]) + study_doc['audit'].append(self._audit_fact('studies.lineage.record',trial['id'],{'intent_sha256':digest(intent)})) + return [envelope('trial_history',origin,trial['id'],doc), + envelope('study_history',origin,trial['study_id'],study_doc)],value + return self._mutate('studies.record_lineage',body.get('trial',{}).get('trial_id'),body,expected_checkpoint,request_key,build) + + def unavailable_execution(self,*args,**kwargs): + _invalid('Project execution and candidate preparation require the M3 custody boundary.','unavailable') + + approve=admit=continue_study=stop=prepare_package=prepare_verifier=unavailable_execution diff --git a/project_locks.py b/project_locks.py new file mode 100644 index 00000000..ed906d8a --- /dev/null +++ b/project_locks.py @@ -0,0 +1,66 @@ +"""Preserve host -> project session -> projection command lock ordering.""" +from contextlib import contextmanager, ExitStack +import threading +import time + +from metadata import CATALOG_LOCAL_TIMEOUT, CatalogBusy + + +class ProjectLock: + """CatalogLock interface for owners that begin at the projection lock. + + Owners retain the actual PostgreSQL advisory lock and its deadlines. Every + entry also owns the host authorization connection before the project + session, including background workers which have no foreground request. + """ + def __init__(self, host, session, projection): + self.host, self.session, self.projection = host, session, projection + self._local = threading.local() + + @property + def depth(self): + return self.projection.depth + + @property + def mutex(self): + return self.projection.mutex + + @property + def owned_by_current_thread(self): + return bool(getattr(self._local, 'stacks', ()) or getattr(self._local, 'bounded_depth', 0)) + + def __enter__(self): + stack = ExitStack() + try: + stack.enter_context(self.host) + if not self.session.acquire(timeout=CATALOG_LOCAL_TIMEOUT): + raise CatalogBusy('Project command ownership did not become available within its local wait bound.') + stack.callback(self.session.release) + stack.enter_context(self.projection) + if not hasattr(self._local, 'stacks'): + self._local.stacks = [] + self._local.stacks.append(stack) + return self + except BaseException: + stack.close() + raise + + def __exit__(self, kind, error, tb): + return self._local.stacks.pop().__exit__(kind, error, tb) + + @contextmanager + def bounded(self, deadline, cancelled=None): + with ExitStack() as stack: + stack.enter_context(self.host.bounded(deadline, cancelled)) + while not self.session.acquire(timeout=min(.1, max(0, deadline-time.monotonic()))): + if (cancelled and cancelled()) or time.monotonic() >= deadline: + raise CatalogBusy('Project command ownership reached its recovery deadline.') + stack.callback(self.session.release) + if (cancelled and cancelled()) or time.monotonic() >= deadline: + raise CatalogBusy('Project command ownership was cancelled or reached its recovery deadline.') + stack.enter_context(self.projection.bounded(deadline, cancelled)) + self._local.bounded_depth = getattr(self._local, 'bounded_depth', 0) + 1 + try: + yield self + finally: + self._local.bounded_depth -= 1 diff --git a/project_material.py b/project_material.py new file mode 100644 index 00000000..530ce34a --- /dev/null +++ b/project_material.py @@ -0,0 +1,783 @@ +"""Portable model/source/recipe facts, using their existing semantic owners. + +Open decodes inert metadata. Only explicit publication reads projection-local +captures and snapshots their exact bytes before Session publishes a checkpoint. +No runtime is provisioned, captured source imported, or model state loaded here. +""" +import copy +import hashlib +import os +import re +import shutil +import tempfile +from functools import wraps +from datetime import datetime +from pathlib import Path + +from workbench import WorkbenchError, timestamp, encode +from model_definitions import (decode_model_draft, decode_model_material, verify_model_closure, + reference, natural, retained_json, MAX_SOURCE_BYTES) +from pipeline import decode_graph, decode_recipe_draft, decode_recipe_material, verify_recipe_closure, digest, execution_digest +from project_store import ProjectStoreError + +FAMILIES = { + 'model_definition': 'model_definition_history_v1', + 'pipeline_graph': 'pipeline_graph_history_v1', + 'recipe_definition': 'recipe_definition_history_v1', + 'model_material': 'retained_model_material_v1', + 'recipe_material': 'retained_recipe_material_v1', + 'project_snapshot': 'retained_project_snapshot_v1', + 'runtime_material': 'retained_runtime_material_v1', +} +EDITABLE = ('model_definition', 'pipeline_graph', 'recipe_definition') +ID = re.compile(r'^[a-f0-9]{32}$') +SHA = re.compile(r'^[a-f0-9]{64}$') +AUDIT_FIELDS = {'action','target','details','result','actor_id','occurred_at'} +AUDIT_DETAILS = { + 'models.create':('revision','document_sha256'), + 'models.save':('revision','document_sha256'), + 'models.publish':('revision','material_sha256'), + 'pipeline.create':('revision','document_sha256','execution_sha256'), + 'pipeline.create_external':('revision','document_sha256','execution_sha256'), + 'pipeline.save':('revision','document_sha256','execution_sha256'), + 'pipeline.clone':('source_graph_id','revision','document_sha256','execution_sha256'), + 'pipeline.clone_retained':('id','revision','material'), + 'recipes.create':('revision','document_sha256'), + 'recipes.save':('revision','document_sha256'), + 'recipes.material':('recipe_id','revision'), +} +AUDIT_ACTIONS = frozenset(AUDIT_DETAILS) + + +def _boundary(function): + @wraps(function) + def checked(*args, **kwargs): + try:return function(*args, **kwargs) + except (WorkbenchError,ProjectStoreError):raise + except ImportError: + raise WorkbenchError('Retained material validator is unavailable.','unavailable',409) from None + except (KeyError, TypeError, ValueError, AttributeError, OverflowError, RecursionError, UnicodeError): + raise WorkbenchError('Malformed retained material document.','invalid',400) from None + return checked + + +def _closed(value, fields, label): + if type(value) is not dict or set(value) != set(fields): + raise WorkbenchError('Invalid retained ' + label + ' fields.') + + +def _catalog(value): + if type(value) is not str or not ID.fullmatch(value): + raise WorkbenchError('Invalid retained origin catalog.') + return value + + +def _intrinsic(item): + return {'sha256': item['sha256'], 'bytes': item['bytes']} + + +def _declaration(item): + return dict(format='project_artifact_v1', **_intrinsic(item)) + + +def _edges(items, family): + required = 'execution' if family == 'runtime_material' else 'snapshot_verification' if family == 'project_snapshot' else 'material_verification' if family == 'model_material' else 'composition_verification' + result=[] + for item in items: + runtime_file=family=='runtime_material' and item['name']!='manifest.json' + role='runtime_file' if runtime_file else 'runtime_manifest' if family=='runtime_material' else 'project_manifest' if family=='project_snapshot' else 'model_source' if family=='model_material' and item['name'].startswith('source-') else 'model_policy' if family=='model_material' else 'recipe_capture' if item['name'].startswith('capture-') else 'recipe_document' + result.append(dict(name=item['name'],blob=_intrinsic(item),role=role, + preservation='regenerable' if runtime_file else 'unique',required_for=[required])) + return result + + +def _inventory(family, value): + if family == 'model_material': + material = value['material'] + return [*(material[key] for key in ('specification', 'preprocessing_policy', 'output_policy')), + *(item['blob'] for item in material['source_closure'])] + if family == 'recipe_material': + material = value['material'] + return [material['recipe_document'], material['resolved_profile'], *material['captures']] + return [] + + +def _definition(family, value, catalog_id): + if family == 'model_definition': return decode_model_draft(value, catalog_id=catalog_id) + if family == 'recipe_definition': return decode_recipe_draft(value, catalog_id=catalog_id) + if family == 'pipeline_graph': return copy.deepcopy(decode_graph(value)) + raise WorkbenchError('Unsupported material definition family.', 'unsupported', 409) + + +def _audit(value): + _closed(value,AUDIT_FIELDS,'material audit') + if (value['action'] not in AUDIT_ACTIONS or value['result']!='success' or + type(value['target']) is not str or not 1<=len(value['target'])<=128 or + value['actor_id'] is not None and (type(value['actor_id']) is not str or not ID.fullmatch(value['actor_id'])) or + type(value['occurred_at']) is not str or len(value['occurred_at'])>80): + raise WorkbenchError('Invalid material audit fact.') + target_pattern=SHA if value['action']=='recipes.material' else ID + if not target_pattern.fullmatch(value['target']):raise WorkbenchError('Invalid material audit target.') + details=value['details'];_closed(details,AUDIT_DETAILS[value['action']],'material action details') + natural(details['revision'],'Audit revision',1) + for key,item in details.items(): + if key.endswith('_sha256') and (type(item) is not str or not SHA.fullmatch(item)): + raise WorkbenchError('Invalid material audit digest.') + if key in ('id','recipe_id','source_graph_id') and (type(item) is not str or not ID.fullmatch(item)): + raise WorkbenchError('Invalid material audit source identity.') + if 'material' in details: + _catalog(details['material']['catalog_id']) + reference(details['material'],'recipe_material',details['material']['catalog_id']) + instant=datetime.fromisoformat(value['occurred_at']) + if instant.utcoffset() is None:raise WorkbenchError('Material audit requires an offset timestamp.') + + +def _definition_audit(family,value,audit,catalog_id): + _audit(audit) + actions={'model_definition':('models.create','models.save'), + 'recipe_definition':('recipes.create','recipes.save'), + 'pipeline_graph':('pipeline.create','pipeline.create_external','pipeline.save','pipeline.clone','pipeline.clone_retained')} + if audit['action'] not in actions[family] or audit['target']!=value['id']: + raise WorkbenchError('Definition audit action or identity differs.') + details=audit['details'] + if audit['action']=='pipeline.clone_retained': + _closed(details,('id','revision','material'),'retained clone audit') + if not ID.fullmatch(details['id']) or value['revision']!=1: + raise WorkbenchError('Retained clone audit identity differs.') + natural(details['revision'],'Source recipe revision',1) + reference(details['material'],'recipe_material',catalog_id) + elif details.get('revision')!=value['revision']: + raise WorkbenchError('Definition audit revision differs.') + if audit['action'].endswith(('.create','.create_external','.clone','.clone_retained')) and value['revision']!=1: + raise WorkbenchError('Created definition audit must identify its initial revision.') + if audit['action'].endswith('.save') and value['revision']<2: + raise WorkbenchError('Saved definition audit requires a successor revision.') + if 'document_sha256' in details and details['document_sha256']!=digest(value): + raise WorkbenchError('Definition audit document hash differs.') + if 'execution_sha256' in details and details['execution_sha256']!=execution_digest(value): + raise WorkbenchError('Definition audit execution hash differs.') + + +class _MaterialAccess: + """Host authorization plus transaction-scoped inert scientific audit facts.""" + def __init__(self,session):self.session=session;self.host=session.access;self.facts=[];self.registered=False + def require(self,*args,**kwargs):return self.host.require(*args,**kwargs) + def denial(self,*args,**kwargs):return self.host.denial(*args,**kwargs) + def _context(self):return self.host._context() + @property + def lock(self):return self.host.lock + def audit(self,action,target=None,details=None,result='success'): + if action not in AUDIT_ACTIONS or result!='success' or not self.session.db.in_transaction: + raise WorkbenchError('Material audit must share its canonical mutation.','unavailable',409) + actor=self.host.require('edit',action,target) + fact=dict(action=action,target=target,details=copy.deepcopy(details or {}),result=result, + actor_id=actor.actor_id,occurred_at=timestamp()) + _audit(fact) + if not self.registered: + self.registered=True + def clear():self.facts.clear();self.registered=False + self.session.db.on_commit(clear);self.session.db.on_rollback(clear) + self.facts.append(fact) + + +@_boundary +def decode_records(records): + """Closed family-local metadata checks; missing payloads do not erase facts.""" + result = {} + for envelope in records: + if envelope.get('family') not in FAMILIES: + continue + _closed(envelope, ('family', 'id', 'document'), 'material envelope') + family, doc = envelope['family'], envelope['document'] + if family in EDITABLE: + _closed(doc, ('format', 'origin_catalog_id', 'current', 'revisions', 'audit') + + (('fork_origin',) if 'fork_origin' in doc else ()), 'definition history') + else: + _closed(doc, ('format', 'origin_catalog_id', 'value', 'artifacts','audit'), 'immutable material') + if doc['format'] != FAMILIES[family]: + raise WorkbenchError('Unsupported material document format.', 'unsupported', 409) + catalog_id = _catalog(doc['origin_catalog_id']) + if family in EDITABLE: + revisions = doc['revisions'] + forked = 'fork_origin' in doc + if (type(revisions) is not list or not revisions or type(doc['audit']) is not list or + len(doc['audit']) != len(revisions) - int(forked)): + raise WorkbenchError('Definition history must retain its observed revisions and audit.') + previous = None + for index, value in enumerate(revisions): + from project_material_fork import is_intent, decode_intent + if is_intent(value): + if not forked or index != 0 or len(revisions) != 1: + raise WorkbenchError('Unresolved fork intents cannot acquire owner revisions.') + value = decode_intent(value, family, catalog_id) + else: + value = _definition(family, value, catalog_id) + if previous and (value['id'] != previous['id'] or value['revision'] != previous['revision'] + 1): + raise WorkbenchError('Definition history identity or revision changed.') + if not forked or index: + _definition_audit(family, value, doc['audit'][index - int(forked)], catalog_id) + previous = value + if doc['current'] != revisions[-1]: + raise WorkbenchError('Definition current value differs from its history.') + expected = catalog_id + '.' + doc['current']['id'] + else: + if type(doc['audit']) is not list:raise WorkbenchError('Material audit must retain its fact list.') + for audit in doc['audit']:_audit(audit) + value = doc['value'] + if family == 'model_material': value = decode_model_material(value, catalog_id=catalog_id) + elif family == 'recipe_material': value = decode_recipe_material(value, catalog_id=catalog_id) + elif family == 'project_snapshot': + from projects import decode_retained_snapshot + value = decode_retained_snapshot(value) + else: + value = decode_runtime_material(value, catalog_id=catalog_id) + for audit in doc['audit']: + if family=='model_material': + if (audit['action']!='models.publish' or audit['target']!=value['ref']['id'] or + audit['details']!={'revision':value['ref']['revision'],'material_sha256':value['ref']['sha256']}): + raise WorkbenchError('Published Model audit differs.') + elif family=='recipe_material': + if (audit['action']!='recipes.material' or audit['target']!=value['ref']['sha256'] or + audit['details']!={'recipe_id':value['material']['recipe_id'],'revision':value['material']['recipe_revision']}): + raise WorkbenchError('Retained Recipe audit differs.') + else:raise WorkbenchError('Unsupported immutable material audit fact.') + inventory = _inventory(family, value) + if family == 'project_snapshot': + if type(doc['artifacts']) is not list or len(doc['artifacts'])!=1:raise WorkbenchError('Snapshot requires its exact manifest edge.') + size=doc['artifacts'][0]['blob']['bytes'];natural(size,'Manifest bytes',1,64*1024) + inventory=[dict(name=value['manifest_path'],sha256=value['manifest_sha256'],bytes=size)] + if family == 'runtime_material': + inventory=([dict(name='manifest.json',sha256=value['descriptor']['manifest_sha256'],bytes=value['descriptor']['manifest_bytes'])]+ + [dict(name=item['path'],sha256=item['sha256'],bytes=item['bytes']) for item in value['manifest']['files']]) if value['descriptor'] is not None else [] + if type(doc['artifacts']) is not list or doc['artifacts'] != _edges(inventory,family): + raise WorkbenchError('Retained material artifact edges differ from the owner inventory.') + expected = catalog_id + '.' + (value['manifest_sha256'] if family == 'project_snapshot' else value['ref']['sha256']) + if envelope['id'] != expected or (family, expected) in result: + raise WorkbenchError('Retained material locator differs or repeats.') + result[(family, expected)] = copy.deepcopy(doc) + from project_material_fork import validate_records + validate_records(records) + return result + + +@_boundary +def decode_runtime_material(value, *, catalog_id): + """Keep original physical descriptors as historical facts, never live bindings.""" + _closed(value, ('ref', 'descriptor', 'environment','manifest'), 'runtime material') + reference(value['ref'], 'runtime', catalog_id) + descriptor = value['descriptor'] + if descriptor is None: + if value['manifest'] is not None:raise WorkbenchError('Built-in environment cannot carry an external runtime manifest.') + if type(value['environment']) is not dict or digest(value['environment']) != value['ref']['sha256']: + raise WorkbenchError('Retained built-in runtime identity differs.') + else: + if value['environment'] is not None: + raise WorkbenchError('Retained runtime cannot mix descriptor and environment.') + if descriptor.get('profile') == 'captured_cpu_python_v1': + from recipe_process import validate_cpu_snapshot_manifest,encoded + manifest=validate_cpu_snapshot_manifest(descriptor,value['manifest']) + elif descriptor.get('profile') == 'managed_torch_python_v1': + from torch_runtime import verify_descriptor,encoded + manifest=verify_descriptor(descriptor,value['manifest']) + else: raise WorkbenchError('Unsupported retained runtime profile.', 'unsupported', 409) + raw=encoded(manifest) + if len(raw)!=descriptor['manifest_bytes'] or hashlib.sha256(raw).hexdigest()!=descriptor['manifest_sha256']: + raise WorkbenchError('Retained runtime manifest identity differs.') + if descriptor['catalog_id'] != catalog_id or digest(descriptor) != value['ref']['sha256']: + raise WorkbenchError('Retained runtime descriptor identity differs.') + return copy.deepcopy(value) + + +@_boundary +def validate_operation_result(operation, result, records=None): + """True for a validated owned result; False for another fixed owner.""" + if operation in ('models.create','models.save'): + decode_model_draft(result,catalog_id=result['task']['catalog_id']);family='model_definition' + elif operation=='models.publish': + decode_model_material(result,catalog_id=result['ref']['catalog_id']);family='model_material' + elif operation in ('graphs.create','graphs.create_external','graphs.save','graphs.clone'): + decode_graph(result);family='pipeline_graph' + elif operation in ('recipes.create','recipes.save'): + decode_recipe_draft(result,catalog_id=result['task']['catalog_id']);family='recipe_definition' + elif operation in ('recipes.material','recipes.prepare_external_material','recipes.prepare_builtin_material'): + decode_recipe_material(result,catalog_id=result['ref']['catalog_id']);family='recipe_material' + else:return False + if family in EDITABLE: + create=operation.endswith(('.create','.create_external','.clone')) + if create and result['revision']!=1 or not create and result['revision']<2: + raise WorkbenchError('Retained definition result revision differs from its operation.') + if records is not None: + documents=decode_records(records) + values=[value for (owned,_),doc in documents.items() if owned==family + for value in (doc['revisions'] if family in EDITABLE else [doc['value']])] + if not any(encode(value)==encode(result) for value in values): + raise WorkbenchError('Material operation result differs from its retained family closure.') + return True + + +class ProjectMaterial: + def __init__(self, session): + self.session = session + self.owner_access = _MaterialAccess(session) if hasattr(session,'access') else None + + def _documents(self): + self.session.check() + return decode_records(self.session.snapshot['records']) + + @_boundary + def intents(self): + """Inspect unresolved active definitions; they confer no execution binding.""" + if self.owner_access: + self.session.access.require('inspect', 'project_material.intents') + from project_fork import active_records + from project_material_fork import is_intent + documents = self._documents() + active = {(r['family'], r['id']) for r in active_records( + self.session.snapshot['records'], project_id=self.session.snapshot['project_id'])} + return [dict(family=family, id=locator, current=copy.deepcopy(doc['current']), + fork_origin=copy.deepcopy(doc['fork_origin'])) + for (family, locator), doc in sorted(documents.items()) + if (family, locator) in active and family in EDITABLE and is_intent(doc['current'])] + + @_boundary + def intent_capabilities(self): + intents = self.intents() + unresolved = {(r['family'],r['id']) for r in intents} + from project_fork import active_records + active = active_records(self.session.snapshot['records'], project_id=self.session.snapshot['project_id']) + return dict(requires_reselection=[dict(family=r['family'],id=r['id'],fields=r['current']['required_reselection']) for r in intents], + editable=[dict(family=r['family'],id=r['id']) for r in active + if r['family'] in EDITABLE and (r['family'],r['id']) not in unresolved]) + + def _require_resolved(self, family, identity): + for intent in self.intents(): + if intent['family'] == family and intent['current']['id'] == identity: + raise WorkbenchError('Fork intent requires explicit binding reselection.', 'requires_reselection', 409) + + @_boundary + def _value(self, family, ref): + catalog_id = _catalog(ref['catalog_id']) + reference(ref,{'model_material':'model','recipe_material':'recipe_material','runtime_material':'runtime'}[family],catalog_id) + key = catalog_id + '.' + ref['sha256'] + document = self._documents().get((family, key)) + if document is None: raise WorkbenchError('Retained material is unavailable.', 'unavailable', 409) + if document['value']['ref'] != ref: raise WorkbenchError('Retained material reference differs.', 'invalid', 400) + return copy.deepcopy(document['value']) + + @_boundary + def model(self, ref, *, verify=False): + value = self._value('model_material', ref) + if verify: verify_model_closure(value, read_blob=lambda item, maximum: self.blob(ref, item, maximum)) + return value + + @_boundary + def recipe(self, ref, *, verify=False): + value = self._value('recipe_material', ref) + if verify: self.composition_closure(ref) + return value + + @_boundary + def blob(self, ref, item, maximum=40*1024**2): + family = 'model_material' if ref['kind'] == 'model' else 'recipe_material' + value = self._value(family, ref) + if item not in _inventory(family, value): raise WorkbenchError('Undeclared material blob.', 'invalid', 400) + return self.session.artifacts.read_blob(_intrinsic(item), maximum_bytes=maximum) + + @_boundary + def composition_closure(self, ref): + value = self.recipe(ref) + return verify_recipe_closure(value, self.model(value['material']['model']), read_blob=self.blob) + + @_boundary + def runtime(self, ref, *, verify=False): + value = self._value('runtime_material', ref) + if verify and value['descriptor'] is not None: + descriptor = value['descriptor'] + raw = self.session.artifacts.read_blob({'sha256': descriptor['manifest_sha256'], 'bytes': descriptor['manifest_bytes']}, maximum_bytes=32*1024**2) + if descriptor['profile'] == 'captured_cpu_python_v1': + from recipe_process import decode, encoded, validate_cpu_manifest + manifest = validate_cpu_manifest(decode(raw)) + else: + from torch_runtime import decode, encoded, validate_manifest + manifest = validate_manifest(decode(raw)) + if encoded(manifest) != raw: raise WorkbenchError('Retained runtime manifest encoding differs.', 'invalid', 400) + return value + + @_boundary + def snapshot_ref(self, project_id, manifest_sha256, *, catalog_id=None, verify=False): + candidates = [doc for (family, _), doc in self._documents().items() if family == 'project_snapshot' and + doc['value']['id'] == project_id and doc['value']['manifest_sha256'] == manifest_sha256 and + (catalog_id is None or doc['origin_catalog_id'] == catalog_id)] + if len(candidates) != 1: raise WorkbenchError('Retained project snapshot is unavailable or ambiguous.', 'unavailable', 409) + doc = candidates[0] + if verify: + from projects import decode, manifest + edge = doc['artifacts'][0]['blob'] + raw = self.session.artifacts.read_blob(edge, maximum_bytes=64*1024) + if manifest(decode(raw)) != doc['value']['manifest']: + raise WorkbenchError('Retained project manifest differs.', 'invalid', 400) + return copy.deepcopy(doc['value']) + + def capture_definition(self, family, value, before_records, *, action, actor_id=None, audit_facts=None): + catalog_id = self.session.snapshot['project_id'] + value = _definition(family, value, catalog_id) + key = catalog_id + '.' + value['id'] + prior = decode_records(before_records).get((family, key)) + revisions = copy.deepcopy(prior['revisions']) if prior else [] + audits = copy.deepcopy(prior['audit']) if prior else [] + if revisions and value == revisions[-1]: + return {'family': family, 'id': key, 'document': prior} + if revisions and value['revision'] != revisions[-1]['revision'] + 1: + raise WorkbenchError('Definition changed outside its canonical history.', 'stale', 409) + details={'revision':value['revision'],'document_sha256':digest(value)} + if family=='pipeline_graph':details['execution_sha256']=execution_digest(value) + facts=copy.deepcopy(audit_facts) if audit_facts is not None else [dict(action=action,target=value['id'],details=details,result='success',actor_id=actor_id,occurred_at=timestamp())] + if len(facts)!=1:raise WorkbenchError('A definition revision requires its exact owner audit fact.') + revisions.append(copy.deepcopy(value));audits.extend(facts) + record = dict(family=family, id=key, document=dict(format=FAMILIES[family], origin_catalog_id=catalog_id, + current=copy.deepcopy(value), revisions=revisions, audit=audits)) + if prior and 'fork_origin' in prior: + from project_material_fork import is_intent + if is_intent(prior['current']): + raise WorkbenchError('Fork intent requires explicit binding reselection.', 'requires_reselection', 409) + record['document']['fork_origin'] = copy.deepcopy(prior['fork_origin']) + decode_records([r for r in before_records if (r['family'],r['id']) != (family,key)] + [record]) + else: + decode_records([record]) + return record + + def _capture_material(self, family, value, root): + catalog_id = value['ref']['catalog_id'] + value = decode_model_material(value, catalog_id=catalog_id) if family == 'model_material' else decode_recipe_material(value, catalog_id=catalog_id) + inventory = _inventory(family, value) + root_fd = material_fd = None + try: + root_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + material_fd = os.open(value['ref']['sha256'], os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=root_fd) + for item in inventory: + fd = os.open(item['name'], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=material_fd) + try: self.session.artifacts.publish_fd(fd, _declaration(item), maximum_bytes=item['bytes']) + finally: os.close(fd) + except OSError: + raise WorkbenchError('Projection-local material capture is unavailable.','unavailable',409) from None + finally: + if material_fd is not None: os.close(material_fd) + if root_fd is not None: os.close(root_fd) + record = dict(family=family, id=catalog_id+'.'+value['ref']['sha256'], document=dict( + format=FAMILIES[family], origin_catalog_id=catalog_id, value=copy.deepcopy(value), artifacts=_edges(inventory,family), + audit=copy.deepcopy([fact for fact in self.owner_access.facts + if fact['action']==('models.publish' if family=='model_material' else 'recipes.material') + and fact['target'] in (value['ref'].get('id'),value['ref']['sha256'])]) if self.owner_access else [])) + prior=decode_records(self.session.snapshot['records']).get((family,record['id'])) + if prior is not None: + if prior['value']!=record['document']['value'] or prior['artifacts']!=record['document']['artifacts']: + raise WorkbenchError('Retained immutable material changed.','invalid',400) + facts=copy.deepcopy(prior['audit']) + facts.extend(fact for fact in record['document']['audit'] if fact not in facts) + record['document']['audit']=facts + decode_records([record]); return record + + @staticmethod + def declarations(record): + """Explicit intrinsic declaration upserts, coalesced without hidden state.""" + result={} + for edge in record['document'].get('artifacts',[]): + item=edge['blob']; declaration=_declaration(item) + if item['sha256'] in result and result[item['sha256']]['document']!=declaration: + raise WorkbenchError('Artifact declarations disagree.','invalid',400) + result[item['sha256']]=dict(family='project_artifact',id=item['sha256'],document=declaration) + return list(result.values()) + + def capture_model_material(self, value, root): return self._capture_material('model_material', value, root) + def capture_recipe_material(self, value, root): return self._capture_material('recipe_material', value, root) + + @staticmethod + def _coalesce(records): + result={} + for record in records: + key=record['family'],record['id'] + if key in result and result[key]!=record: + raise WorkbenchError('Owned material upserts disagree.','invalid',400) + result[key]=record + return list(result.values()) + + @_boundary + def capture_recipe_dependencies(self,record,before_records,*,runtime_manifest=None): + """Retain exact measured runtime facts without using origin paths. + + Captured runtime manifests must be supplied by an explicit trusted + current-runtime reader, or already be part of the canonical checkpoint. + This method never provisions or captures runtime binaries. + """ + value=record['document']['value'];ref=value['material']['runtime'] + snapshot=retained_json(self.session.artifacts.read_blob( + _intrinsic(value['material']['recipe_document']),maximum_bytes=2*1024**2).decode(),2*1024**2) + if snapshot['format']=='builtin_recipe_snapshot_v1':environment=snapshot['validation']['runtime'] + else: + execution=snapshot['preflight']['material'] + environment=execution.get('torch_runtime',execution.get('cpu_snapshot',execution['runtime'])) + descriptor=environment if environment.get('profile') in ('captured_cpu_python_v1','managed_torch_python_v1') else None + prior=decode_records(before_records).get(('runtime_material',ref['catalog_id']+'.'+ref['sha256'])) + if prior is not None: + expected=prior['value']['descriptor'] if descriptor is not None else prior['value']['environment'] + if expected!=environment or prior['value']['ref']!=ref: + raise WorkbenchError('Recipe and retained runtime identity differ.','invalid',400) + if descriptor is not None: + if runtime_manifest is not None: + raw=runtime_manifest(copy.deepcopy(descriptor)) if callable(runtime_manifest) else runtime_manifest + retained=self.capture_runtime(prior['value'],raw) + return [*self.declarations(retained),retained] + self.session.artifacts.read_blob({'sha256':descriptor['manifest_sha256'],'bytes':descriptor['manifest_bytes']},maximum_bytes=32*1024**2) + return [] + manifest=None + manifest_raw=None + if descriptor is not None: + manifest_raw=runtime_manifest(copy.deepcopy(descriptor)) if callable(runtime_manifest) else runtime_manifest + if type(manifest_raw) is not bytes: + raise WorkbenchError('Explicit captured runtime manifest is unavailable.','unavailable',409) + if descriptor['profile']=='captured_cpu_python_v1': + from recipe_process import decode,validate_cpu_manifest + manifest=validate_cpu_manifest(decode(manifest_raw)) + else: + from torch_runtime import decode,validate_manifest + manifest=validate_manifest(decode(manifest_raw)) + runtime=self.capture_runtime(dict(ref=ref,descriptor=descriptor, + environment=None if descriptor is not None else environment,manifest=manifest),manifest_raw) + return [*self.declarations(runtime),runtime] + + @_boundary + def capture_snapshot(self, value, raw, *, catalog_id): + from projects import decode_retained_snapshot,decode,manifest + value=decode_retained_snapshot(value);_catalog(catalog_id) + if type(raw) is not bytes or len(raw)>64*1024 or hashlib.sha256(raw).hexdigest()!=value['manifest_sha256'] or manifest(decode(raw))!=value['manifest']: + raise WorkbenchError('Exact original project manifest bytes differ.') + key=catalog_id+'.'+value['manifest_sha256'] + prior=decode_records(self.session.snapshot['records']).get(('project_snapshot',key)) + if prior is not None: + original=prior['value'] + if any(original[field]!=value[field] for field in ('id','name','manifest_sha256','manifest')): + raise WorkbenchError('Original snapshot identity differs.','invalid',400) + # Explicit current root registration may move. It cannot replace + # original historical root/path identity facts at the same hash. + return dict(family='project_snapshot',id=key,document=prior) + item=dict(name=value['manifest_path'],sha256=value['manifest_sha256'],bytes=len(raw)) + self.session.artifacts.publish_bytes(raw,_declaration(item),maximum_bytes=64*1024) + record=dict(family='project_snapshot',id=catalog_id+'.'+value['manifest_sha256'],document=dict( + format=FAMILIES['project_snapshot'],origin_catalog_id=catalog_id,value=value,artifacts=_edges([item],'project_snapshot'),audit=[])) + decode_records([record]);return record + + @_boundary + def capture_runtime(self,value,manifest_raw=None): + value=decode_runtime_material(value,catalog_id=value['ref']['catalog_id']) + items=[] + if value['descriptor'] is not None: + descriptor=value['descriptor'] + if type(manifest_raw) is not bytes or len(manifest_raw)!=descriptor['manifest_bytes'] or hashlib.sha256(manifest_raw).hexdigest()!=descriptor['manifest_sha256']: + raise WorkbenchError('Exact original runtime manifest bytes differ.') + item=dict(name='manifest.json',sha256=descriptor['manifest_sha256'],bytes=len(manifest_raw)) + self.session.artifacts.publish_bytes(manifest_raw,_declaration(item),maximum_bytes=32*1024**2) + items=[item,*[dict(name=row['path'],sha256=row['sha256'],bytes=row['bytes']) for row in value['manifest']['files']]] + record=dict(family='runtime_material',id=value['ref']['catalog_id']+'.'+value['ref']['sha256'],document=dict( + format=FAMILIES['runtime_material'],origin_catalog_id=value['ref']['catalog_id'],value=value,artifacts=_edges(items,'runtime_material'),audit=[])) + decode_records([record]);return record + + def restore(self): + """Rebuild only current-namespace SQL values, without mutable owners.""" + documents=self._documents();catalog_id=self.session.db.dataset_id + from project_fork import active_records + from project_material_fork import is_intent + active={(r['family'],r['id']) for r in active_records(self.session.snapshot['records'],project_id=catalog_id)} + with self.session.db.transaction(): + for table in ('model_definition_drafts','workspace_graphs','workspace_recipe_drafts','model_materials','workspace_recipe_materials'): + self.session.db.execute('DELETE FROM '+table) + for (family,locator),doc in documents.items(): + if (family,locator) not in active or doc['origin_catalog_id']!=catalog_id:continue + if family in EDITABLE and is_intent(doc['current']):continue + if family in EDITABLE: + value=doc['current'] + table={'model_definition':'model_definition_drafts','pipeline_graph':'workspace_graphs','recipe_definition':'workspace_recipe_drafts'}[family] + self.session.db.execute('INSERT INTO '+table+' VALUES(%s,%s,%s)',(value['id'],value['revision'],encode(value))) + elif family=='model_material': + value=doc['value'];ref=value['ref'] + self.session.db.execute('INSERT INTO model_materials VALUES(%s,%s,%s,%s)',(ref['id'],ref['revision'],ref['sha256'],encode(value))) + elif family=='recipe_material': + value=doc['value'];material=value['material'] + self.session.db.execute('INSERT INTO workspace_recipe_materials VALUES(%s,%s,%s,%s)',(value['ref']['sha256'],material['recipe_id'],material['recipe_revision'],encode(value))) + + def reconcile_projection(self): + """Restore selected canonical material facts without deleting local custody. + + The caller holds projection/session/access guards and has drained custody. + Immutable SQL conflicts fence recovery instead of replacing hash identity. + """ + documents = self._documents() + catalog_id = self.session.db.dataset_id + from project_fork import active_records + from project_material_fork import is_intent + active = {(r['family'], r['id']) for r in active_records( + self.session.snapshot['records'], project_id=catalog_id)} + with self.session.db.transaction(): + for (family, locator), doc in documents.items(): + if (family, locator) not in active or doc['origin_catalog_id'] != catalog_id: + continue + if family in EDITABLE: + value = doc['current'] + if is_intent(value): continue + table = {'model_definition':'model_definition_drafts', 'pipeline_graph':'workspace_graphs', + 'recipe_definition':'workspace_recipe_drafts'}[family] + self.session.db.execute('INSERT INTO '+table+'(id,revision,document) VALUES(%s,%s,%s) ' + 'ON CONFLICT(id) DO UPDATE SET revision=EXCLUDED.revision,document=EXCLUDED.document', + (value['id'], value['revision'], encode(value))) + elif family in ('model_material', 'recipe_material'): + value = doc['value']; ref = value['ref'] + table = 'model_materials' if family == 'model_material' else 'workspace_recipe_materials' + if family == 'model_material': + rows = self.session.db.execute('SELECT id,revision,sha256,document FROM model_materials ' + 'WHERE sha256=%s OR (id=%s AND revision=%s)', + (ref['sha256'],ref['id'],ref['revision'])).fetchall() + consistent = len(rows) == 1 and tuple(rows[0][:3]) == (ref['id'],ref['revision'],ref['sha256']) and retained_json(rows[0][3]) == value + else: + rows = self.session.db.execute('SELECT recipe_id,recipe_revision,document FROM workspace_recipe_materials ' + 'WHERE sha256=%s', (ref['sha256'],)).fetchall() + consistent = len(rows) == 1 and tuple(rows[0][:2]) == (value['material']['recipe_id'],value['material']['recipe_revision']) and retained_json(rows[0][2]) == value + if rows: + if not consistent: + raise WorkbenchError('Immutable projection material differs from its canonical hash.', + 'projection_unavailable', 503) + continue + if family == 'model_material': + self.session.db.execute('INSERT INTO model_materials(id,revision,sha256,document) VALUES(%s,%s,%s,%s)', + (ref['id'], ref['revision'], ref['sha256'], encode(value))) + else: + material = value['material'] + self.session.db.execute('INSERT INTO workspace_recipe_materials(sha256,recipe_id,recipe_revision,document) ' + 'VALUES(%s,%s,%s,%s)', (ref['sha256'],material['recipe_id'],material['recipe_revision'],encode(value))) + + def materialize_projection(self,ref,root): + """Explicit preparation may recreate a disposable owner byte cache. + + The destination must live inside the current physical SQL projection. + Canonical files and original runtime paths are never cleanup targets. + """ + root=Path(root) + if not root.resolve().is_relative_to(Path(self.session.db.path).resolve()): + raise WorkbenchError('Material cache must belong to the disposable projection.','invalid',400) + value=self.model(ref) if ref['kind']=='model' else self.recipe(ref) + family='model_material' if ref['kind']=='model' else 'recipe_material' + inventory=_inventory(family,value) + root.mkdir(mode=0o700,parents=True,exist_ok=True) + target=root/ref['sha256'] + if target.exists(): + from model_definitions import read_owned_blob + if target.is_symlink() or set(os.listdir(target))!={item['name'] for item in inventory}: + raise WorkbenchError('Disposable material cache differs.','unavailable',409) + for item in inventory:read_owned_blob(root,ref['sha256'],item,item['bytes']) + return target + staging=Path(tempfile.mkdtemp(prefix='.material-rebuild-',dir=root)) + try: + for item in inventory: + with self.session.artifacts.open_verified(_declaration(item),maximum_bytes=item['bytes']) as source: + with (staging/item['name']).open('xb') as destination: + while True: + raw=os.read(source,1024**2) + if not raw:break + destination.write(raw) + destination.flush();os.fchmod(destination.fileno(),0o400);os.fsync(destination.fileno()) + from metadata import _sync_directory + _sync_directory(staging) + # The Session lock serializes owner cache publication. An externally + # created target is refused, never adopted or overwritten. + if os.path.lexists(target):raise WorkbenchError('Material cache changed during reconstruction.','stale',409) + os.rename(staging,target);_sync_directory(root) + return target + finally: + if staging.exists():shutil.rmtree(staging) + + def prepare_recipe(self,owner,graph_id,revision,model_ref,expected_checkpoint,key,*,builtin=False,reconcile_capture=False,runtime_manifest=None): + self._require_resolved('pipeline_graph', graph_id) + operation='recipes.prepare_builtin_material' if builtin else 'recipes.prepare_external_material' + self.session.access.require('edit',operation,graph_id,owner_only=reconcile_capture and not builtin) + if builtin: + if reconcile_capture: + raise WorkbenchError('Built-in orphan capture reconciliation is unsupported.','unsupported',409) + from project_native_preparation import preparation_owner + current=preparation_owner(owner) + if current is None:raise WorkbenchError('Current project preparation for the runtime probe is unavailable.','unavailable',409) + return current.prepare_recipe(owner,graph_id,revision,model_ref,expected_checkpoint,key) + body=dict(graph_id=graph_id,revision=revision,model=model_ref,reconcile_capture=reconcile_capture) + def invoke(): + self.materialize_projection(model_ref,owner.dataset.models.path) + prepare=owner.prepare_builtin_material if builtin else owner.prepare_external_material + prepared=prepare(graph_id,revision,model_ref,reconcile_capture=reconcile_capture) + return owner._retain_material(prepared) + def capture(result,before): + record=self.capture_recipe_material(result,owner.material_path) + return {'records':self._coalesce([*self.declarations(record),record, + *self.capture_recipe_dependencies(record,before,runtime_manifest=runtime_manifest)]),'result':result} + return self.session.mutate(operation,graph_id,body,expected_checkpoint,key,invoke,capture) + + def mutate_definition(self, family, operation, target, body, expected_checkpoint, key, invoke): + authority = self.session.access.require('edit', operation, target) + def capture(result, before): + validate_operation_result(operation, result) + return {'records': [self.capture_definition(family,result,before,action=operation,actor_id=authority.actor_id, + audit_facts=[fact for fact in self.owner_access.facts if fact['target']==result['id']])], 'result': result} + return self.session.mutate(operation, target, body, expected_checkpoint, key, invoke, capture) + + def publish_model(self, owner, model_id, body, expected_checkpoint, key): + self._require_resolved('model_definition', model_id) + self.session.access.require('edit', 'models.publish', model_id, owner_only=body.get('reconcile_capture', False)) + def capture(result, before): + record=self.capture_model_material(result,owner.path) + return {'records': [*self.declarations(record),record], 'result': result} + return self.session.mutate('models.publish', model_id, body, expected_checkpoint, key, + lambda: owner.publish(model_id, body), capture) + + def retain_recipe(self, owner, prepared, expected_checkpoint, key, *, runtime_manifest=None): + value = prepared['document']; target = value['material']['recipe_id'] + self.session.access.require('edit', 'recipes.prepare_external_material', target, + owner_only=prepared.get('reconcile_capture', False)) + body = {'ref': value['ref']} + def capture(result, before): + record=self.capture_recipe_material(result,owner.material_path) + return {'records': self._coalesce([*self.declarations(record),record, + *self.capture_recipe_dependencies(record,before,runtime_manifest=runtime_manifest)]), 'result': result} + return self.session.mutate('recipes.material', target, body, expected_checkpoint, key, + lambda: owner._retain_material(prepared), capture) + + @_boundary + def evidence(self, run, selectors=None, *, read_blob=None): + import model_evidence + import recipe_contract + selectors = {} if selectors is None else selectors + inventory = {item['name']: item for item in run['artifacts']} + def read(name, maximum): + if name not in inventory: raise WorkbenchError('Evidence artifact is unavailable.', 'unavailable', 409) + item = inventory[name] + raw = read_blob(name, maximum) if read_blob else self.session.artifacts.read_blob(_intrinsic(item), maximum_bytes=maximum) + if type(raw) is not bytes or len(raw)>maximum or len(raw)!=item['bytes'] or hashlib.sha256(raw).hexdigest()!=item['sha256']: + raise WorkbenchError('Evidence artifact differs.', 'invalid', 400) + return raw + if run.get('extension_context') is not None: + import extension_evidence + ref = {'kind': 'recipe_material', 'catalog_id': run['dataset_id'], 'id': run['approval_material_sha256'], 'sha256': run['approval_material_sha256']} + composition = self.recipe(ref); closure = self.composition_closure(ref) + context = extension_evidence.validate_context(run['extension_context'], run, composition, closure['static_context']) + verified = extension_evidence.verify_blobs(run, context, read_blob=read, captured=closure['captured_inputs']) + return extension_evidence.query(verified, selectors) + from pipeline import adapter_for_graph, model_aware_graph + import image_training + if not model_aware_graph(run['graph']): return {'state': 'unsupported', 'reason': 'This retained adapter did not capture model_evidence_v1.'} + if set(selectors) - {'checkpoint_sha256', 'split'} or selectors.get('split', 'validation') != 'validation': + raise WorkbenchError('Unsupported retained evidence selector.', 'unsupported', 409) + if adapter_for_graph(run['graph']) is image_training: evaluator = model_evidence + else: + import one_fit_evidence as evaluator + result, descriptors, predictions = evaluator.verify_blobs(run, run['metrics'], read_blob=read) + result.update(checkpoint=None, predictions=None, metric_definition_hashes={item['id']: model_evidence.digest(item) for item in result['metric_definitions']}) + selected = selectors.get('checkpoint_sha256') + if selected is not None: + matches = [index for index, entry in enumerate(result['checkpoint_index']['entries']) if entry['checkpoint']['sha256'] == selected] + if not matches: raise WorkbenchError('Checkpoint does not belong to this Run.', 'not_found', 404) + result.update(checkpoint=descriptors[matches[0]], predictions=predictions[matches[0]]) + return result diff --git a/project_material_fork.py b/project_material_fork.py new file mode 100644 index 00000000..deb35c8c --- /dev/null +++ b/project_material_fork.py @@ -0,0 +1,202 @@ +"""Pure, finite reconstruction of editable materials for an independent fork. + +Original definitions and hash-bearing materials remain unchanged in the archive. +Unresolved bindings are retained as inert, inspectable intents, never SQL drafts. +""" +import copy +from datetime import datetime + +from workbench import WorkbenchError + +INTENT_FORMAT = 'project_material_fork_intent_v1' +OWNED = ('model_definition', 'pipeline_graph', 'recipe_definition') + + +def _fail(message): + raise WorkbenchError(message, 'invalid', 400) + + +def _date(value): + if type(value) is not str or len(value) > 80: + _fail('Fork creation requires an offset timestamp.') + try: + instant = datetime.fromisoformat(value) + if instant.utcoffset() is None: raise ValueError() + except ValueError: + _fail('Fork creation requires an offset timestamp.') + + +def is_intent(value): + return type(value) is dict and value.get('format') == INTENT_FORMAT + + +def _model_bindings(value, destination): + """Qualify only exact pure templates accepted by the existing task owners.""" + import image_training + import model_evidence + import one_fit_evidence + import text_training + import audio_training + task = value['task'] + owners = [(model_evidence, image_training), + (one_fit_evidence.contract(text_training), text_training), + (one_fit_evidence.contract(audio_training), audio_training)] + for owner, adapter in owners: + if task != owner.task_ref(task['catalog_id']): continue + required = [] + definition = None + specification = value['specification'] + try: + if adapter is image_training: + if specification.get('template') != 'tinycnn_v2': raise ValueError() + architecture = image_training.validate_architecture( + {k:v for k,v in specification.items() if k != 'template'}) + definition = dict(specification=dict(template='tinycnn_v2', **architecture), + preprocessing_policy=image_training.transform_for_parameters(architecture), + output_policy=model_evidence.OUTPUT_POLICY) + else: + if specification.get('template') != owner.template: raise ValueError() + definition = owner.model_definition({k:v for k,v in specification.items() if k != 'template'}) + except ValueError: + required.append('specification') + if definition is not None: + required.extend(field for field in ('specification','preprocessing_policy','output_policy') + if value[field] != definition[field]) + elif value['output_policy'] != owner.OUTPUT_POLICY: + required.append('output_policy') + return owner.task_ref(destination), sorted(set(required)) + return None, ['task'] + + +def _construct(family, source, identity, catalog): + """Rebuild only explicitly understood bindings; retain every other field.""" + from project_material import _definition + original = copy.deepcopy(source['definition'] if is_intent(source) else source) + required = [] + if family == 'model_definition': + task, required = _model_bindings(original, catalog) + if original['kind'] != 'template': required.append('source_selector') + if not required: + value = dict(original, id=identity, revision=1, task=task) + return _definition(family, value, catalog) + elif family == 'pipeline_graph': + if original.get('recipe') == 'external_recipe': + required = ['recipe_source', 'inputs', 'runtime'] + elif any(node['type'] == 'release' for node in original['nodes']): + required = ['release'] + elif any(node['type'] not in ('train', 'evaluate', 'validate', 'publish') or + node['type'] != 'train' and node['parameters'] for node in original['nodes']): + required = ['nodes'] + else: + # The graph owner owns its closed structure and adapter selection. + # No release, recipe-source or external input binding is adopted. + from pipeline import adapter_for_graph, types_for_graph + import image_training + adapter = adapter_for_graph(original) + try: + for node in original['nodes']: + if node['type'] not in types_for_graph(original): raise ValueError() + if node['type'] == 'train': + validate = (image_training.validate_model_parameters if original.get('recipe') == image_training.MODEL_RECIPE + else adapter.validate_parameters) + validate(node['parameters']) + except ValueError: + required = ['nodes'] + else: + return _definition(family, dict(original, id=identity, revision=1), catalog) + elif family == 'recipe_definition': + required = ['data', 'evaluation_profile', 'model', 'runtime', 'task', 'trainer'] + else: + _fail('Unsupported editable material fork family.') + return dict(format=INTENT_FORMAT, catalog_id=catalog, id=identity, revision=1, + name=original['name'], definition_family=family, definition=original, + required_reselection=sorted(required)) + + +def decode_intent(value, family, catalog): + from project_material import _closed, _catalog, _definition, ID + _closed(value, ('format', 'catalog_id', 'id', 'revision', 'name', + 'definition_family', 'definition', 'required_reselection'), 'fork intent') + if (value['format'] != INTENT_FORMAT or value['catalog_id'] != catalog or + value['definition_family'] != family or type(value['id']) is not str or + not ID.fullmatch(value['id']) or type(value['revision']) is not int or value['revision'] != 1): + _fail('Invalid material fork intent identity.') + old = value['definition'] + origin = old['task']['catalog_id'] if family != 'pipeline_graph' else catalog + _catalog(origin) + _definition(family, old, origin) + if _construct(family, old, value['id'], catalog) != value: + _fail('Material fork intent differs from its exact unresolved definition.') + return copy.deepcopy(value) + + +def fork_records(records, source_project_id, source_checkpoint, destination_project_id, *, id_factory, created_at): + from project_material import decode_records, FAMILIES, _catalog, SHA, ID + from project_fork import active_records + _catalog(source_project_id); _catalog(destination_project_id); _date(created_at) + if source_project_id == destination_project_id or type(source_checkpoint) is not str or not SHA.fullmatch(source_checkpoint): + _fail('Independent material fork requires a distinct identity and source checkpoint.') + decode_records(records) + active = active_records(records, project_id=source_project_id) + existing = {r['document']['current']['id'] for r in records if r['family'] in OWNED} + fresh, mapping = [], [] + for envelope in sorted(active, key=lambda r: (r['family'], r['id'])): + family = envelope['family'] + if family not in OWNED: continue + old = envelope['document']['current'] + identity = id_factory(family, envelope['id'], old['revision']) + if type(identity) is not str or not ID.fullmatch(identity) or identity in existing: + _fail('Material fork allocator must supply a fresh editable UUID.') + existing.add(identity) + value = _construct(family, old, identity, destination_project_id) + locator = destination_project_id + '.' + identity + fresh.append(dict(family=family, id=locator, document=dict(format=FAMILIES[family], + origin_catalog_id=destination_project_id, current=value, revisions=[copy.deepcopy(value)], audit=[], + fork_origin=dict(project_id=source_project_id, checkpoint=source_checkpoint, family=family, + id=envelope['id'], revision=old['revision'], created_at=created_at)))) + mapping.append(dict(family=family, source_id=envelope['id'], source_revision=old['revision'], destination_id=locator)) + mapping.sort(key=lambda m: (m['family'],m['source_id'],m['source_revision'],m['destination_id'])) + return dict(records=fresh, mapping=mapping) + + +def validate_records(records): + """Prove each fork's unaudited initial revision from exact retained owners.""" + from project_material import _closed, _catalog, SHA + from project_fork import decode_fork + if not any(r.get('family') == 'project_fork' or + r.get('family') in OWNED and 'fork_origin' in r.get('document', {}) for r in records): + return + decode_fork(records) + indexed = {(r['family'],r['id']):r for r in records} + overlays = [r['document'] for r in records if r['family'] == 'project_fork'] + mapped = {} + for overlay in overlays: + for mapping in overlay['mapping']: + if mapping['family'] in OWNED: + key = mapping['family'],mapping['destination_id'] + if key in mapped: _fail('Material fork target repeats across origin overlays.') + mapped[key] = (overlay, mapping) + for record in records: + if record['family'] not in OWNED: continue + doc = record['document']; key = record['family'],record['id'] + origin = doc.get('fork_origin') + if origin is None: + if key in mapped: _fail('Mapped material draft lacks its explicit fork origin.') + continue + _closed(origin, ('project_id','checkpoint','family','id','revision','created_at'), 'material fork origin') + _catalog(origin['project_id']); _date(origin['created_at']) + if type(origin['checkpoint']) is not str or not SHA.fullmatch(origin['checkpoint']): + _fail('Invalid material fork source checkpoint.') + if key not in mapped: _fail('Material fork origin lacks a retained mapping.') + overlay, mapping = mapped[key] + expected = dict(project_id=overlay['source']['project_id'], checkpoint=overlay['source']['checkpoint'], + family=mapping['family'], id=mapping['source_id'], revision=mapping['source_revision'], + created_at=origin['created_at']) + if origin != expected or doc['origin_catalog_id'] != overlay['destination_project_id']: + _fail('Material fork origin differs from its archived source mapping.') + original = indexed[(mapping['family'],mapping['source_id'])]['document']['current'] + if original['revision'] != origin['revision']: + _fail('Material fork source revision differs from its archived current value.') + initial = _construct(record['family'], original, doc['current']['id'], doc['origin_catalog_id']) + if doc['revisions'][0] != initial: + _fail('Material fork initial revision differs from its owner reconstruction.') diff --git a/project_native_execution.py b/project_native_execution.py new file mode 100644 index 00000000..832a990f --- /dev/null +++ b/project_native_execution.py @@ -0,0 +1,901 @@ +"""Current native probe/train/verifier phases; retained phases are inert facts. + +Only an explicitly authorized preparation or an actual local Run continuation +may call the phase owner. Reading a checkpoint validates these documents and +their immutable evidence without preparing files, importing workers, or GO. +""" +import copy +from datetime import datetime +import hashlib +import os +from pathlib import Path +import stat +import sys +import time +import uuid + +import agent_api +import recipe_process +from pipeline import adapter_for_graph, adapter_identity_for_graph, decode_probe_observation, digest +from project_native_process import ENVIRONMENT, PHASES, WORKERS, prepare, validate_prepared_spec, validate_job_evidence +from project_session import REQUEST_KEY +from workbench import WorkbenchError, timestamp + +FAMILY = 'native_execution_history' +FORMAT = 'native_execution_history_v1' +STATES = ('prepared', 'ready', 'go_intent', 'completed', 'failed', 'unknown') +FIELDS = {'format','origin_catalog_id','phase_id','phase','run_id','recipe', + 'request_key','request_sha256','actor_id','definition_identity','spec','events','payloads','observations','probe_result'} +OBSERVATIONS = {'job_template':('native-job-template.json',65536), + 'effective_job':('effective-job.json',65536),'parent_observation':('native-parent-observation.json',65536), + 'drain_receipt':('receipt.json',16384),'probe_stdout':('stdout.log',32768)} +PHASE_WALL_SECONDS = {'probe':45,'train':600,'verify':90} +PARENT_DRAIN_ALLOWANCE = 5 + + +def native_admission_wall_seconds(): + """New material's finite probe/train/verifier and custody time allowance. + + This budgets child/custodian phases, not a storage availability guarantee. + Cleanup already includes the custodian's TERM grace interval. + """ + overhead=recipe_process.READY_SECONDS+recipe_process.GO_WAIT_SECONDS+recipe_process.CLEANUP_SECONDS+PARENT_DRAIN_ALLOWANCE + return sum(PHASE_WALL_SECONDS.values())+len(PHASE_WALL_SECONDS)*overhead + + +def _fail(message, code='invalid'): + raise WorkbenchError(message, code, 409 if code != 'invalid' else 400) + + +def _id(value): + if type(value) is not str or recipe_process.ID.fullmatch(value) is None: + _fail('Invalid native execution identity.') + + +def _sha(value): + if type(value) is not str or recipe_process.HASH.fullmatch(value) is None: + _fail('Invalid native execution digest.') + + +def phase_reference(value): + event=value['events'][-1] + return dict(format='project_native_phase_v1',origin_catalog_id=value['origin_catalog_id'], + phase_id=value['phase_id'],phase=value['phase'],run_id=value['run_id'],state=event['state'], + spec_sha256=hashlib.sha256(recipe_process.encoded(value['spec'])).hexdigest(), + receipt_sha256=event['receipt_sha256']) + + +def classify_probe(recipe,raw): + """Process drainage and a valid native runtime observation are distinct.""" + result=dict(stdout=dict(sha256=hashlib.sha256(raw).hexdigest(),bytes=len(raw))) + if len(raw)>32768:return dict(result,outcome='too_large') + try:text=raw.decode('utf8') + except UnicodeError:return dict(result,outcome='invalid_utf8') + try:decode_probe_observation(recipe,recipe_process.decode(text)) + except (WorkbenchError,recipe_process.ProcessError):return dict(result,outcome='invalid_observation') + return dict(result,outcome='valid') + + +def decode_records(records, *, artifacts=None): + """Check exact finite phase/evidence closure, never local process state.""" + result={} + availability={} + declarations={r['id']:r['document'] for r in records if r['family']=='project_artifact'} + try: + for record in records: + if record['family']!=FAMILY:continue + value=record['document'] + if type(value) is not dict or set(value)!=FIELDS or value['format']!=FORMAT: + _fail('Invalid native phase document.') + for key in ('origin_catalog_id','phase_id','actor_id'):_id(value[key]) + if record['id']!=value['origin_catalog_id']+'.'+value['phase_id'] or record['id'] in result: + _fail('Native phase envelope identity differs.') + if value['phase'] not in PHASES or value['phase']!='probe' and value['run_id'] is None: + _fail('Native phase does not match its Run association.') + if value['run_id'] is not None:_id(value['run_id']) + if type(value['request_key']) is not str or REQUEST_KEY.fullmatch(value['request_key']) is None: + _fail('Invalid native phase request key.') + _sha(value['request_sha256']) + adapter_identity_for_graph(dict(recipe=value['recipe'])) + definition=value['definition_identity'] + if (type(definition) is not dict or set(definition)!={'definition_files','adapter_definition_sha256'} + or type(definition['definition_files']) is not dict or not definition['definition_files'] + or len(definition['definition_files'])>120 + or digest(definition['definition_files'])!=definition['adapter_definition_sha256']): + _fail('Native definition identity differs.') + for name,sha in definition['definition_files'].items(): + if (type(name) is not str or '/' in name or '\\' in name or not name.endswith('.py')): + _fail('Invalid native definition source name.') + _sha(sha) + worker=Path(adapter_for_graph(dict(recipe=value['recipe'])).__file__).name + spec=validate_prepared_spec(value['spec'],phase=value['phase'],worker=worker) + if (spec['attempt_id']!=value['phase_id'] or spec['request_sha256']!=value['request_sha256'] + or spec['env']!=ENVIRONMENT or spec.get('native_loader_policy')!='glibc_x86_64_no_preload_v1' + or spec['wall_seconds']!=PHASE_WALL_SECONDS[value['phase']] + or spec['max_output_bytes']!=64*1024*1024 or not 1<=len(spec['runtime_files'])<=3): + _fail('Native phase specification differs from its exact owner identity or environment.') + events=value['events'] + if type(events) is not list or not 1<=len(events)<=4: + _fail('Native phase has invalid bounded transitions.') + previous=None;helper=None + for index,event in enumerate(events): + if type(event) is not dict or set(event)!={'state','at','actor_id','helper','receipt_sha256'}: + _fail('Unclassified native phase event.') + _id(event['actor_id']) + state=event['state'] + allowed={'prepared':{'ready','failed','unknown'},'ready':{'go_intent','failed','unknown'}, + 'go_intent':{'completed','failed','unknown'}} + if (index==0 and state!='prepared' or index>0 and state not in allowed.get(previous,set())): + _fail('Invalid native phase transition.') + if type(event['at']) is not str or len(event['at'])>80 or datetime.fromisoformat(event['at']).utcoffset() is None: + _fail('Native phase requires an offset timestamp.') + if event['helper'] is not None: + if not recipe_process.valid_identity(event['helper']) or helper is not None and helper!=event['helper']: + _fail('Native phase helper identity changed.') + helper=event['helper'] + if state in ('ready','go_intent','completed') and event['helper'] is None: + _fail('Native phase lacks its actual READY helper observation.') + if event['receipt_sha256'] is not None: + _sha(event['receipt_sha256']) + if state not in ('completed','failed'):_fail('Native drainage proof precedes terminal observation.') + if state=='completed' and event['receipt_sha256'] is None: + _fail('Native completed phase requires its exact drainage proof.') + previous=state + payloads=value['payloads'] + if type(payloads) is not list or len(payloads)>180:_fail('Native payload inventory exceeds its bound.') + runs=[];output_names=set() + if value['run_id'] is not None: + runs=[r['document']['run'] for r in records if r['family']=='run_history' + and r['id']==value['origin_catalog_id']+'.'+value['run_id']] + if len(runs)!=1:_fail('Native phase lacks its exact retained Run.') + from runs import native_payload_names + output_names={'output/'+name for name in native_payload_names(runs[0])|{'artifact-manifest.json'}} + names=set();byname={} + for edge in payloads: + if (type(edge) is not dict or set(edge)!={'name','blob','role','preservation','required_for'} + or edge['role'] not in ('execution_source','execution_spec','custody_proof','measured_job','measured_stream','measured_output') + or edge['preservation']!='unique' or edge['required_for']!=['execution_proof'] + or type(edge['name']) is not str or edge['name'] in names): + _fail('Invalid native phase evidence edge.') + name=edge['name'];names.add(name);blob=edge['blob'] + if (name not in ('spec.json','native-job-template.json','effective-job.json','native-parent-observation.json', + 'stdout.log','stderr.log','receipt.json') and + not (name.startswith('source/') and name[7:] in {*definition['definition_files'],'native_process_bootstrap.py'}) + and name not in output_names): + _fail('Unsupported native phase evidence path.') + role=('execution_source' if name.startswith('source/') else 'measured_output' if name.startswith('output/') + else 'execution_spec' if name in ('spec.json','native-job-template.json') + else 'measured_stream' if name in ('stdout.log','stderr.log') + else 'custody_proof' if name=='receipt.json' else 'measured_job') + if edge['role']!=role:_fail('Native evidence edge has a different finite role.') + if type(blob) is not dict or set(blob)!={'sha256','bytes'}:_fail('Invalid native evidence blob.') + _sha(blob['sha256']) + if type(blob['bytes']) is not int or not 0<=blob['bytes']<=64*1024*1024: + _fail('Native evidence exceeds its owner bound.') + if declarations.get(blob['sha256'])!=dict(format='project_artifact_v1',**blob): + _fail('Native evidence declaration is missing or differs.') + byname[name]=blob + if artifacts is not None and blob['sha256'] not in availability: + observed=artifacts.status(dict(format='project_artifact_v1',**blob)) + if observed['state']=='corrupt':_fail('Present native evidence differs from its declared bytes.') + availability[blob['sha256']]=observed['state']=='verified' + observations=value['observations'] + if type(observations) is not dict or set(observations)!=set(OBSERVATIONS): + _fail('Native phase has unclassified canonical observations.') + proofs={} + for key,(name,maximum) in OBSERVATIONS.items(): + text=observations[key] + if text is not None: + if type(text) is not str:_fail('Native proof must retain its exact UTF8 observation.') + raw=text.encode('utf8') + if len(raw)>maximum or byname.get(name)!=dict(sha256=hashlib.sha256(raw).hexdigest(),bytes=len(raw)): + _fail('Canonical native observation differs from its exact evidence edge.') + proofs[key]=raw + spec_raw=recipe_process.encoded(spec) + if byname.get('spec.json')!=dict(sha256=hashlib.sha256(spec_raw).hexdigest(),bytes=len(spec_raw)): + _fail('Native phase lacks its exact prepared specification.') + for name,sha in definition['definition_files'].items(): + if byname.get('source/'+name,{}).get('sha256')!=sha: + _fail('Native phase lacks exact qualified source bytes.') + captures={str(Path(item['path']).relative_to(spec['cwd'])):dict(sha256=item['sha256'],bytes=item['bytes']) + for item in spec['captured_files']} + if set(captures)!={'source/'+name for name in definition['definition_files']}|( + {'source/native_process_bootstrap.py','native-job-template.json'} if value['phase']!='probe' else set()): + _fail('Native phase capture inventory differs from its finite definition closure.') + for name,blob in captures.items(): + if byname.get(name)!=blob:_fail('Native phase lacks exact immutable capture evidence.') + if value['run_id'] is not None: + if (len(runs)!=1 or runs[0]['graph']['recipe']!=value['recipe'] + or runs[0]['runtime']['definition_files']!=definition['definition_files']): + _fail('Native phase differs from its retained Run and exact adapter.') + if (value['phase']=='probe')!=(observations['job_template'] is None): + _fail('Native job template observation differs from its phase.') + if value['phase']!='probe' and observations['probe_stdout'] is not None: + _fail('Training/verifier logs are not probe runtime observations.') + if (observations['effective_job'] is None)!=(observations['parent_observation'] is None): + _fail('Native measured parent/job observations must be retained together.') + if value['phase']!='probe': + template=recipe_process.decode(proofs['job_template']) + if (type(template) is not dict or set(template)!={'format','phase','worker','attempt_id','request_sha256','job'} + or template['format']!='native_parent_binding_v1' or template['phase']!=value['phase'] + or template['worker']!=worker or template['attempt_id']!=value['phase_id'] + or template['request_sha256']!=value['request_sha256']): + _fail('Native job template differs from its exact phase.') + job=adapter_for_graph(dict(recipe=value['recipe']))._checked_job(template['job']) + run=runs[0] + from pipeline import model_aware_graph + import image_training + expected_job=dict(schema_version=1,dataset_id=run['dataset_id'],run_id=run['id'], + graph_sha256=run['graph_sha256'],release_id=run['release_id'],release_sha256=run['release_sha256'], + release_path=job['release_path'],output_dir=spec['output_dir'],parameters=run['parameters'],parent_pid=1) + if value['recipe']==image_training.MODEL_RECIPE: + expected_job.update(schema_version=2,evidence_context=run['evidence_context']) + elif model_aware_graph(run['graph']): + from one_fit_evidence import job_for_run + expected_job=job_for_run(run,spec['output_dir']) + # Historical Run projection deliberately carries no live + # release path. Its absolute template observation is inert; + # scientific release identity and all evidence selections + # remain exact projections of the retained Run owner. + expected_job['release_path']=job['release_path'] + if (agent_api.encoded(job)!=agent_api.encoded(template['job']) + or agent_api.encoded(job)!=agent_api.encoded(expected_job)): + _fail('Native job template differs from its exact scientific Run inputs.') + receipt_sha=events[-1]['receipt_sha256'] + if (receipt_sha is None)!=(observations['drain_receipt'] is None): + _fail('Native terminal receipt observation differs from its acknowledged phase.') + if receipt_sha is not None: + if byname.get('receipt.json',{}).get('sha256')!=receipt_sha: + _fail('Native terminal observation differs from its retained receipt.') + if observations['drain_receipt'] is not None: + raw=proofs['drain_receipt'] + expected=dict(attempt_id=value['phase_id'],request_sha256=value['request_sha256'], + spec_sha256=hashlib.sha256(spec_raw).hexdigest(),helper=helper) + receipt=recipe_process.validate_receipt(raw,expected) + validate_job_evidence(spec,phase=value['phase'],helper=helper, + template=proofs.get('job_template'),effective_job=proofs.get('effective_job'), + parent_observation=proofs.get('parent_observation'),receipt=receipt) + if not receipt['drained'] or (previous=='completed')!=(receipt['outcome']=='completed'): + _fail('Native terminal state differs from measured drainage.') + for stream in ('stdout','stderr'): + if byname.get(stream+'.log')!=receipt['streams'][stream]: + _fail('Native measured stream differs from its exact receipt.') + if value['phase']=='probe': + classified=value['probe_result'] + if (type(classified) is not dict or set(classified)!={'outcome','stdout'} + or classified['outcome'] not in ('valid','invalid_utf8','invalid_observation','too_large') + or classified['stdout']!=byname.get('stdout.log')): + _fail('Native probe protocol observation differs from measured stdout.') + if observations['probe_stdout'] is not None: + if classify_probe(value['recipe'],proofs['probe_stdout'])!=classified: + _fail('Canonical native probe classification differs from exact stdout.') + elif classified['outcome'] not in ('invalid_utf8','too_large'): + _fail('A bounded UTF8 native probe requires its exact canonical stdout.') + if (classified['outcome']=='too_large')!=(classified['stdout']['bytes']>32768): + _fail('Native oversized probe observation has a different bound.') + if artifacts is not None and availability.get(classified['stdout']['sha256']): + actual=artifacts.read_blob(classified['stdout'],maximum_bytes=recipe_process.STREAM_LIMIT) + if classify_probe(value['recipe'],actual)!=classified: + _fail('Present native probe bytes differ from the classified observation.') + elif value['probe_result'] is not None: + _fail('Native probe protocol result precedes its measured terminal receipt.') + if value['phase']!='probe' and value['probe_result'] is not None: + _fail('Only a probe phase may classify a runtime response.') + result[record['id']]=copy.deepcopy(value) + return result + except WorkbenchError:raise + except (ValueError,TypeError,KeyError,AttributeError,OverflowError,RecursionError): + _fail('Malformed retained native execution phase.') + + +class _RunAuthority: + """One actual current SQL admission, never constructed from a checkpoint.""" + def __init__(self,native,run_id): + self.native=native;self.run_id=run_id + self.recipe=native.execution.runs._load(run_id)['graph']['recipe'] + + def authorize_probe(self,recipe): + execution=self.native.execution;owner=execution.runs + with execution.db.lock: + run=owner._load(self.run_id);execution._proof(run) + context=execution.runtime_access.require_job('runs',run['id'],run['admission_id'],run['request_sha256']) + execution.runtime_access.require('edit','runs.update',run['id']) + execution.runtime_access.require('execute','pipeline.probe_native',recipe) + if recipe!=self.recipe or run['state'] not in ('queued','running','cancelling'): + _fail('Current native Run cannot observe this phase.','unavailable') + inputs=self.native._run_inputs.get(run['id']) + if inputs is None:_fail('A retained native Run has no current one-use qualification.','unavailable') + if (os.environ.get('TULDOK_TRAINING_PYTHON') or sys.executable)!=inputs['runtime']['executable']: + _fail('Configured native interpreter changed after admission.','stale') + from project_native_process import current_runtime + if current_runtime(inputs['runtime']['executable'])!=inputs['runtime']: + _fail('Current native interpreter changed after admission.','stale') + if owner.pipelines.definition_identity(recipe)!=inputs['definition_identity']: + _fail('Native adapter changed after admission.','stale') + return context + + def dispatch_allowed(self): + owner=self.native.execution.runs;run=owner._load(self.run_id) + if run['state'] not in ('queued','running') or owner._stop.is_set() or owner._blocked: + _fail('Current native Run no longer permits dispatch.','unavailable') + + +class _ReconciliationAuthority: + def __init__(self,native,recipe):self.native=native;self.recipe=recipe + def authorize_probe(self,recipe): + if recipe!=self.recipe:_fail('Native reconciliation phase changed.','conflict') + return self.native.access.require('cancel','native_execution.reconcile',owner_only=True) + + +class ProjectNativeExecution: + """The fixed current phase owner; construction is entirely inert.""" + def __init__(self, execution): + self.execution=execution;self.projection=execution.projection + self.session=execution.session;self.db=execution.db;self.access=execution.runtime_access + self._dirty={};self._handles={};self._prepared={};self._contexts={};self._active=set();self._run_inputs={} + self._never_launched=set() + self._foreground_phase=None + + @property + def has_dirty(self):return bool(self._dirty) + + @property + def has_local_custody(self): + return bool(self._active or self._handles) + + def capture_dirty(self, before_records): + records=[] + for phase_id,document in self._dirty.items(): + self._authorize(phase_id) + records.append(dict(family=FAMILY,id=document['origin_catalog_id']+'.'+phase_id,document=copy.deepcopy(document))) + records.extend(dict(family='project_artifact',id=edge['blob']['sha256'], + document=dict(format='project_artifact_v1',**edge['blob'])) for edge in document['payloads']) + return records + + def outcome(self, outcome): + if outcome not in ('committed','rolled_back','unknown'):raise ValueError('Invalid native SQL outcome.') + self._dirty.clear() + if outcome=='unknown' or not self.session.available: + for handle in self._handles.values():handle.close_control(wait=False) + + def abandon_pending(self): + if not self.execution._reconstructing:raise RuntimeError('Native pending capture clears only during guarded reconstruction.') + self._dirty.clear() + + def _authorize(self, phase_id): + context=self._contexts.get(phase_id) + if context is None:_fail('Native phase has no actual current preparation context.','unavailable') + return context.authorize_probe(context.recipe) + + def authorize_dirty(self): + for phase_id in self._dirty:self._authorize(phase_id) + + def _dispatch_authorized(self,phase_id): + actor=self._authorize(phase_id) + context=self._contexts[phase_id] + if isinstance(context,_RunAuthority):context.dispatch_allowed() + return actor + + def _retain(self, document, name, raw, role): + declaration=dict(format='project_artifact_v1',sha256=hashlib.sha256(raw).hexdigest(),bytes=len(raw)) + self.session.artifacts.publish_bytes(raw,declaration,maximum_bytes=64*1024*1024) + document['payloads'].append(dict(name=name,blob={k:declaration[k] for k in ('sha256','bytes')}, + role=role,preservation='unique',required_for=['execution_proof'])) + for key,(filename,maximum) in OBSERVATIONS.items(): + if name==filename and (key!='probe_stdout' or document['phase']=='probe'): + if key=='probe_stdout': + document['probe_result']=classify_probe(document['recipe'],raw) + try:text=raw.decode('utf8') if len(raw)<=maximum else None + except UnicodeError:text=None + document['observations'][key]=text + else: + if len(raw)>maximum:_fail('Native canonical observation exceeds its fixed bound.') + document['observations'][key]=raw.decode('utf8') + + def _retain_outputs(self,document,*,run=None,output=None): + """Preserve measured native outputs without claiming their validity.""" + if document['phase']=='probe':return + from runs import native_payload_names + run=run if run is not None else self.execution.runs._load(document['run_id']) + allowed=native_payload_names(run)|{'artifact-manifest.json'} + # A saved pathname is an observation, never a directory capability. + # Select only this physical projection's exact phase stage, including + # the earlier training stage explicitly associated with a verifier. + from project_store import DIR_FLAGS, FILE_FLAGS, ProjectStoreError, _open_directory_path, anchored_directory + stage_id=document['phase_id'] + if document['phase']=='verify': + matches=[row['document'] for row in self.session.snapshot['records'] + if row['family']==FAMILY and row['document']['phase']=='train' + and row['document']['origin_catalog_id']==self.db.dataset_id + and row['document']['run_id']==document['run_id']] + if len(matches)!=1:_fail('Native verifier lacks its exact training stage.','unknown') + stage_id=matches[0]['phase_id'] + _id(stage_id) + relative='project-native-phases/'+stage_id+'/output' + expected=self.db.path/relative + if document['spec']['output_dir']!=str(expected) or output is not None and Path(output)!=expected: + _fail('Native measured output is not its original local stage.','unknown') + root=None;captured=[];total=0 + def identity(info): + return info.st_dev,info.st_ino,info.st_size,info.st_mtime_ns,info.st_ctime_ns + try: + root_path,root=_open_directory_path(self.db.path) + with anchored_directory(root,relative,root_path=root_path) as directory: + before=os.fstat(directory);names=sorted(os.listdir(directory)) + for name in names: + if name not in allowed:_fail('Native worker produced an unclassified output; retain local custody.','unknown') + descriptor=os.open(name,FILE_FLAGS,dir_fd=directory) + try: + info=os.fstat(descriptor) + if not stat.S_ISREG(info.st_mode) or info.st_size>document['spec']['max_output_bytes']: + _fail('Native output is not an admitted bounded regular file.','unknown') + chunks=[];remaining=document['spec']['max_output_bytes']-total+1 + while remaining: + chunk=os.read(descriptor,min(remaining,65536)) + if not chunk:break + chunks.append(chunk);remaining-=len(chunk) + raw=b''.join(chunks);total+=len(raw) + if (total>document['spec']['max_output_bytes'] or identity(info)!=identity(os.fstat(descriptor)) + or identity(info)!=identity(os.stat(name,dir_fd=directory,follow_symlinks=False))): + _fail('Native output changed during its measured observation.','unknown') + captured.append((name,raw)) + finally:os.close(descriptor) + if identity(before)!=identity(os.fstat(directory)) or names!=sorted(os.listdir(directory)): + _fail('Native output directory changed during observation.','unknown') + # The anchored context rechecks the complete directory binding + # before any bytes receive canonical measured-output attribution. + for name,raw in captured:self._retain(document,'output/'+name,raw,'measured_output') + except (OSError,ProjectStoreError) as error: + raise WorkbenchError('Native output stage is unavailable or changed; retain local custody.','unknown',409) from error + finally: + if root is not None:os.close(root) + + def _publish(self, document, state, *, helper=None, receipt_sha256=None): + with self.db.lock,self.db.transaction(): + self.session.check();actor=self._authorize(document['phase_id']) + document['events'].append(dict(state=state,at=timestamp(),actor_id=actor.actor_id, + helper=copy.deepcopy(helper),receipt_sha256=receipt_sha256)) + self._dirty[document['phase_id']]=copy.deepcopy(document) + if state!='prepared' and self.execution.custody is not None: + with self.execution._journal_guard():self.execution.custody.native_observed(document) + + def assert_quiescent(self): + if self.has_local_custody:_fail('Current native phase custody requires explicit drainage.','busy') + + def reconcile(self,phase_id,expected_checkpoint): + """Observe exact original-host drainage; never prepare, import or GO.""" + import json + from project_native_process import read_phase_evidence + _id(phase_id);_sha(expected_checkpoint) + self.access.require('cancel','native_execution.reconcile',phase_id,owner_only=True) + if self.db.lock.owned_by_current_thread or self.db.in_transaction: + _fail('Native custody reconciliation must drain outside command locks.','busy') + execution=self.execution;journal=execution.custody + if journal is None:_fail('There is no original local native custody.','unavailable') + with self.db.lock: + if self.session.snapshot['checkpoint']!=expected_checkpoint:_fail('Expected native recovery checkpoint changed.','stale_project') + local=journal.refresh() + selected=next((row for row in local.get('native_phases',[]) if row['phase_id']==phase_id),None) + if selected is None:_fail('A retained phase alone cannot select current native custody.','unavailable') + if (local['runs'] or local['producers'] or local['runtime_effects'] or local['package_effects'] + or any(row['run_id']!=selected['run_id'] for row in local.get('native_admissions',[])) + or execution._runtime_active or execution._package_active): + _fail('Other current effect owners must reconcile first.','busy') + entries=[row for row in local['native_phases'] + if row['phase_id']==phase_id or selected['run_id'] is not None and row['run_id']==selected['run_id']] + if len(entries)!=len(local['native_phases']):_fail('Other native custody remains unresolved.','busy') + if not self.db.claim_runtime('project_native_phases'):_fail('The original native application is still active.','busy') + other=self.db.execute("SELECT id FROM workspace_runs WHERE document::jsonb->>'state' IN ('queued','running','cancelling','unknown') AND id<>%s", + (selected['run_id'] or '',)).fetchall() + if other:_fail('Another current Run must drain before native recovery.','busy') + owner=execution._runs + if owner is not None:owner._fence_coordinator() + for entry in entries: + handle=self._handles.get(entry['phase_id']) + if handle is not None: + receipt=handle.close_control() + if not receipt or not receipt.get('drained'):_fail('Actual native helper has not drained.','unknown') + if owner is not None and owner._thread is not None: + owner._thread.join(60) + if owner._thread.is_alive():_fail('Current native supervisor has not drained.','busy') + if self._active:_fail('Current native owner has not finished its bounded observation.','busy') + with execution.access.lock,self.session.lock,self.db.lock: + self.access.require('cancel','native_execution.reconcile',phase_id,owner_only=True) + if self.session.snapshot['checkpoint']!=expected_checkpoint:_fail('Native checkpoint changed while draining.','stale_project') + if journal.refresh()!=local:_fail('Original native custody changed while draining.','stale_project') + binding=journal.binding + if (binding['project_id']!=self.db.dataset_id or binding['projection_id']!=self.db.projection_id + or binding['host_catalog_id']!=execution.access.catalog_id): + _fail('Native custody belongs to another physical owner.','local_custody_unavailable') + self.session.available=False + snapshot=self.session.store.recover_checkpoint();journal.validate_selection(snapshot) + with self.session.store.root_directory() as descriptor: + info=os.fstat(descriptor) + if [info.st_dev,info.st_ino]!=binding['root_identity']: + _fail('Native custody belongs to another physical project root.','local_custody_unavailable') + documents=decode_records(snapshot['records'],artifacts=self.session.artifacts) + phases=[];local_run=None;retained_run=None + if selected['run_id'] is not None: + if owner is None and not self.db.claim_runtime('workspace_runs'): + _fail('The original Run application still owns this projection.','busy') + row=self.db.execute('SELECT document FROM workspace_runs WHERE id=%s',(selected['run_id'],)).fetchone() + if row is None:_fail('Native custody has no original local Run admission.','local_custody_unavailable') + local_run=json.loads(row['document']);execution._proof(local_run) + retained_run=next((row['document']['run'] for row in snapshot['records'] if row['family']=='run_history' + and row['id']==self.db.dataset_id+'.'+selected['run_id']),None) + immutable=('id','dataset_id','request_key','request_sha256','admission_id','graph_sha256','release_sha256','runtime') + if retained_run is None or any(local_run.get(key)!=retained_run.get(key) for key in immutable): + _fail('Original native Run admission differs from retained science.','local_custody_unavailable') + for entry in entries: + document=copy.deepcopy(documents.get(self.db.dataset_id+'.'+entry['phase_id'])) + stage=self.db.path/'project-native-phases'/entry['phase_id'] + if (document is None or document['run_id']!=entry['run_id'] or document['phase']!=entry['phase'] + or document['request_sha256']!=entry['request_sha256'] or document['spec']['cwd']!=str(stage) + or hashlib.sha256(recipe_process.encoded(document['spec'])).hexdigest()!=entry['spec_sha256']): + _fail('Original native phase differs from its exact physical journal.','local_custody_unavailable') + if entry['helper'] is None: + # A prepared journal has not acknowledged launch intent. + # After intent, only this process's retained call boundary + # proves Popen was never entered; a restarted owner cannot + # convert that ambiguous spawn window into cessation. + if entry['state']!='prepared' and not (entry['state']=='launch_intent' and entry['phase_id'] in self._never_launched): + _fail('Original native launch lacks an exact helper drainage identity.','unknown') + evidence=None + else:evidence=read_phase_evidence(stage,document['spec'],phase=entry['phase'],helper=entry['helper']) + phases.append((entry,document,evidence)) + # Fixed cache reconstruction happens only after every actual/local + # effect has a complete drain receipt. It restores no Run or grant. + self.projection.reconcile_inert(snapshot) + try: + for entry,document,evidence in phases: + identity=entry['phase_id'];self._contexts[identity]=_ReconciliationAuthority(self,document['recipe']) + if evidence is None: + if document['events'][-1]['state']!='failed':self._publish(document,'failed') + elif entry['state']!='failed': + with execution._journal_guard():journal.native_observed(document) + with execution._journal_guard():journal.native_not_launched(identity) + self._never_launched.discard(identity) + continue + names={edge['name'] for edge in document['payloads']} + for name,raw,role in (('stdout.log',evidence['stdout'],'measured_stream'), + ('stderr.log',evidence['stderr'],'measured_stream'), + ('receipt.json',recipe_process.encoded(evidence['receipt']),'custody_proof'), + ('effective-job.json',evidence['effective_job'],'measured_job'), + ('native-parent-observation.json',evidence['parent_observation'],'measured_job')): + if raw is not None and name not in names:self._retain(document,name,raw,role) + if document['phase']!='probe' and not any(name.startswith('output/') for name in names): + output=stage/'output' + if document['phase']=='verify': + train=next((value for value in documents.values() if value['run_id']==document['run_id'] + and value['phase']=='train'),None) + if train is None:_fail('Original verifier has no exact retained training phase.','unknown') + output=self.db.path/'project-native-phases'/train['phase_id']/'output' + if document['spec']['output_dir']!=str(output):_fail('Native output path differs from original local custody.','unknown') + if output.exists():self._retain_outputs(document,run=retained_run,output=output) + receipt_sha=hashlib.sha256(recipe_process.encoded(evidence['receipt'])).hexdigest() + if document['events'][-1]['state'] not in ('completed','failed'): + state='completed' if evidence['receipt']['outcome']=='completed' else 'failed' + self._publish(document,state,helper=entry['helper'],receipt_sha256=receipt_sha) + elif document['events'][-1]['receipt_sha256']!=receipt_sha: + _fail('Original native terminal receipt changed.','unknown') + elif entry['state'] not in ('completed','failed'): + with execution._journal_guard():journal.native_observed(document) + self._handles.pop(identity,None) + if retained_run is not None: + current=copy.deepcopy(retained_run) + from runs import LOCAL_RUN_FIELDS + current.update({key:copy.deepcopy(value) for key,value in local_run.items() if key in LOCAL_RUN_FIELDS}) + owner=execution._local_owner(recover=False) if owner is None else owner + owner._fence_coordinator() + with self.db.transaction(): + execution._proof(current) + state='completed' if current['state']=='completed' else 'interrupted' + owner._event(current,state,'Original native custody drained; no phase was resumed or regenerated.') + owner._save(current) + elif evidence is not None: + journal.native_confirmed(phase_id) + journal.acknowledge(self.session.snapshot) + return phase_reference(next(document for entry,document,evidence in phases if entry['phase_id']==phase_id)) + except BaseException: + self.session.available=False + if owner is not None:owner._fence_coordinator() + raise + + def settle_run(self,run): + if self.execution.custody is None or run['state'] not in ('completed','failed','cancelled','interrupted'):return + for entry in self.execution.custody.snapshot().get('native_phases',[]): + if entry['run_id']==run['id'] and entry['state'] in ('completed','failed') and entry['receipt_sha256'] is not None: + self.execution.custody.native_confirmed(entry['phase_id']) + + def bind_run(self,run): + """Retain a private measured qualification only during actual admission.""" + if not self.db.in_transaction:_fail('Native qualification binds only inside its admission transaction.') + if self.execution.runs._validation_run(run):return + context=self.projection.native_preparation.current(self.execution.runs.pipelines) + _actor,inputs=self._inputs(run['graph']['recipe'],context) + if (run['runtime']['definition_files']!=inputs['definition_identity']['definition_files'] + or run['runtime']['adapter_definition_sha256']!=inputs['definition_identity']['adapter_definition_sha256']): + _fail('Native Run differs from its qualified adapter.','stale') + if run['id'] in self._run_inputs:_fail('A native Run qualification cannot be replaced.','conflict') + self._run_inputs[run['id']]=copy.deepcopy(inputs) + self.db.on_rollback(lambda:self._run_inputs.pop(run['id'],None)) + + def probe_current_run(self,owner,recipe): + from access import current_context + context=current_context() + if (context is None or context.mode!='job' or context.kind!='runs' + or context.catalog_id!=self.db.dataset_id or owner.db is not self.db): + _fail('Native runtime probing requires its exact current local Run.','unavailable') + authority=_RunAuthority(self,context.job_id);actor=authority.authorize_probe(recipe) + inputs=self._run_inputs[context.job_id] + result=self._run_phase('probe',recipe,'native-run-probe-'+context.job_id,actor,inputs,authority,run_id=context.job_id) + return dict(runtime=self._probe_result(recipe,inputs,result['evidence']),phase=phase_reference(result['document'])) + + def capture_run_payloads(self,run,artifacts): + """Copy a newly admitted frozen native input, then reuse exact custody.""" + from projects import Projects + prior=next((record['document'] for record in self.session.snapshot['records'] + if record['family']=='run_history' and record['id']==run['dataset_id']+'.'+run['id']),None) + if prior is not None: + retained=[copy.deepcopy(edge) for edge in prior['payloads'] + if edge['name']=='release.zip' or edge['name'].startswith('source/')] + expected={'release.zip':run['release_sha256'], + **{'source/'+name:sha for name,sha in run['runtime']['definition_files'].items()}} + if {edge['name']:edge['blob']['sha256'] for edge in retained}!=expected: + _fail('Native Run input/source custody is incomplete.','unavailable') + for edge in retained: + with artifacts.open_verified(dict(format='project_artifact_v1',**edge['blob'])):pass + return retained + if run['state']!='queued':_fail('Only an actual newly admitted native Run may capture inputs.','unavailable') + edges=[] + release=Path(run['release_path']) + reader=Projects(self.execution.runs.dataset,{'native_release':release.parent}) + try: + declaration=dict(format='project_artifact_v1',sha256=run['release_sha256'],bytes=run['release_bytes']) + with reader._file('native_release',release.name) as (descriptor,_info):artifacts.publish_fd(descriptor,declaration) + edges.append(dict(name='release.zip',blob={key:declaration[key] for key in ('sha256','bytes')}, + population='unknown',role='captured_input',preservation='unique',required_for=['scientific_inspection','execution_proof'])) + finally:reader.close() + inputs=self._run_inputs.get(run['id']) + if self.execution.runs._validation_run(run): + sources={name:(self.execution.runs.pipelines.source_path/name).read_bytes() + for name in run['runtime']['definition_files']} + elif inputs is None:_fail('Native input capture has no current qualification.','unavailable') + else:sources=inputs['sources'] + for name,raw in sorted(sources.items()): + if hashlib.sha256(raw).hexdigest()!=run['runtime']['definition_files'][name]: + _fail('Native source changed before canonical admission.','stale') + declaration=dict(format='project_artifact_v1',sha256=hashlib.sha256(raw).hexdigest(),bytes=len(raw)) + artifacts.publish_bytes(raw,declaration) + edges.append(dict(name='source/'+name,blob={key:declaration[key] for key in ('sha256','bytes')}, + population='unknown',role='execution_source',preservation='unique',required_for=['scientific_inspection','execution_proof'])) + return edges + + def execute_run(self,run_id): + """Run the existing CPU owners through three fixed custody phases.""" + from pipeline import model_aware_graph + from types import SimpleNamespace + from runs import _sync + owner=self.execution.runs + with self.db.lock,self.db.transaction(): + run=owner._load(run_id) + self.access.require_job('runs',run_id,run['admission_id'],run['request_sha256']) + if run['state']!='queued' or owner._stop.is_set() or owner._blocked:return + owner._event(run,'running','Verifying exact frozen native inputs before current CPU phases.') + run['started_at']=timestamp();run['progress']['stage']='validate';owner._save(run) + try: + if owner._validation_run(run):return owner._execute_validation(run) + runtime=owner.pipelines.probe(run['graph']['recipe']) + if not runtime['available'] or runtime!=run['runtime']:_fail('Native runtime changed after admission.','stale') + release=owner.pipelines.inspect_release(run['release_id'],private=True,recipe=run['graph']['recipe']) + if release['sha256']!=run['release_sha256']:_fail('Native frozen release changed after admission.','stale') + authority=_RunAuthority(self,run_id);actor=authority.authorize_probe(run['graph']['recipe']) + phase_id=uuid.uuid4().hex;output=self.db.path/'project-native-phases'/phase_id/'output' + job=dict(schema_version=1,dataset_id=run['dataset_id'],run_id=run_id,graph_sha256=run['graph_sha256'], + release_id=run['release_id'],release_sha256=run['release_sha256'],release_path=release['path'], + output_dir=str(output),parameters=run['parameters'],parent_pid=1) + if run['graph']['recipe']=='image_classification_model_v2': + job.update(schema_version=2,evidence_context=run['evidence_context']) + elif model_aware_graph(run['graph']): + from one_fit_evidence import job_for_run + job.update(job_for_run(run,output),parent_pid=1,release_path=release['path']) + with self.db.lock,self.db.transaction(): + current=owner._load(run_id);current['progress']['stage']='train' + current['execution_test_exposure']=owner.pipelines.exposures(current['source_records']) + for node in current['node_attempts']: + node['state']='completed' if node['type'] in ('release','validate') else 'running' if node['type']=='train' else 'pending' + owner._save(current) + trained=self._run_phase('train',run['graph']['recipe'],'native-train-'+run_id,actor, + self._run_inputs[run_id],authority,run_id=run_id,job=job,phase_id=phase_id) + if trained['evidence']['receipt']['outcome']!='completed': + state='cancelled' if trained['evidence']['receipt']['outcome']=='cancelled' else 'failed' + return owner._finish(run_id,state,'Native trainer drained without publishing results.') + with (trained['prepared'].stage/'custody/stdout.log').open('rb') as stream: + owner._read_progress(SimpleNamespace(stdout=stream),run,dict(buffer=b'',bytes=0,epoch=0)) + artifacts,metrics=owner._verify_directory(run,output) + if model_aware_graph(run['graph']): + verified=self._run_phase('verify',run['graph']['recipe'],'native-verify-'+run_id,actor, + self._run_inputs[run_id],authority,run_id=run_id,job=job) + if verified['evidence']['receipt']['outcome']!='completed': + state='cancelled' if verified['evidence']['receipt']['outcome']=='cancelled' else 'failed' + return owner._finish(run_id,state,'Independent checkpoint verification drained without accepting outputs.') + for path in output.iterdir(): + with path.open('rb') as source:os.fsync(source.fileno()) + _sync(output) + with self.db.lock,self.db.transaction(): + current=owner._load(run_id) + if current['state']=='cancelling' or owner._stop.is_set(): + owner._event(current,'cancelled','Native workers drained before final publication.');owner._save(current);return + os.rename(output,owner.path/run_id);_sync(owner.path) + current['artifacts']=artifacts;current['metrics']=metrics + for node in current['node_attempts']:node['state']='completed' + owner._event(current,'completed','Measured native CPU outputs and independent evidence published after custody drainage.') + owner._save(current) + except (WorkbenchError,recipe_process.ProcessError) as error: + state='unknown' if error.code in ('unknown','outcome_unknown') else 'failed' + owner._finish(run_id,state,str(error),error.code) + + def _inputs(self, recipe, preparation): + from project_native_preparation import NativePreparationContext + if type(preparation) is not NativePreparationContext or preparation.recipe!=recipe: + _fail('Native probing requires its actual current preparation context.','unavailable') + actor=preparation.authorize_probe(recipe) + inputs=preparation.native_inputs(recipe) + if type(inputs) is not dict or set(inputs)!={'runtime','worker','sources','model_aware','definition_identity'}: + _fail('Native preparation supplied unclassified phase inputs.') + if inputs['worker'] not in WORKERS or type(inputs['model_aware']) is not bool: + _fail('Native preparation supplied an unsupported adapter.') + definition=inputs['definition_identity'] + if (type(definition) is not dict or set(definition)!={'definition_files','adapter_definition_sha256'} + or type(inputs['sources']) is not dict or set(inputs['sources'])!=set(definition['definition_files']) + or digest(definition['definition_files'])!=definition['adapter_definition_sha256'] + or any(type(raw) is not bytes or hashlib.sha256(raw).hexdigest()!=definition['definition_files'][name] + for name,raw in inputs['sources'].items())): + _fail('Native measured sources differ from their exact definition.','stale') + return actor,inputs + + def probe(self, recipe, *, expected_checkpoint, request_key, preparation): + """Explicitly measure a native runtime behind canonical launch/GO ACKs.""" + if self.db.in_transaction: + _fail('Native probing must precede the foreground admission transaction.','busy') + if type(request_key) is not str or REQUEST_KEY.fullmatch(request_key) is None: + _fail('Invalid native probe request key.') + with self.db.lock: + current=self.session.check() + if current['checkpoint']!=expected_checkpoint:_fail('Native preparation checkpoint changed.','stale_project') + actor,inputs=self._inputs(recipe,preparation) + prior=[value for value in decode_records(current['records']).values() + if value['origin_catalog_id']==self.session.project_id and value['request_key']==request_key] + if prior: + _fail('A previous native probe is historical; start a fresh explicit preparation.','unavailable') + result=self._run_phase('probe',recipe,request_key,actor,inputs,preparation) + return dict(runtime=self._probe_result(recipe,inputs,result['evidence']),phase=phase_reference(result['document'])) + + @staticmethod + def _probe_result(recipe, inputs, evidence): + receipt=evidence['receipt'] + if not receipt['go_received'] or receipt['outcome'] not in ('completed','failed'): + _fail('The native runtime probe did not produce an acknowledged observation.','unavailable') + if classify_probe(recipe,evidence['stdout'])['outcome']!='valid': + _fail('The native runtime probe returned an invalid bounded observation.','unavailable') + try:probe=decode_probe_observation(recipe,recipe_process.decode(evidence['stdout'])) + except (WorkbenchError,recipe_process.ProcessError): + _fail('The native runtime probe did not return its bounded observation.','unavailable') + if receipt['leader_returncode'] and probe['available']: + _fail('Failed native runtime probe claimed availability.','invalid') + return dict(probe,**inputs['definition_identity']) + + def _run_phase(self, phase, recipe, request_key, actor, inputs, authority, *, run_id=None, job=None, phase_id=None): + if self.db.in_transaction:_fail('Native phase cannot launch inside a metadata transaction.','busy') + with self.db.lock: + self.session.check() + if run_id is None and self._foreground_phase is not None: + _fail('Another explicit native preparation phase is active.','busy') + if not self.db.claim_runtime('project_native_phases'):_fail('Another current native phase owner is active.','busy') + phase_id=phase_id or uuid.uuid4().hex + if run_id is None:self._foreground_phase=phase_id + self._active.add(phase_id);self._contexts[phase_id]=authority + document=None;handle=None + self._never_launched.add(phase_id) + try: + parent=self.db.path/'project-native-phases';parent.mkdir(mode=0o700,exist_ok=True) + request_sha=hashlib.sha256(agent_api.encoded(dict(recipe=recipe,phase=phase,run_id=run_id,job=job,runtime=inputs['runtime'], + definition_identity=inputs['definition_identity'],request_key=request_key))).hexdigest() + prepared=prepare(parent/phase_id,phase=phase,attempt_id=phase_id,request_sha256=request_sha, + runtime=inputs['runtime'],worker=inputs['worker'],sources=inputs['sources'],model_aware=inputs['model_aware'],job=job) + self._prepared[phase_id]=prepared + document=dict(format=FORMAT,origin_catalog_id=self.session.project_id,phase_id=phase_id,phase=phase, + run_id=run_id,recipe=recipe,request_key=request_key,request_sha256=request_sha,actor_id=actor.actor_id, + definition_identity=copy.deepcopy(inputs['definition_identity']),spec=prepared.spec,events=[],payloads=[], + observations=dict.fromkeys(OBSERVATIONS),probe_result=None) + self._retain(document,'spec.json',recipe_process.encoded(prepared.spec),'execution_spec') + for item in prepared.spec['captured_files']: + name=str(Path(item['path']).relative_to(prepared.stage)) + raw=recipe_process.read_regular(item['path'],64*1024*1024) + if len(raw)!=item['bytes'] or hashlib.sha256(raw).hexdigest()!=item['sha256']: + _fail('Prepared native source or job changed before acknowledgement.','stale') + self._retain(document,name,raw,'execution_spec' if name=='native-job-template.json' else 'execution_source') + self._publish(document,'prepared') + if self.execution.custody is None:_fail('Native launch requires its original physical custody journal.','unavailable') + with self.db.lock,self.execution._journal_guard(): + self._dispatch_authorized(phase_id);self.session.check() + if not self.execution.custody.has_pending:self.execution.custody.acknowledge(self.session.snapshot) + self.execution.custody.native_prepared(document) + self.execution.custody.native_launch_intent(phase_id) + self._dispatch_authorized(phase_id) + # Once the foundation launch call is entered, failure may mean a + # spawned helper whose identity ACK was lost. Never infer no launch + # from an exception, absent Handle, or a saved PID scan. + handle=prepared.launch(prepared.stage/'custody', + _before_spawn=lambda:self._never_launched.discard(phase_id)) + self._handles[phase_id]=handle + with self.execution._journal_guard(): + helper=recipe_process.identity(handle.process.pid) + self.execution.custody.native_launched(phase_id,helper) + until=time.monotonic()+recipe_process.preparation_seconds(prepared.spec)+2 + terminal=False + while not terminal: + for event in handle.poll(): + if event['type']=='ready': + self._publish(document,'ready',helper=handle.ready) + try:self._dispatch_authorized(phase_id) + except WorkbenchError: + handle.cancel('cancelled') + until=time.monotonic()+recipe_process.CLEANUP_SECONDS+PARENT_DRAIN_ALLOWANCE + continue + self._publish(document,'go_intent',helper=handle.ready) + # Recheck actual authority after both durable ACKs. A + # saved GO intent is never permission to retry this send. + with self.db.lock: + self.session.check();self._dispatch_authorized(phase_id) + if not self.execution.custody.has_pending:_fail('Native local custody disappeared.','unknown') + prepared.check_current_runtime() + handle.go(handle.ready) + until=(time.monotonic()+prepared.spec['wall_seconds'] + +recipe_process.verification_seconds(prepared.spec)+recipe_process.CLEANUP_SECONDS+5) + elif event['type']=='terminal':terminal=True + elif event['type']=='lost':_fail('Native custodian control closed without its measured receipt.','unknown') + if run_id is not None: + with self.db.lock: + current=self.execution.runs._load(run_id) + if current['state']=='cancelling' or self.execution.runs._stop.is_set():handle.cancel('cancelled') + if time.monotonic()>until:_fail('Native phase exceeded its bounded custody deadline.','unknown') + if not terminal:time.sleep(.02) + evidence=prepared.evidence(handle) + self._retain_outputs(document) + for name,raw in (('stdout.log',evidence['stdout']),('stderr.log',evidence['stderr']), + ('receipt.json',recipe_process.encoded(evidence['receipt']))): + self._retain(document,name,raw,'custody_proof' if name=='receipt.json' else 'measured_stream') + for name,key in (('effective-job.json','effective_job'),('native-parent-observation.json','parent_observation')): + if evidence[key] is not None:self._retain(document,name,evidence[key],'measured_job') + receipt_sha=hashlib.sha256(recipe_process.encoded(evidence['receipt'])).hexdigest() + state='completed' if evidence['receipt']['outcome']=='completed' else 'failed' + self._publish(document,state,helper=handle.ready,receipt_sha256=receipt_sha) + if run_id is None: + with self.execution._journal_guard():self.execution.custody.native_confirmed(phase_id) + return dict(document=document,evidence=evidence,prepared=prepared) + except BaseException as error: + # An unacknowledged phase cannot send GO or publish a successful + # terminal fact. Close only this process's actual control channel. + if handle is not None: + try:handle.close_control() + except (OSError,recipe_process.ProcessError):pass + if phase_id not in self._never_launched: + retained=next((record['document'] for record in self.session.snapshot['records'] + if record['family']==FAMILY and record['id']==self.session.project_id+'.'+phase_id),None) + acknowledged=(self.session.available and retained is not None + and retained['events'][-1]['state'] in ('completed','failed') + and retained['events'][-1]['receipt_sha256'] is not None) + if not acknowledged: + already_fenced=not self.session.available + self.session.available=False + if self.execution._runs is not None:self.execution._runs._fence_coordinator() + if not already_fenced: + raise WorkbenchError('Native spawn outcome is uncertain; reconcile exact original custody before further dispatch.', + 'outcome_unknown',409) from error + raise + finally: + self._active.discard(phase_id) + if self._foreground_phase==phase_id:self._foreground_phase=None + if handle is not None and handle.process.poll() is not None and handle.terminal: + self._handles.pop(phase_id,None) diff --git a/project_native_preparation.py b/project_native_preparation.py new file mode 100644 index 00000000..85a0b1be --- /dev/null +++ b/project_native_preparation.py @@ -0,0 +1,429 @@ +"""Explicit current native qualification, before the short admission transaction. + +The configured interpreter and installed packages are operator-managed inputs. +Measured interpreter/source bytes and observed package versions are evidence; +they do not promise a complete package closure or scientific reproducibility. +No serialized validation result can become this process-local preparation. +""" +import copy +from contextlib import contextmanager +from contextvars import ContextVar +import hashlib +import os +from pathlib import Path +import shutil +import sys + +from model_definitions import checked_json, natural, reference +from pipeline import (adapter_for_graph, decode_graph, digest, execution_digest, + identifier, model_aware_graph, closed, decode_probe_observation) +from project_session import operation_records +from workbench import WorkbenchError, timestamp + + +OPERATIONS=frozenset(('native_preparation.intent','native_preparation.validate')) +_CURRENT=ContextVar('project_native_preparation',default=None) + + +def _fail(message,code='unavailable'): + raise WorkbenchError(message,code,409) + + +def _sha(value): + if type(value) is not str or len(value)!=64 or any(c not in '0123456789abcdef' for c in value): + _fail('Native preparation requires an exact SHA256.','invalid') + + +def _key(operation,key): + from experiments import request_key + request_key(key) + return 'native-'+digest(dict(operation=operation,key=key)) + + +def _validation_observation(result,records): + closed(result,('status','valid','errors','validation_token','graph_revision','execution_sha256','release','runtime'),('selection_policy',)) + if type(result['valid']) is not bool or type(result['errors']) is not list or len(result['errors'])>100: + _fail('Invalid native validation result.','invalid') + natural(result['graph_revision'],'Graph revision',1);_sha(result['execution_sha256']) + if result['validation_token'] is not None:_sha(result['validation_token']) + for error in result['errors']: + closed(error,('path','message','code')) + if any(type(error[k]) is not str or len(error[k])>2000 for k in error):_fail('Invalid native validation error.','invalid') + if (result['valid'] and (result['errors'] or result['status']!='valid' or result['validation_token'] is None) + or not result['valid'] and (not result['errors'] or result['status']!=result['errors'][0]['code'] or result['validation_token'] is not None)): + _fail('Contradictory native validation result.','invalid') + release=result['release'] + if release is not None: + import release_validation + integrity=release.get('recipe') in release_validation.RECIPES + fields=('id','sha256','format','recipe','allowed_recipes','records','split_counts','prior_test_exposure','status') + if integrity: + fields+=('result_kind','validation_summary') + if release['recipe'] in release_validation.CLASSIFICATION_RECIPES:fields+=('class_vocabulary',) + closed(release,fields) + if release['result_kind']!='release_validation':_fail('Invalid integrity release result kind.','invalid') + else:closed(release,(*fields,'class_vocabulary'),('audio_summary',)) + _sha(release['id']);_sha(release['sha256']) + if release['id']!=release['sha256'] or release['status']!='available':_fail('Invalid frozen input observation.','invalid') + for name in ('format','recipe'): + if type(release[name]) is not str or not 1<=len(release[name])<=120:_fail('Invalid release task.','invalid') + for name in ('allowed_recipes',): + if type(release[name]) is not list or len(release[name])>10000 or any(type(v) is not str or len(v)>1000 for v in release[name]): + _fail('Invalid release vocabulary.','invalid') + if 'class_vocabulary' in release: + manifests=[] if records is None else [record['document']['manifest'] for record in records + if record['family']=='dataset_release' and record['id']==release['id']] + release_validation.decode_class_vocabulary(release['class_vocabulary'],release['recipe'], + manifest=manifests[0] if len(manifests)==1 else None) + natural(release['records'],'Frozen record count');closed(release['split_counts'],('train','validation','test')) + for count in release['split_counts'].values():natural(count,'Split count') + exposure=release['prior_test_exposure'];closed(exposure,('exposed','events')) + if type(exposure['exposed']) is not bool or type(exposure['events']) is not list or len(exposure['events'])>100000: + _fail('Invalid prior disclosure observations.','invalid') + for event in exposure['events']: + closed(event,('id','record_id','source_hash','reason','run_id','release_id','created_at')) + for field in ('id','run_id'):identifier(event[field]) + for field in ('source_hash','release_id'):_sha(event[field]) + for field in ('record_id','reason','created_at'): + if type(event[field]) is not str or len(event[field])>1000:_fail('Invalid disclosure observation text.','invalid') + if 'audio_summary' in release: + audio=release['audio_summary'];closed(audio,('sample_rates_hz','analyzed_frames','coordinate_contract')) + if type(audio['sample_rates_hz']) is not list or len(audio['sample_rates_hz'])>1000:_fail('Invalid audio rate inventory.','invalid') + for rate in audio['sample_rates_hz']:natural(rate,'Sample rate',1) + natural(audio['analyzed_frames'],'Analyzed frames') + if type(audio['coordinate_contract']) is not str or len(audio['coordinate_contract'])>1000:_fail('Invalid audio coordinate contract.','invalid') + if integrity: + matches=[] if records is None else [record['document']['manifest'] for record in records + if record['family']=='dataset_release' and record['id']==release['id']] + if len(matches)!=1:_fail('Integrity validation needs its exact retained frozen manifest.') + manifest=matches[0] + summary=release_validation.decode_summary(release['validation_summary'],release['recipe'], + manifest=manifest,release_id=release['id']) + if (release['records']!=len(manifest['records']) or release['split_counts']!=summary['split_counts'] + or release['format']!=manifest.get('format','canonical_v1')): + _fail('Integrity release header differs from its frozen summary.','invalid') + if release['recipe'] in release_validation.CLASSIFICATION_RECIPES: + if release['class_vocabulary']!=summary['class_vocabulary']:_fail('Integrity class vocabulary differs.','invalid') + runtime=result['runtime'] + recipes=[] + if runtime is not None: + import release_validation + from pipeline import MODEL_RECIPES + for recipe in ('image_classification','text_classification','audio_classification',*MODEL_RECIPES,*release_validation.RECIPES): + try:decode_probe_observation(recipe,runtime,with_definition=True) + except (WorkbenchError,TypeError,ValueError):continue + recipes.append(recipe) + if len(recipes)!=1:_fail('Unclassified native validation runtime.','invalid') + if 'selection_policy' in result: + from experiments import validate_retained_policy + validate_retained_policy(result['selection_policy']) + if result['valid']: + if release is None or runtime is None or not runtime['available'] or release['recipe']!=recipes[0]: + _fail('Native validation lacks its exact runtime and frozen input.','invalid') + if records is not None: + from project_material_fork import is_intent + matches=[] + for record in records: + if record['family']!='pipeline_graph':continue + for graph in record['document']['revisions']: + if is_intent(graph):continue + if graph['revision']!=result['graph_revision'] or execution_digest(graph)!=result['execution_sha256']:continue + token=digest(dict(graph_id=graph['id'],revision=graph['revision'],execution_sha256=execution_digest(graph), + release_sha256=release['sha256'],runtime=runtime)) + release_id=next((node['parameters']['release_id'] for node in graph['nodes'] if node['type']=='release'),None) + if graph['recipe']==recipes[0] and token==result['validation_token'] and release_id==release['id']:matches.append(graph) + if not matches:_fail('Native validation has no exact retained graph/runtime binding.','invalid') + + +def preparation_owner(owner): + """Select only the fixed current project owner; legacy paths stay legacy.""" + from project_material import _MaterialAccess + from project_execution import ProjectExecution + adapter=getattr(owner.dataset,'project_native_preparation',None) + project=isinstance(owner.access,_MaterialAccess) or isinstance(getattr(owner.dataset,'project_execution',None),ProjectExecution) + if not project and adapter is None:return None + if not isinstance(adapter,ProjectNativePreparation) or adapter.db is not owner.db: + _fail('Current native preparation for the runtime probe is unavailable.') + return adapter + + +def validate_operation_result(operation,result,records=None): + if operation not in OPERATIONS:return False + try: + if operation=='native_preparation.intent': + closed(result,('format','origin_catalog_id','actor_id','purpose','graph','model','composition', + 'release_id','definition_identity','runtime','occurred_at')) + if result['format']!='project_native_preparation_intent_v1' or result['purpose'] not in ('material','validation','admission'): + _fail('Unsupported native preparation intent.','unsupported') + for field in ('origin_catalog_id','actor_id'):identifier(result[field]) + graph=result['graph'];closed(graph,('id','revision','document_sha256','execution_sha256')) + identifier(graph['id']);natural(graph['revision'],'Graph revision',1) + for field in ('document_sha256','execution_sha256'):_sha(graph[field]) + _sha(result['release_id']) + if result['model'] is not None:reference(result['model'],'model',result['origin_catalog_id']) + if result['composition'] is not None:reference(result['composition'],'recipe_material',result['origin_catalog_id']) + identity=result['definition_identity'];closed(identity,('definition_files','adapter_definition_sha256')) + if type(identity['definition_files']) is not dict or not 1<=len(identity['definition_files'])<=120: + _fail('Invalid native definition inventory.','invalid') + from project_native_process import WORKERS, validate_current_runtime + for name,sha in identity['definition_files'].items(): + if type(name) is not str or Path(name).name!=name or not name.endswith('.py'): + _fail('Invalid native definition filename.','invalid') + _sha(sha) + if not set(identity['definition_files'])&WORKERS or digest(identity['definition_files'])!=identity['adapter_definition_sha256']: + _fail('Native definition identity differs.','invalid') + validate_current_runtime(result['runtime']) + from datetime import datetime + if datetime.fromisoformat(result['occurred_at']).utcoffset() is None:_fail('Native intent time requires an offset.','invalid') + if records is not None: + matches=[r['document'] for r in records if r['family']=='pipeline_graph' + and r['document']['origin_catalog_id']==result['origin_catalog_id'] + and r['document']['current']['id']==graph['id']] + if len(matches)!=1 or not any(v['revision']==graph['revision'] and digest(v)==graph['document_sha256'] + and execution_digest(v)==graph['execution_sha256'] for v in matches[0]['revisions']): + _fail('Native intent has no exact retained graph.','invalid') + actual=next(v for v in matches[0]['revisions'] if v['revision']==graph['revision']) + if (next((node['parameters']['release_id'] for node in actual['nodes'] if node['type']=='release'),None)!=result['release_id'] + or Path(adapter_for_graph(actual).__file__).name not in identity['definition_files']): + _fail('Native intent source or frozen input differs from its graph.','invalid') + from types import SimpleNamespace + from project_material import ProjectMaterial + retained=ProjectMaterial(SimpleNamespace(snapshot=dict(records=records),check=lambda:None)) + if result['model'] is not None:retained.model(result['model']) + if result['composition'] is not None: + composition=retained.recipe(result['composition'])['material'] + if (composition['model']!=result['model'] or composition['recipe_id']!=graph['id'] + or composition['recipe_revision']!=graph['revision']): + _fail('Native admission intent differs from its retained composition.','invalid') + else: + _validation_observation(result,records) + checked_json(result,1024**2) + return True + except WorkbenchError:raise + except (ValueError,TypeError,KeyError,AttributeError,ImportError): + _fail('Invalid native preparation observation.','invalid') + + +class NativePreparationContext: + """A lexical current qualification, never a portable admission capability.""" + def __init__(self,adapter,owner,graph,model,composition,purpose,key): + self.adapter,self.owner=adapter,owner + self.graph=copy.deepcopy(decode_graph(graph));self.model=copy.deepcopy(model) + self.recipe=self.graph['recipe'] + self.composition=copy.deepcopy(composition);self.purpose=purpose;self.key=key + self.actor=adapter.access.require('execute','pipeline.probe_native',self.graph['recipe'],owner_only=True) + self.configured_executable=os.environ.get('TULDOK_TRAINING_PYTHON') or sys.executable + self.definition=owner.definition_identity(self.graph['recipe']) + self.sources={name:(owner.source_path/name).read_bytes() for name in self.definition['definition_files']} + self.release_id=next(node['parameters']['release_id'] for node in self.graph['nodes'] if node['type']=='release') + self.release=owner.inspect_release(self.release_id,private=True,recipe=self.graph['recipe']) + self.model_value=adapter.materials.model(model,verify=True) if model is not None else None + if model is not None: + current_model,_definition=owner._builtin_model_for_graph(self.graph,model) + if current_model!=self.model_value or {item['import_path']:item['blob']['sha256'] + for item in current_model['material']['source_closure']}!=self.definition['definition_files']: + _fail('Published native model differs from its graph or current source.','stale') + from project_native_process import current_runtime + self.runtime=current_runtime(self.configured_executable) + self._active=False;self._probe=None;self._prepared=None;self._consumed=False;self.phase=None + + def _check(self,recipe): + if not self._active or _CURRENT.get() is not self or recipe!=self.graph['recipe']: + _fail('An explicit current native preparation is required.') + self.adapter.session.check() + actor=self.adapter.access.require('execute','pipeline.probe_native',recipe,owner_only=True) + if actor.actor_id!=self.actor.actor_id:_fail('Native preparation caller changed.','conflict') + if (os.environ.get('TULDOK_TRAINING_PYTHON') or sys.executable)!=self.configured_executable: + _fail('Configured native interpreter changed.','stale') + if self.owner.get(self.graph['id'])!=self.graph or self.owner.definition_identity(recipe)!=self.definition: + _fail('Native graph or source changed during qualification.','stale') + if any(hashlib.sha256((self.owner.source_path/name).read_bytes()).hexdigest()!=sha + for name,sha in self.definition['definition_files'].items()): + _fail('Native source bytes changed.','stale') + current=self.owner.inspect_release(self.release_id,private=True,recipe=recipe) + if current!=self.release:_fail('Native frozen input observation changed.','stale') + if self.model is not None and self.adapter.materials.model(self.model,verify=True)!=self.model_value: + _fail('Native model observation changed.','stale') + from project_native_process import current_runtime + if current_runtime(self.configured_executable)!=self.runtime:_fail('Configured native interpreter identity changed.','stale') + return actor + + def authorize_probe(self,recipe): + return self._check(recipe) + + def native_inputs(self,recipe): + self.authorize_probe(recipe) + return dict(runtime=copy.deepcopy(self.runtime),worker=Path(adapter_for_graph(self.graph).__file__).name, + sources=copy.deepcopy(self.sources),model_aware=model_aware_graph(self.graph),definition_identity=copy.deepcopy(self.definition)) + + def probe(self,owner,recipe): + if owner.db is not self.adapter.db:_fail('Native preparation belongs to another projection.') + self._check(recipe) + if self._probe is None: + if self.adapter.db.in_transaction:_fail('Native probe cannot run inside a metadata transaction.','unsupported') + self.authorize_probe(recipe) + result=self.adapter.execution.native.probe(recipe,expected_checkpoint=self.adapter.session.snapshot['checkpoint'], + request_key=self.key+'.probe',preparation=self) + self._probe=copy.deepcopy(result['runtime']);self.phase=copy.deepcopy(result['phase']) + return copy.deepcopy(self._probe) + + def capture_builtin(self,owner,value): + self._check(self.graph['recipe']) + if self.composition is None or value!=self.composition:_fail('Prepared native material differs.','stale') + if self.adapter.db.in_transaction: + if self._prepared is None or self._consumed:_fail('Native qualification is missing or already consumed.') + self._consumed=True + return copy.deepcopy(self._prepared) + if self._prepared is None:self._prepared=owner._prepare_builtin_recipe_material_current(value) + return copy.deepcopy(self._prepared) + + def consume_admission(self): + self._check(self.recipe) + if self._consumed:_fail('Native qualification has already been consumed.') + self._consumed=True + + +class PurePreparationContext: + """Current integrity qualification; it cannot authorize a child phase.""" + def __init__(self,adapter,owner,graph): + self.adapter,self.owner=adapter,owner;self.graph=copy.deepcopy(graph) + self.recipe=graph['recipe'];self.definition=owner.definition_identity(self.recipe) + self.actor=adapter.access.require('execute','pipeline.snapshot',graph['id'],owner_only=True) + self.validation=owner.validate_graph(graph) + if not self.validation['valid']: + first=self.validation['errors'][0];_fail(first['message'],first['code']) + self.release=owner.inspect_release(self.validation['release']['id'],private=True,recipe=self.recipe) + self._consumed=False;self._active=True + + def consume_admission(self): + if not self._active or self._consumed:_fail('Current integrity qualification has already been consumed.') + self.adapter.session.check() + actor=self.adapter.access.require('execute','pipeline.snapshot',self.graph['id'],owner_only=True) + if actor.actor_id!=self.actor.actor_id:_fail('Current integrity caller changed.','conflict') + if self.owner.get(self.graph['id'])!=self.graph or self.owner.definition_identity(self.recipe)!=self.definition: + _fail('Current integrity graph or source changed.','stale') + if self.owner.inspect_release(self.release['id'],private=True,recipe=self.recipe)!=self.release: + _fail('Current integrity frozen input changed.','stale') + self._consumed=True + + +class ProjectNativePreparation: + def __init__(self,projection): + self.projection=projection;self.session=projection.session;self.db=projection.db + self.access=projection.execution.runtime_access;self.execution=projection.execution;self.materials=projection.materials + + def current(self,owner): + context=_CURRENT.get() + if not isinstance(context,NativePreparationContext) or context.adapter is not self or owner.db is not self.db: + _fail('Native qualification requires an explicit current preparation.') + return context + + def probe(self,owner,recipe): + actor=self.access._context() + if actor.mode=='job': + if actor.kind!='runs' or actor.catalog_id!=self.session.project_id: + _fail('Native probe requires its exact current Run admission.') + self.access.require('execute','pipeline.probe_native',recipe) + result=self.execution.native.probe_current_run(owner,recipe) + return copy.deepcopy(result['runtime']) + return self.current(owner).probe(owner,recipe) + + @contextmanager + def _scope(self,owner,graph,model,composition,purpose,expected_checkpoint,key): + if self.db.in_transaction:_fail('Native preparation must precede the metadata transaction.','unsupported') + if _CURRENT.get() is not None:_fail('A native preparation is already active.','busy') + self.access.require('edit','native_preparation.'+purpose,graph['id'],owner_only=True) + self.session.check() + if self.session.snapshot['checkpoint']!=expected_checkpoint:_fail('Expected project checkpoint changed.','stale_project') + context=NativePreparationContext(self,owner,graph,model,composition,purpose,_key(purpose,key)) + result=dict(format='project_native_preparation_intent_v1',origin_catalog_id=self.session.project_id, + actor_id=context.actor.actor_id,purpose=purpose,graph=dict(id=graph['id'],revision=graph['revision'], + document_sha256=digest(graph),execution_sha256=execution_digest(graph)),model=model, + composition=None if composition is None else composition['ref'],release_id=context.release_id, + definition_identity=context.definition,runtime=context.runtime,occurred_at=timestamp()) + body={name:copy.deepcopy(value) for name,value in result.items() if name not in ('format','occurred_at')} + self.session.mutate('native_preparation.intent',graph['id'],body,expected_checkpoint,context.key, + lambda:copy.deepcopy(result),lambda value,before:dict(records=[],result=value),scope='execute') + context._active=True;token=_CURRENT.set(context) + try:yield context + finally:_CURRENT.reset(token);context._active=False;context._prepared=None + + def validate_graph(self,owner,graph_id,revision,expected_checkpoint,key): + self.access.require('edit','native_preparation.validation',graph_id,owner_only=True) + self.access.require('execute','pipeline.probe_native',graph_id,owner_only=True) + body=dict(graph_id=graph_id,revision=revision) + prior=operation_records(self.session.snapshot['records']).get(key) + if prior is not None: + return self.session.mutate('native_preparation.validate',graph_id,body,expected_checkpoint,key, + lambda:(_ for _ in ()).throw(RuntimeError('Native validation replay launched a probe.')), + lambda value,before:dict(records=[],result=value),scope='execute') + graph=owner.get(graph_id) + if graph['revision']!=revision:_fail('Graph changed before native validation.','stale') + import release_validation + if adapter_for_graph(graph) is release_validation: + self.session.check() + if self.session.snapshot['checkpoint']!=expected_checkpoint:_fail('Expected project checkpoint changed.','stale_project') + value=owner.validate_graph(graph) + return self.session.mutate('native_preparation.validate',graph_id,body,expected_checkpoint,key, + lambda:copy.deepcopy(value),lambda value,before:dict(records=[],result=value),scope='execute') + with self._scope(owner,graph,None,None,'validation',expected_checkpoint,key) as context: + value=owner.validate_graph(graph);context._check(graph['recipe']) + return self.session.mutate('native_preparation.validate',graph_id,body,self.session.snapshot['checkpoint'],key, + lambda:copy.deepcopy(value),lambda value,before:dict(records=[],result=value),scope='execute') + + @contextmanager + def current_graph(self,owner,graph_id,revision,expected_checkpoint,key): + if self.db.in_transaction:_fail('Current native validation must precede admission.','unsupported') + self.access.require('edit','native_preparation.admission',graph_id,owner_only=True) + self.session.check() + if self.session.snapshot['checkpoint']!=expected_checkpoint:_fail('Expected project checkpoint changed.','stale_project') + graph=owner.get(graph_id) + if graph['revision']!=revision:_fail('Graph changed before native admission.','stale') + import release_validation + if adapter_for_graph(graph) is release_validation: + context=PurePreparationContext(self,owner,graph) + try:yield context + finally:context._active=False + return + with self._scope(owner,graph,None,None,'admission',expected_checkpoint,key) as context: + context.validation=owner.validate_graph(graph) + if not context.validation['valid']: + first=context.validation['errors'][0];_fail(first['message'],first['code']) + context._check(graph['recipe']) + yield context + + def prepare_recipe(self,owner,graph_id,revision,model_ref,expected_checkpoint,key): + self.materials._require_resolved('pipeline_graph',graph_id) + self.access.require('edit','recipes.prepare_builtin_material',graph_id,owner_only=True) + self.access.require('execute','pipeline.probe_native',graph_id,owner_only=True) + body=dict(graph_id=graph_id,revision=revision,model=model_ref,reconcile_capture=False) + operation='recipes.prepare_builtin_material' + if key in operation_records(self.session.snapshot['records']): + return self.session.mutate(operation,graph_id,body,expected_checkpoint,key, + lambda:(_ for _ in ()).throw(RuntimeError('Material replay launched a native probe.')), + lambda value,before:dict(records=[],result=value)) + graph=owner.get(graph_id) + if graph['revision']!=revision or not model_aware_graph(graph):_fail('Exact model-aware graph is unavailable.','stale') + self.materials.materialize_projection(model_ref,owner.dataset.models.path) + with self._scope(owner,graph,model_ref,None,'material',expected_checkpoint,key) as context: + prepared=owner.prepare_builtin_material(graph_id,revision,model_ref) + try: + context._check(graph['recipe']) + def capture(value,before): + record=self.materials.capture_recipe_material(value,owner.material_path) + return dict(records=self.materials._coalesce([record,*self.materials.declarations(record), + *self.materials.capture_recipe_dependencies(record,before)]),result=value) + return self.session.mutate(operation,graph_id,body,self.session.snapshot['checkpoint'],key, + lambda:owner._retain_material(prepared),capture) + finally: + if prepared['staging'].exists():shutil.rmtree(prepared['staging']) + + @contextmanager + def admission(self,owner,material_ref,expected_checkpoint,key): + value=self.materials.recipe(material_ref,verify=True) + import json + snapshot=json.loads(self.materials.blob(material_ref,value['material']['recipe_document'],2*1024**2)) + graph=decode_graph(snapshot['graph']) + with self._scope(owner,graph,value['material']['model'],value,'admission',expected_checkpoint,key) as context: + context.capture_builtin(owner,value) + yield context diff --git a/project_native_process.py b/project_native_process.py new file mode 100644 index 00000000..41b28f38 --- /dev/null +++ b/project_native_process.py @@ -0,0 +1,445 @@ +"""Finite native probe/train/verifier preparation over the existing custodian. + +Only the current native domain owner supplies measured runtime and source bytes. +Preparation owns new disposable files, not SQL, portable publication, authority, +or GO. The caller acknowledges READY through its canonical boundary, then uses +the returned ordinary recipe_process.Handle explicitly. +""" +import copy +import hashlib +import os +from pathlib import Path +import re +import stat + +import recipe_process as custody + +WORKERS = frozenset(('image_training.py', 'text_training.py', 'audio_training.py')) +PHASES = frozenset(('probe', 'train', 'verify')) +TEMPLATE_FORMAT = 'native_parent_binding_v1' +TEMPLATE_LIMIT = 64 * 1024 +BOOTSTRAP = Path(__file__).with_name('native_process_bootstrap.py') +ENVIRONMENT = dict(PATH='/usr/bin:/bin', LANG='C.UTF-8', CUDA_VISIBLE_DEVICES='', + OMP_NUM_THREADS='1', MKL_NUM_THREADS='1', OPENBLAS_NUM_THREADS='1', BLIS_NUM_THREADS='1', + VECLIB_MAXIMUM_THREADS='1', NUMEXPR_NUM_THREADS='1', PYTHONDONTWRITEBYTECODE='1') + + +def _fail(message, code='invalid'): + raise custody.ProcessError(message, code) + + +def validate_current_runtime(value): + """Pure observed runtime metadata, not a complete package/library closure.""" + if (type(value) is not dict or set(value) != {'executable', 'executable_source', 'executable_sha256', + 'trusted_install_owner_uid', 'files'} + or type(value['files']) is not dict or not 1 <= len(value['files']) <= 3 + or type(value['trusted_install_owner_uid']) is not int or value['trusted_install_owner_uid'] < 0): + _fail('Invalid observed current native runtime.') + for key in ('executable', 'executable_source'): + path = value[key] + if (type(path) is not str or not 1 <= len(path) <= 4096 or '\0' in path + or not Path(path).is_absolute() or str(Path(path)) != path or '..' in Path(path).parts): + _fail('Invalid observed native interpreter path.') + if type(value['executable_sha256']) is not str or custody.HASH.fullmatch(value['executable_sha256']) is None: + _fail('Invalid observed native interpreter hash.') + allowed = {value['executable_source'], str(Path(value['executable']).parent/'pyvenv.cfg'), + str(Path(value['executable']).parent.parent/'pyvenv.cfg')} + for path, row in value['files'].items(): + if (path not in allowed or type(row) is not dict or set(row) != {'sha256', 'bytes'} + or type(row['sha256']) is not str or custody.HASH.fullmatch(row['sha256']) is None + or type(row['bytes']) is not int or not 0 <= row['bytes'] <= 64*1024*1024): + _fail('Invalid observed native runtime file.') + if value['files'].get(value['executable_source'], {}).get('sha256') != value['executable_sha256']: + _fail('Observed native interpreter source differs.') + return copy.deepcopy(value) + + +def current_runtime(executable): + """Measure only the explicit current interpreter and venv configuration. + + Installed packages and native libraries remain operator-managed dependencies; + the actual native probe observes their reported versions. No executable runs + here and no alternative interpreter is selected. + """ + if type(executable) is not str or not executable or '\0' in executable: + _fail('Choose the explicitly configured native interpreter.') + configured = Path(os.path.abspath(executable)) + try: + physical = configured.resolve(strict=True) + files = {} + for path in dict.fromkeys((physical, configured.parent/'pyvenv.cfg', configured.parent.parent/'pyvenv.cfg')): + if path != physical and not os.path.lexists(path): continue + raw = custody.read_regular(path, 64*1024*1024) + files[str(path)] = dict(sha256=hashlib.sha256(raw).hexdigest(), bytes=len(raw)) + if configured.resolve(strict=True) != physical or not os.path.samestat(configured.stat(),physical.stat()): + _fail('Configured native interpreter alias changed.', 'stale') + return validate_current_runtime(dict(executable=str(configured), executable_source=str(physical), + executable_sha256=files[str(physical)]['sha256'], trusted_install_owner_uid=os.stat('/usr').st_uid, + files=files)) + except OSError: + _fail('Configured current native interpreter is unavailable.', 'unavailable') + + +def validate_prepared_spec(value, *, phase, worker): + """Pure finite native invocation grammar; never a current runtime resolver.""" + value = custody.decode_retained_spec(value) + if (type(phase) is not str or phase not in PHASES or type(worker) is not str or worker not in WORKERS + or value['schema_version'] != 1 or value['env'] != ENVIRONMENT + or value.get('native_loader_policy') != 'glibc_x86_64_no_preload_v1' + or any(key in value for key in ('cpu_snapshot', 'torch_runtime', 'resource_profile'))): + _fail('Unsupported retained native phase specification.') + root = Path(value['cwd']) + if str(root) != value['cwd'] or '..' in root.parts: + _fail('Retained native working directory differs.') + source = root / 'source' + captures = {row['path'] for row in value['captured_files']} + template = str(root / 'native-job-template.json') + bootstrap = str(source / BOOTSTRAP.name) + for path in captures: + item = Path(path) + if path == template and phase != 'probe': continue + if (item.parent != source or str(item) != path + or re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]{0,119}\.py', item.name) is None + or item.name == BOOTSTRAP.name and phase == 'probe'): + _fail('Unclassified retained native capture path.') + if str(source / worker) not in captures: + _fail('Retained native worker capture is missing.') + executable = value['argv'][0] + if phase == 'probe': + expected = [executable, '-B', str(source / worker), '--probe'] + if value['argv'] not in (expected, expected + ['--model-aware']): + _fail('Retained native probe argument vector differs.') + elif (value['argv'] != [executable, '-I', '-B', bootstrap, phase, template] + or not {bootstrap, template} <= captures): + _fail('Retained native job bootstrap differs.') + if phase in ('probe', 'train') and value['output_dir'] != str(root / 'output'): + _fail('Retained native output directory differs.') + return value + + +def _job_template(spec, phase, template): + if (type(template) is not bytes or not 1 <= len(template) <= TEMPLATE_LIMIT): + _fail('Native retained job template is unavailable or exceeds its bound.', 'stale') + frame = custody.decode(template) + if (type(frame) is not dict or set(frame) != {'format','phase','worker','attempt_id','request_sha256','job'} + or frame['format'] != TEMPLATE_FORMAT or frame['phase'] != phase + or frame['attempt_id'] != spec['attempt_id'] or frame['request_sha256'] != spec['request_sha256'] + or type(frame['job']) is not dict or type(frame['job'].get('parent_pid')) is not int + or frame['job']['parent_pid'] != 1 or custody.encoded(frame) != template): + _fail('Native retained job template differs from its phase.', 'stale') + return frame + + +def validate_job_evidence(spec, *, phase, helper, template, effective_job, parent_observation, receipt): + """Pure exact native job proof for inline history or current measured bytes. + + The existing native worker still owns scientific job semantics. This seam + binds only the immutable template, qualified helper, distinct effective job + and closed custodian receipt; it confers no current execution authority. + """ + spec = custody.decode_retained_spec(spec) + if type(phase) is not str or phase not in PHASES or not custody.valid_identity(helper): + _fail('Invalid native job evidence phase or helper.') + if phase == 'probe': + if any(value is not None for value in (template,effective_job,parent_observation)): + _fail('Native probe cannot carry job execution evidence.', 'stale') + worker = Path(spec['argv'][2]).name if len(spec['argv']) > 2 else '' + frame = None + else: + frame = _job_template(spec,phase,template) + worker = frame['worker'] + spec = validate_prepared_spec(spec,phase=phase,worker=worker) + expected = dict(attempt_id=spec['attempt_id'],request_sha256=spec['request_sha256'], + spec_sha256=hashlib.sha256(custody.encoded(spec)).hexdigest(),helper=helper) + if type(receipt) is not dict:_fail('Native job evidence requires its closed custody receipt.') + receipt = custody.validate_receipt(custody.encoded(receipt),expected) + if not receipt['drained']:_fail('Native descendant drainage is unconfirmed.', 'unknown') + if frame is not None: + row = next(item for item in spec['captured_files'] if item['path'] == str(Path(spec['cwd'])/'native-job-template.json')) + if len(template) != row['bytes'] or hashlib.sha256(template).hexdigest() != row['sha256']: + _fail('Native retained job template differs from its captured identity.', 'stale') + if effective_job is not None or parent_observation is not None: + if (type(effective_job) is not bytes or type(parent_observation) is not bytes + or not 1 <= len(effective_job) <= TEMPLATE_LIMIT + or not 1 <= len(parent_observation) <= TEMPLATE_LIMIT): + _fail('Native measured job evidence is incomplete or exceeds its bound.', 'unknown') + expected_job = dict(frame['job'],parent_pid=helper['pid']) + expected_observation = dict(format='native_parent_observation_v1', + attempt_id=spec['attempt_id'],request_sha256=spec['request_sha256'], + template_sha256=hashlib.sha256(template).hexdigest(), + effective_job_sha256=hashlib.sha256(effective_job).hexdigest(),helper=helper) + if (effective_job != custody.encoded(expected_job) or parent_observation != custody.encoded(expected_observation) + or not receipt['go_received']): + _fail('Native effective job differs from its exact qualified parent.', 'stale') + elif receipt['outcome'] == 'completed': + _fail('Completed native phase lacks measured job evidence.', 'unknown') + return dict(receipt=receipt,template=template,effective_job=effective_job,parent_observation=parent_observation) + + +def _record(path, raw): + return dict(path=str(path), sha256=hashlib.sha256(raw).hexdigest(), bytes=len(raw)) + + +def _write(directory, name, raw): + descriptor = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o400, dir_fd=directory) + with os.fdopen(descriptor, 'wb') as target: + target.write(raw); target.flush(); os.fsync(target.fileno()) + + +def _parent(path): + """Walk the caller's explicit current path without directory symlinks.""" + if not path.is_absolute() or '..' in path.parts or path.name in ('', '.', '..'): + _fail('Native staging requires an absolute new path.') + descriptor = os.open('/', os.O_RDONLY | os.O_DIRECTORY) + try: + for component in path.parent.parts[1:]: + following = os.open(component, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=descriptor) + os.close(descriptor); descriptor = following + return descriptor + except BaseException: + os.close(descriptor); raise + + +def _directory(path): + parent = _parent(path) + try: + descriptor = os.open(path.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent) + finally:os.close(parent) + info = os.fstat(descriptor) + if info.st_uid != os.getuid() or info.st_mode & 0o077: + os.close(descriptor) + _fail('Native phase evidence requires its current private physical directory.', 'stale') + return descriptor + + +def _read_at(directory, name, maximum, *, immutable=False): + descriptor = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory) + with os.fdopen(descriptor, 'rb') as source: + before = os.fstat(source.fileno()) + if (not stat.S_ISREG(before.st_mode) or before.st_nlink != 1 or before.st_uid != os.getuid() + or before.st_size > maximum or immutable and before.st_mode & 0o222): + _fail('Native phase evidence file is unsafe or exceeds its bound.', 'stale') + raw = source.read(maximum + 1) + after = os.fstat(source.fileno()) + identity = lambda row: (row.st_dev,row.st_ino,row.st_mode,row.st_nlink,row.st_uid, + row.st_size,row.st_mtime_ns,row.st_ctime_ns) + if (len(raw) > maximum or identity(before) != identity(after) + or identity(after) != identity(os.stat(name, dir_fd=directory, follow_symlinks=False))): + _fail('Native phase evidence changed during observation.', 'stale') + return raw + + +def read_phase_evidence(stage, spec, *, phase, helper): + """Read one caller-qualified original physical phase, never restore custody. + + The caller must first prove its current original-host/root/projection journal + binding. This function uses only that explicit stage and its custody child; + retained absolute paths cannot select another filesystem root. No runtime + lookup, process observation, signal, launch or GO occurs here. + """ + return _read_phase_evidence(stage, spec, phase=phase, helper=helper, + custody_directory=Path(stage) / 'custody') + + +def _read_phase_evidence(stage, spec, *, phase, helper, custody_directory): + stage = Path(stage) + spec = custody.decode_retained_spec(spec) + if spec['cwd'] != str(stage) or not custody.valid_identity(helper): + _fail('Native phase evidence differs from its qualified physical stage or helper.', 'stale') + if type(phase) is not str or phase not in PHASES: + _fail('Unsupported native phase evidence.') + root = source = owner = None + try: + root = _directory(stage) + template = None + if phase == 'probe': + worker = Path(spec['argv'][2]).name if len(spec['argv']) > 2 else '' + else: + template = _read_at(root, 'native-job-template.json', TEMPLATE_LIMIT, immutable=True) + frame = _job_template(spec,phase,template) + worker = frame['worker'] + spec = validate_prepared_spec(spec, phase=phase, worker=worker) + source = os.open('source', os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=root) + for item in spec['captured_files']: + name = Path(item['path']).name + raw = template if item['path'] == str(stage/'native-job-template.json') else _read_at( + source, name, item['bytes'], immutable=True) + if raw is None or len(raw) != item['bytes'] or hashlib.sha256(raw).hexdigest() != item['sha256']: + _fail('Native retained capture differs from its exact specification.', 'stale') + expected = dict(attempt_id=spec['attempt_id'],request_sha256=spec['request_sha256'], + spec_sha256=hashlib.sha256(custody.encoded(spec)).hexdigest(),helper=helper) + try: + owner = _directory(Path(custody_directory)) + receipt_raw = _read_at(owner,'receipt.json',16384) + receipt = custody.validate_receipt(receipt_raw,expected) + if custody.encoded(receipt) != receipt_raw: + _fail('Native custody receipt lost its exact canonical bytes.', 'stale') + except (OSError,custody.ProcessError): + _fail('Native phase has no exact drainage receipt.', 'unknown') + if not receipt['drained']: _fail('Native descendant drainage is unconfirmed.', 'unknown') + streams = {} + for name in ('stdout','stderr'): + row = receipt['streams'][name] + raw = _read_at(owner,name+'.log',custody.STREAM_LIMIT) + if len(raw) != row['bytes'] or hashlib.sha256(raw).hexdigest() != row['sha256']: + _fail('Native measured stream differs from its custody receipt.', 'stale') + streams[name] = raw + effective = observation = None + if template is not None: + present = [] + for name in ('effective-job.json','native-parent-observation.json'): + try:os.stat(name,dir_fd=root,follow_symlinks=False);present.append(True) + except FileNotFoundError:present.append(False) + if any(present): + if not all(present): _fail('Native measured job evidence is incomplete.', 'unknown') + effective = _read_at(root,'effective-job.json',TEMPLATE_LIMIT,immutable=True) + observation = _read_at(root,'native-parent-observation.json',TEMPLATE_LIMIT,immutable=True) + proof = validate_job_evidence(spec,phase=phase,helper=helper,template=template, + effective_job=effective,parent_observation=observation,receipt=receipt) + if not os.path.samestat(os.fstat(root),os.stat(stage,follow_symlinks=False)): + _fail('Native physical phase changed during observation.', 'stale') + if (not os.path.samestat(os.fstat(source),os.stat('source',dir_fd=root,follow_symlinks=False)) + or not os.path.samestat(os.fstat(owner),os.stat(custody_directory,follow_symlinks=False))): + _fail('Native phase evidence directory changed during observation.', 'stale') + return dict(proof,stdout=streams['stdout'],stderr=streams['stderr']) + finally: + for descriptor in (owner,source,root): + if descriptor is not None:os.close(descriptor) + + +class PreparedNative: + """A fixed phase specification and its new files; never an execution grant.""" + def __init__(self, stage, phase, spec, template, runtime=None): + self.stage, self.phase = stage, phase + self._spec, self.template = copy.deepcopy(spec), template + self._runtime = copy.deepcopy(runtime) + + @property + def spec(self): + return copy.deepcopy(self._spec) + + def launch(self, custody_directory, *, _before_spawn=None): + """Return a READY/GO Handle. This method never sends GO.""" + if self._runtime is None: + return custody.launch(self.spec, custody_directory, _before_spawn=_before_spawn) + self.check_current_runtime() + descriptor = os.open(self._runtime['executable_source'], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + try: + if not os.path.samestat(os.fstat(descriptor), os.stat(self._runtime['executable'])): + _fail('Configured interpreter alias changed before launch.', 'stale') + return custody.launch(self.spec, custody_directory, executable_fd=descriptor, + _before_spawn=_before_spawn) + finally:os.close(descriptor) + + def check_current_runtime(self): + """Explicit owner check before GO; never dispatches or grants permission.""" + if self._runtime is not None and current_runtime(self._runtime['executable']) != self._runtime: + _fail('Current native interpreter or venv configuration changed.', 'stale') + return copy.deepcopy(self._runtime) + + def evidence(self, handle): + """Read the same exact phase proof used by original-host recovery.""" + if handle.spec != self._spec or handle.ready is None: + _fail('Native phase handle differs from its prepared specification.', 'unknown') + result = _read_phase_evidence(self.stage, self._spec, phase=self.phase, + helper=handle.ready, custody_directory=handle.directory) + if result['template'] != self.template: + _fail('Native immutable job template differs from its prepared value.', 'stale') + return result + + +def prepare(stage, *, phase, attempt_id, request_sha256, runtime, worker, sources, + job=None, model_aware=False, wall_seconds=None, max_output_bytes=64 * 1024 * 1024): + """Create one new phase, using only caller-supplied current measurements. + + runtime is current_runtime's explicit interpreter/venv observation, or the + four-field regular-file input executable/executable_sha256/ + trusted_install_owner_uid/files. These are observed files, not complete + installed package/library closure. sources maps bounded Python + basenames to exact captured bytes. The existing worker owns job semantics; + this boundary requires parent_pid=1 and retains the whole template exactly. + A verifier may inspect the preceding training phase's private output folder. + """ + if (type(phase) is not str or phase not in PHASES or type(worker) is not str + or worker not in WORKERS or type(model_aware) is not bool + or type(attempt_id) is not str or custody.ID.fullmatch(attempt_id) is None + or type(request_sha256) is not str or custody.HASH.fullmatch(request_sha256) is None): + _fail('Invalid fixed native phase identity.') + if (type(runtime) is not dict or set(runtime) not in ( + {'executable', 'executable_sha256', 'trusted_install_owner_uid', 'files'}, + {'executable', 'executable_source', 'executable_sha256', 'trusted_install_owner_uid', 'files'}) + or type(runtime['files']) is not dict or not runtime['files']): + _fail('Supply an explicitly measured current native runtime.') + measured = validate_current_runtime(runtime) if 'executable_source' in runtime else None + if (type(sources) is not dict or worker not in sources or not 1 <= len(sources) <= 120 + or any(type(name) is not str or re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]{0,119}\.py', name) is None + or name == BOOTSTRAP.name or type(raw) is not bytes for name, raw in sources.items())): + _fail('Supply the exact finite native worker source closure.') + if sum(map(len, sources.values())) > 64 * 1024 * 1024 - TEMPLATE_LIMIT: + _fail('Native source closure exceeds its custody bound.') + stage = Path(stage) + if phase == 'probe': + if job is not None: _fail('Native probe cannot accept a job.') + template = None + output = stage / 'output' + else: + if type(job) is not dict or type(job.get('parent_pid')) is not int or job['parent_pid'] != 1: + _fail('Native job must retain its parent_pid=1 template.') + if type(job.get('output_dir')) is not str or not Path(job['output_dir']).is_absolute(): + _fail('Native job requires its current private output directory.') + output = Path(job['output_dir']) + if phase == 'train' and output != stage / 'output': + _fail('Native training output must belong to its new phase.') + template = custody.encoded(dict(format=TEMPLATE_FORMAT, phase=phase, worker=worker, + attempt_id=attempt_id, request_sha256=request_sha256, job=job)) + if len(template) > TEMPLATE_LIMIT: _fail('Native job template exceeds 64 KiB.') + files = [] + for path, item in runtime['files'].items(): + if (type(path) is not str or not Path(path).is_absolute() or type(item) is not dict + or set(item) != {'sha256', 'bytes'}): _fail('Invalid measured native runtime file.') + files.append(dict(path=path, **item)) + executable = runtime['executable'] + executable_source = runtime.get('executable_source', executable) + if (executable_source not in runtime['files'] or runtime['files'][executable_source]['sha256'] != runtime['executable_sha256']): + _fail('Native interpreter is absent from its measured runtime closure.') + captures = [_record(stage / 'source' / name, raw) for name, raw in sorted(sources.items())] + if template is not None: + bootstrap = BOOTSTRAP.read_bytes() + captures.extend((_record(stage / 'source' / BOOTSTRAP.name, bootstrap), + _record(stage / 'native-job-template.json', template))) + argv = [executable, '-I', '-B', str(stage / 'source' / BOOTSTRAP.name), phase, + str(stage / 'native-job-template.json')] + else: + argv = [executable, '-B', str(stage / 'source' / worker), '--probe', + *(['--model-aware'] if model_aware else [])] + spec = custody.validate_spec(dict(schema_version=1, attempt_id=attempt_id, request_sha256=request_sha256, + argv=argv, env=ENVIRONMENT.copy(), cwd=str(stage), stdin_base64='', + wall_seconds=wall_seconds if wall_seconds is not None else {'probe':45, 'train':600, 'verify':90}[phase], + executable_sha256=runtime['executable_sha256'], max_output_bytes=max_output_bytes, + output_dir=str(output), captured_files=captures, runtime_files=sorted(files, key=lambda row:row['path']), + trusted_install_owner_uid=runtime['trusted_install_owner_uid'], native_loader_policy='glibc_x86_64_no_preload_v1')) + parent = _parent(stage) + directory = source = None + try: + os.mkdir(stage.name, 0o700, dir_fd=parent) + directory = os.open(stage.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent) + os.mkdir('source', 0o700, dir_fd=directory) + source = os.open('source', os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory) + if output == stage / 'output': os.mkdir('output', 0o700, dir_fd=directory) + elif phase == 'verify': + check = os.open(output, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + info = os.fstat(check) + if info.st_uid != os.getuid() or info.st_mode & 0o077: + _fail('Native verifier output must be a current private directory.') + finally: os.close(check) + for name, raw in sorted(sources.items()): _write(source, name, raw) + if template is not None: + _write(source, BOOTSTRAP.name, bootstrap) + _write(directory, 'native-job-template.json', template) + os.fsync(source); os.fsync(directory); os.fsync(parent) + finally: + for descriptor in (source, directory, parent): + if descriptor is not None: os.close(descriptor) + return PreparedNative(stage, phase, spec, template, measured) diff --git a/project_producer_execution.py b/project_producer_execution.py new file mode 100644 index 00000000..b4456aba --- /dev/null +++ b/project_producer_execution.py @@ -0,0 +1,445 @@ +"""Explicit current-host producers over inert canonical producer history. + +No owner is constructed by this adapter's constructor. Preparation is part of +Session's canonical foreground transaction; worker launch requires its confirmed +acknowledgement and is never inferred from a saved receipt or imported history. +""" +import copy +import json +import threading +import uuid + +from access import AccessError, current_context +from image_sources import ImageSources +from project_producers import (FAMILIES, TABLES, ProjectProducers, _capture_job, + _CapturedEvidence, _composed, _declarations, _referenced_artifacts, decode_records, + identity, shape) +from workbench import WorkbenchError + +KINDS = {'generation_history':'generation', 'grounded_history':'grounded', + 'caption_proposal_history':'caption', 'text_proposal_history':'text_proposal'} +JOB_KINDS = {'generation_history':'synthetic', 'grounded_history':'grounded', + 'caption_proposal_history':'caption', 'text_proposal_history':'text_classification'} +FINISH = {'generation_history':'synthetic.finish', 'grounded_history':'grounded.finish', + 'caption_proposal_history':'caption.finish', 'text_proposal_history':'text_classification.finish'} +ACTIONS = frozenset(('start','resume','review','decide','cancel')) +OPERATIONS = frozenset('producers.'+value for value in ACTIONS) + + +def validate_operation_result(operation, target, result, records): + if operation not in OPERATIONS: + return False + shape(result, {'format','family','origin_catalog_id','producer_id','action'}) + if (result['format'] != 'project_producer_action_v1' or result['family'] not in FAMILIES + or result['action'] not in ACTIONS or operation != 'producers.'+result['action']): + raise WorkbenchError('Invalid retained producer action.') + if ((result['action']=='resume' and result['family']!='generation_history') + or (result['action']=='review' and result['family']!='grounded_history') + or (result['action']=='decide' and result['family'] not in ('caption_proposal_history','text_proposal_history'))): + raise WorkbenchError('Producer action does not belong to this finite family.') + identity(result['origin_catalog_id']);identity(result['producer_id']) + if target != result['producer_id'] and not (result['action']=='start' and target is None): + raise WorkbenchError('Producer action target differs.') + if not any(r['family']==result['family'] and r['id']==result['origin_catalog_id']+'.'+result['producer_id'] for r in records): + raise WorkbenchError('Producer action has no retained scientific history.') + return True + + +class _LiveContext: + def __init__(self, adapter): + self.adapter = adapter + self.access = adapter.runtime_access + self.project_execution = adapter.execution + def __getattr__(self, name): + return getattr(self.adapter.data.context, name) + + +class ProjectProducerExecution: + def __init__(self, session, data, archive, runtime_access=None, *, execution=None): + self.session, self.db, self.data, self.archive = session, session.db, data, archive + self.runtime_access = runtime_access or session.runtime_access + self.execution = execution or self + self._owners = {} + self._custody_lock = threading.RLock() + self._journaled = set() + self._journal_observations = {} + self._dirty = {family:set() for family in FAMILIES} + self._runtime_data = None + self._fenced = False + + def _family(self, family): + if family not in FAMILIES: + raise WorkbenchError('Unsupported project producer family.') + return family + + def _owner(self, family): + self._family(family);self.session.check() + if self._fenced: + raise WorkbenchError('Current producer custody is uncertain. Reopen for inert inspection.', 'outcome_unknown', 409) + self._refuse_unknown_provider() + if family not in self._owners: + # Imported science retains its physical ordering. A fresh local + # authority table must allocate beyond those observed ordinals. + table=TABLES[family] + observed=max((r['document']['ordinal'] for r in self.session.snapshot['records'] + if r['family']==family and r['document']['origin_catalog_id']==self.db.dataset_id),default=0) + current=self.db.execute('SELECT COALESCE(max(ordinal),0) FROM '+table).fetchone()[0] + maximum=max(observed,current) + self.db.execute('SELECT setval(pg_get_serial_sequence(%s,%s),%s,%s)',(table,'ordinal',max(1,maximum),maximum>0)) + # The calling action has checked current host authority. Never restore + # archived jobs into these local authority tables. + context = _LiveContext(self) + workbench = copy.copy(self.data.workbench) + workbench.dataset = context + if family == 'generation_history': + from synthetic import Jobs + from image_generation import ImageRequests + context.image_requests = ImageRequests(self.runtime_access) + context.workbench = workbench + image_context = _LiveContext(self) + image_context.access = self.data._authority + image_context.workbench = workbench + images = ImageSources(image_context) + def add(body, generation=None): + if generation is None: + raise WorkbenchError('Current producer context only accepts admitted output.') + with self.db.lock, self.db.transaction(): + key = 'generated-image-'+uuid.uuid4().hex + with self.data.runtime_mutation('data.runtime_generated_image', key): + self._runtime_data = ('data.runtime_generated_image', key) + return images.add(body, generation=generation) + context.add = add + owner = Jobs(context) + context.generation_jobs = owner + image_context.generation_jobs = owner + else: + if family == 'grounded_history': + from grounded_candidates import Proposals as Owner + elif family == 'caption_proposal_history': + from caption_proposals import CaptionProposals as Owner + else: + from text_classification_proposals import TextClassificationProposals as Owner + owner = Owner(workbench) + self._owners[family] = owner + return self._owners[family] + + def _mark(self, family, producer_id): + identity(producer_id) + if not self.db.in_transaction: + raise RuntimeError('Producer writes require their owned SQL transaction.') + self._dirty[family].add(producer_id) + + def mark_generation(self, producer_id):self._mark('generation_history', producer_id) + def mark_grounded(self, producer_id):self._mark('grounded_history', producer_id) + def mark_caption(self, producer_id):self._mark('caption_proposal_history', producer_id) + def mark_text_proposal(self, producer_id):self._mark('text_proposal_history', producer_id) + + def _proof(self, family, job): + row = self.db.execute('SELECT * FROM access_admissions WHERE id=%s', (job.get('admission_id'),)).fetchone() + if row is None: + raise AccessError('Current local producer admission is unavailable.') + row = dict(row) + if (row['catalog_id'] != self.db.dataset_id or row['kind'] != JOB_KINDS[family] + or row['job_id'] != job['id'] or row['request_digest'] != job.get('request_digest')): + raise AccessError('Current producer admission differs from its retained request.') + context = current_context() + if context is None: + raise AccessError('Producer observation requires current authority.') + if context.mode == 'job': + self.runtime_access.require_job(JOB_KINDS[family], job['id'], job['admission_id'], job['request_digest']) + self.runtime_access.require('edit', FINISH[family], job['id']) + elif context.mode == 'system': + # Only a constructed local owner can reconcile its own table. + self.runtime_access.require('cancel', JOB_KINDS[family]+'.reconcile', job['id']) + else: + self.runtime_access.require('inspect', 'producers.observe', job['id']) + + def capture_dirty(self, before_records): + result = [] + if self._runtime_data is not None: + operation, key = self._runtime_data + result.extend(self.data.capture_runtime_changes(before_records, operation=operation, key=key)) + if not any(self._dirty.values()): + return result + capture = copy.copy(self.archive) + capture._origin = self.db.dataset_id + capture._artifacts = {} + capture._pending_payloads = {} + for family, ids in self._dirty.items(): + for producer_id in sorted(ids): + row = self.db.execute('SELECT data,ordinal FROM '+TABLES[family]+' WHERE id=%s', (producer_id,)).fetchone() + if row is None: + raise WorkbenchError('Current producer row disappeared.') + job = json.loads(row[0]);self._proof(family, job) + entries = [] + if family == 'generation_history': + entries = [(json.loads(raw),order) for raw,order in self.db.execute( + 'SELECT data,storage_order FROM generation_entries WHERE job_id=%s ORDER BY ordinal',(producer_id,))] + record = _capture_job(capture, family, job, row[1], entries) + # Keep all observed entry facts across explicit continuation and + # deletion; the current view remains the final observation. + previous = next((r for r in before_records if r['family']==family and r['id']==record['id']),None) + if previous is not None and family == 'generation_history': + old = {v['entry']['id']:v for v in previous['document']['entries']} + for value in record['document']['entries']: + if value['entry']['id'] in old: + history = copy.deepcopy(old[value['entry']['id']]['history']) + payload_history=copy.deepcopy(old[value['entry']['id']].get('history_payloads', + [old[value['entry']['id']]['payloads'] for _ in history])) + if history[-1] != value['entry']: + history.append(copy.deepcopy(value['entry'])) + payload_history.append(copy.deepcopy(value['payloads'])) + value['history'] = history + value['history_payloads'] = payload_history + result.append(record) + result.extend(dict(family='project_artifact',id=sha,document=decl) for sha,decl in capture._artifacts.items()) + decode_records(_composed(before_records,result), artifacts=_CapturedEvidence(capture)) + for sha,raw in capture._pending_payloads.items(): + self.session.artifacts.publish_bytes(raw,capture._artifacts[sha],maximum_bytes=32*1024*1024) + return result + + def outcome(self, outcome): + if outcome not in ('committed','rolled_back','unknown'): + raise ValueError('Invalid producer transaction outcome.') + for values in self._dirty.values():values.clear() + if self._runtime_data is not None: + self.data.finish_runtime_mutation() + self._runtime_data = None + if outcome == 'unknown' or not self.session.available: + self._fenced = True + # Unknown acknowledgement fences authority. It never authorizes an + # unacknowledged cancellation signal to a current worker. + if outcome == 'committed': + records = self.session.snapshot['records'] + self.archive._records = copy.deepcopy([r for r in records if r['family'] in FAMILIES]) + declarations = _declarations(records) + self.archive._artifacts = {sha:declarations[sha] for sha in _referenced_artifacts(self.archive._records)} + + def _ack(self, family, producer_id, action): + return dict(format='project_producer_action_v1',family=family, + origin_catalog_id=self.db.dataset_id,producer_id=producer_id,action=action) + + def _mutate(self, action, family, producer_id, body, checkpoint, key, invoke, *, scope='edit'): + return self.session.mutate('producers.'+action,producer_id,dict(family=family,document=body),checkpoint,key, + invoke,lambda result,before:dict(records=[],result=result),scope=scope) + + def _dispatch_prepared(self, family, owner, job): + try: + # Host-local custody must be durable before any worker can perform + # capability discovery or inference. Saved project receipts cannot + # recreate this registration or launch a worker. + self.execution.register_producer(family,job['id'],job['admission_id'],job['request_digest']) + self._journaled.add((family,job['id'])) + self.execution.producer_effect_intent(family,job['id']) + with self._custody_lock: + owner._project_worker_id=job['id'] + owner._launch(job) + except BaseException: + self._fenced=True + self.session.available=False + raise + + def _settle_drained(self): + if not self.session.available: + return + records=self.session.snapshot['records'] + for family,owner in self._owners.items(): + producer_id=getattr(owner,'_project_worker_id',None) + identity_key=(family,producer_id) + if identity_key not in self._journaled or owner.worker is not None and owner.worker.is_alive(): + continue + doc=next((r['document'] for r in records if r['family']==family and r['id']==self.db.dataset_id+'.'+producer_id),None) + if doc is None or doc['job']['status'] in ('preparing','generating','stopping'): + continue + job=doc['job'];outcome=job.get('provider_outcome') + entries=[v['entry'] for v in doc.get('entries',[])] + batches=[v['batch'] for v in doc.get('prompt_batches',[])] + if (outcome=='unknown' or any(e.get('provider_outcome')=='unknown' for e in entries) + or any(b['status']=='requesting' for b in batches)): + outcome='unknown' + elif (outcome=='result_received' or any(e.get('provider_outcome')=='result_received' for e in entries) + or any(b['response_sha256'] is not None for b in batches)): + outcome='result_received' + else: + # Every effectful request is preceded by canonical uncertainty; + # a terminal job with no such request is known not admitted. + outcome='not_admitted' + observation=(outcome,True,self.session.snapshot['checkpoint']) + if self._journal_observations.get(identity_key)!=observation: + self.execution.update_producer_outcome(family,producer_id,outcome,True) + self._journal_observations[identity_key]=observation + if outcome!='unknown':self._journaled.discard(identity_key) + + def start(self, family, body, expected_checkpoint, key): + self._family(family) + if family == 'generation_history': + shape(body, {'server_url','model','prompt','count','strategy','session_id'}, + {'width','height','seed','protocol','profile','prompt_url','prompt_model','book_id','split'}) + self.runtime_access.require('execute','producers.start',owner_only=True) + prepared = [] + def invoke(): + requested = body.get('request_id') if type(body) is dict else None + if requested is not None and any(r['family']==family and r['id']==self.db.dataset_id+'.'+requested for r in self.session.snapshot['records']): + if self.db.execute('SELECT id FROM '+TABLES[family]+' WHERE id=%s',(requested,)).fetchone() is None: + raise WorkbenchError('A retained producer identity cannot acquire current admission. Start with a new identity.','unavailable',409) + owner = self._owner(family) + job, launch = owner._prepare_start(body) + if launch:prepared.append((owner,copy.deepcopy(job))) + return self._ack(family,job['id'],'start') + with self.session.access.lock, self.session.lock, self.db.lock: + self._settle_drained() + result = self._mutate('start',family,None,body,expected_checkpoint,key,invoke,scope='execute') + # Replays never invoke preparation. Unknown/failed commits never reach + # this line. Hold custody locks until the local worker is installed. + for owner,job in prepared: + self._dispatch_prepared(family,owner,job) + return result + + def resume_generation(self, producer_id, expected_checkpoint, key): + identity(producer_id) + self.runtime_access.require('execute','producers.resume',owner_only=True) + prepared=[] + def invoke(): + self._refuse_unknown_provider() + owner=self._owners.get('generation_history') + if owner is None: + raise WorkbenchError('Only a current local generation queue can continue. Imported history is inert.','unavailable',409) + job,launch=owner._prepare_resume(producer_id) + if launch:prepared.append((owner,copy.deepcopy(job))) + return self._ack('generation_history',producer_id,'resume') + with self.session.access.lock,self.session.lock,self.db.lock: + self._settle_drained() + result=self._mutate('resume','generation_history',producer_id,{},expected_checkpoint,key,invoke,scope='execute') + for owner,job in prepared: + self._dispatch_prepared('generation_history',owner,job) + return result + + def _decision(self, action, family, producer_id, body, checkpoint, key): + self._family(family);identity(producer_id) + self.runtime_access.require('edit','producers.'+action,producer_id) + def invoke(): + owner=self._owners.get(family) + if owner is None: + raise WorkbenchError('Imported producer history cannot be applied as current authority.','unavailable',409) + operation={'grounded_history':'data.runtime_grounded_text','caption_proposal_history':'data.runtime_caption', + 'text_proposal_history':'data.runtime_text_classification'}[family] + with self.data.runtime_mutation(operation,key): + self._runtime_data=(operation,key) + if action=='review':owner.review(producer_id,body) + else:owner.decide(producer_id,body) + return self._ack(family,producer_id,action) + return self._mutate(action,family,producer_id,body,checkpoint,key,invoke) + + def review_grounded(self, producer_id, body, expected_checkpoint, key): + return self._decision('review','grounded_history',producer_id,body,expected_checkpoint,key) + + def decide(self, family, producer_id, body, expected_checkpoint, key): + if family not in ('caption_proposal_history','text_proposal_history'): + raise WorkbenchError('Only caption/classification proposals accept a decision.') + return self._decision('decide',family,producer_id,body,expected_checkpoint,key) + + def cancel(self, family, producer_id, expected_checkpoint, key, *, drain_timeout=10): + self._family(family);identity(producer_id) + self.runtime_access.require('cancel','producers.cancel',producer_id) + if getattr(self.db.lock,'owned_by_current_thread',False): + raise WorkbenchError('Cancellation must signal and drain outside project command locks.','producer_active',409) + prepared=[] + def invoke(): + owner=self._owners.get(family) + if owner is None: + raise WorkbenchError('No current local producer custody exists for this history.','unavailable',409) + _result,pending=owner._prepare_cancel(producer_id) + if pending is not None:prepared.append((owner,pending)) + return self._ack(family,producer_id,'cancel') + result=self._mutate('cancel',family,producer_id,{},expected_checkpoint,key,invoke,scope='cancel') + # A replay never invokes preparation. Failure or uncertain COMMIT never + # reaches this point, and therefore never stops transport or a worker. + for owner,pending in prepared: + with self._custody_lock: + signalled=owner._signal_cancel(pending) + # The image transport owner audits through host/SQL locks. Never + # hold the custody mutex while entering those ordered guards. + if signalled and family=='generation_history':owner._cancel_transport(pending) + worker=pending[0] + if worker is not None: + worker.join(drain_timeout) + if worker.is_alive(): + raise WorkbenchError('Cancellation intent is saved; current worker has not drained.','producer_draining',409) + return result + + def _refuse_unknown_provider(self): + for family in self._owners: + for raw, in self.db.execute('SELECT data FROM '+TABLES[family]): + job=json.loads(raw) + if (job.get('provider_outcome')=='unknown' + or any(v['status']=='requesting' for v in job.get('prompt_batches',[]))): + raise WorkbenchError('Downstream producer outcome is unknown; local drainage is not provider cessation.','outcome_unknown',409) + if family=='generation_history': + if any(json.loads(row[0]).get('provider_outcome')=='unknown' for row in self.db.execute('SELECT data FROM generation_entries WHERE job_id=%s',(job['id'],))): + raise WorkbenchError('Image provider outcome is unknown.','outcome_unknown',409) + + def abandon_pending(self): + if not getattr(self.execution,'_reconstructing',False): + raise RuntimeError('Pending producer facts can be abandoned only by explicit current reconstruction.') + for owner in self._owners.values(): + if owner.worker is not None and owner.worker.is_alive(): + raise WorkbenchError('Actual local producer custody has not drained.','producer_active',409) + self._refuse_unknown_provider() + for values in self._dirty.values():values.clear() + self.data.finish_runtime_mutation() + self._runtime_data=None + self._fenced=False + + def assert_quiescent(self): + if not self.has_local_custody: + return + self._settle_drained() + for family,owner in self._owners.items(): + if owner.worker is not None and owner.worker.is_alive(): + raise WorkbenchError('Stop and drain the current producer before changing project.','producer_active',409) + self._refuse_unknown_provider() + for raw, in self.db.execute('SELECT data FROM '+TABLES[family]): + job=json.loads(raw) + if job['status'] in ('preparing','generating','stopping') or job.get('provider_outcome')=='unknown': + raise WorkbenchError('Current producer is active or its provider outcome is unknown.','producer_active',409) + + def close(self): + # Project lifecycle refuses active/unknown custody; it never turns Close + # into an implicit cancellation or recovery instruction. + self.assert_quiescent() + + @property + def has_local_custody(self): + return bool(self._owners) + + def shutdown(self): + """Explicit application shutdown drains only actual constructed owners. + + Historical rows, process IDs and endpoints never select a custodian. + Shutdown is separate from ordinary project Close, which refuses work. + """ + if getattr(self.db.lock,'owned_by_current_thread',self.db.lock.depth) or self.db.in_transaction: + raise WorkbenchError('Producer shutdown must drain outside project command locks.','producer_active',409) + for owner in self._owners.values(): + with self.runtime_access._reconcile():owner.close() + with self.runtime_access._reconcile():self._settle_drained() + + def status(self, *, offset=0, limit=40): + if type(offset) is not int or not 0<=offset<=1_000_000 or type(limit) is not int or not 1<=limit<=40: + raise WorkbenchError('Invalid current producer health page.') + with self.session.access.lock,self.session.lock,self.db.lock: + self.session.access.require('inspect','producers.health') + self.session.check() + self._settle_drained() + items=[] + for family,owner in self._owners.items(): + for raw, in self.db.execute('SELECT data FROM '+TABLES[family]+' ORDER BY ordinal'): + job=json.loads(raw) + observed=job.get('provider_outcome') + if any(v['status']=='requesting' for v in job.get('prompt_batches',[])):observed='unknown' + if family=='generation_history' and any(json.loads(row[0]).get('provider_outcome')=='unknown' for row in self.db.execute('SELECT data FROM generation_entries WHERE job_id=%s',(job['id'],))):observed='unknown' + items.append(dict(family=family,producer_id=job['id'],status=job['status'], + provider_outcome=observed,worker_alive=bool(owner.worker + and owner.worker.is_alive() and getattr(owner,'_project_worker_id',None)==job['id']))) + return dict(format='project_producer_health_v1',items=items[offset:offset+limit],total=len(items), + offset=offset,limit=limit,fenced=bool(self._fenced or self.runtime_access.catalog_id==self.session.access.catalog_id or any(v['provider_outcome']=='unknown' for v in items))) diff --git a/project_producers.py b/project_producers.py new file mode 100644 index 00000000..79b97f20 --- /dev/null +++ b/project_producers.py @@ -0,0 +1,1247 @@ +"""Inert, origin-scoped producer observations and exact evidence payloads. + +No producer constructor, admission, worker, cancellation or resume API enters this +owner. Four closed codecs preserve the scientific observations their existing +owners validate. Local transport configuration and admission envelopes have an +explicit exclusion; unknown fields fail instead of disappearing during export. +""" +import base64 +import copy +from datetime import datetime +import hashlib +import json +import re + +from access import authorized +import caption_proposals +import grounded_candidates +import image_generation +import pumas_operations as pumas +import synthetic +import text_classification_proposals +from workbench import WorkbenchError, encode, text_value, validate_annotation + +FAMILIES = { + 'generation_history': 'project_generation_history_v1', + 'grounded_history': 'project_grounded_history_v1', + 'caption_proposal_history': 'project_caption_proposal_history_v1', + 'text_proposal_history': 'project_text_proposal_history_v1', +} +TABLES = {'generation_history': 'generation_jobs', 'grounded_history': 'grounded_jobs', + 'caption_proposal_history': 'caption_proposals', 'text_proposal_history': 'text_classification_proposals'} +ID = re.compile(r'^[a-f0-9]{32}$') +SHA = re.compile(r'^[a-f0-9]{64}$') +MAX_RECORDS = 100_000 +MAX_RESPONSE = 256 * 1024 +LOCAL_JOB_FIELDS = {'admission_id', 'request_digest'} +LOCAL_CONFIG_FIELDS = {'server_url', 'requested_server_url', 'prompt_url'} +AUTHORITY_FIELDS = {'token', 'token_sha256', 'access_token', 'refresh_token', 'api_key', 'password', + 'authorization', 'cookie', 'set-cookie', 'credentials', 'admission_id', 'grant_id'} +EVIDENCE_FIELDS = {'response_sha256', 'capability_observation', 'producer_contract_source', 'canonical_request', + 'provider_outcome', 'provider_error_code', 'provider_http_status', 'build_observation', 'canonical_request_sha256'} +JOB_COMMON = {'schema_version', 'id', 'revision', 'status', 'error', 'created_at', 'updated_at', + 'source', 'config', 'system_prompt', 'prompt_version', 'prompt_sha256', + 'canonical_request_sha256', 'response_sha256', 'verification'} +JOB_OPTIONAL = {'reported_model', 'catalog_qualification', 'listed_models'} | EVIDENCE_FIELDS +PAYLOAD_FIELDS = {'raw_response_base64': 'raw_response', 'canonical_request': 'canonical_request', + 'input_image_base64': 'input_image'} +ENTRY_REQUIRED = {'id', 'job_id', 'ordinal', 'prompt', 'status', 'sample_id', 'error', 'model', + 'width', 'height', 'requested_seed'} +ENTRY_OPTIONAL = {'size', 'metadata', 'deleted_at', 'request_id', 'provider_outcome', 'attempt_evidence'} + + +def fail(message, code='invalid', status=400): + raise WorkbenchError(message, code, status) + + +def shape(value, required, optional=(), name='producer'): + if type(value) is not dict or not set(required) <= value.keys() or value.keys() - set(required) - set(optional): + fail('Unsupported ' + name + ' fields.') + + +def identity(value): + if type(value) is not str or not ID.fullmatch(value): + fail('Invalid producer historical identity.') + return value + + +def digest(value): + if type(value) is not str or not SHA.fullmatch(value): + fail('Invalid producer digest.') + return value + + +def integer(value, minimum=0, maximum=2**53-1): + if type(value) is not int or not minimum <= value <= maximum: + fail('Invalid producer count/revision.') + return value + + +def date(value): + try: + if type(value) is not str or len(value) > 80: + raise ValueError() + parsed = datetime.fromisoformat(value) + if parsed.utcoffset() is None: + raise ValueError() + return parsed + except ValueError: + fail('Invalid producer observation timestamp.') + + +def _safe(value, depth=0): + if depth > 64: + fail('Producer evidence exceeds its nesting bound.') + if type(value) is dict: + if any(type(key) is not str or key.lower() in AUTHORITY_FIELDS for key in value): + fail('Producer evidence contains live credential or admission material.') + for child in value.values(): + _safe(child, depth+1) + elif type(value) is list: + for child in value: + _safe(child, depth+1) + elif type(value) not in (str, int, float, bool, type(None)): + fail('Producer evidence is not finite JSON.') + try: + if depth == 0: + json.dumps(value, allow_nan=False, ensure_ascii=False).encode('utf-8') + except (ValueError, UnicodeError, OverflowError): + fail('Producer evidence contains invalid Unicode or nonfinite values.') + + +def _sha(raw): + return hashlib.sha256(raw).hexdigest() + + +def _bytes(value, maximum): + try: + if type(value) is not str or len(value) > 4*((maximum+2)//3): + raise ValueError() + raw = base64.b64decode(value, validate=True) + if len(raw) > maximum or base64.b64encode(raw).decode('ascii') != value: + raise ValueError() + return raw + except ValueError: + fail('Producer payload requires bounded exact base64 bytes.') + + +def _observations(job): + for field in ('canonical_request_sha256', 'response_sha256'): + if job.get(field) is not None: + digest(job[field]) + if 'capability_observation' in job: + observed = job['capability_observation'] + shape(observed, {'capabilities', 'observed_sha256'}, name='capability observation') + # The original wire digest is retained independently: the manifest is an + # already-decoded observation and cannot recreate its original spacing. + pumas.validate_manifest(encode(observed['capabilities']).encode()) + digest(observed['observed_sha256']) + if observed['capabilities']['model'] != job['config']['model']: + fail('Producer capability observation belongs to another model.') + if 'producer_contract_source' in job: + source = job['producer_contract_source'] + if type(source) is not str or not re.fullmatch('[a-f0-9]{40}', source): + fail('Invalid producer contract source identity.') + if 'provider_outcome' in job and job['provider_outcome'] not in ('not_admitted', 'unknown', 'result_received'): + fail('Unsupported historical provider outcome.') + if job.get('provider_http_status') is not None: + integer(job['provider_http_status'], 100, 599) + if job.get('provider_error_code') is not None: + text_value(job['provider_error_code'], 'Provider error code', 200) + if 'build_observation' in job: + import pumas_gateway_descriptor as descriptor + build = job['build_observation'] + # Build observation interpretation stays with its existing owner. + shape(build, {'advertisement', 'observed_sha256'}, name='retained build observation') + descriptor.decode(encode(build['advertisement']).encode()) + digest(build['observed_sha256']) + + +def _config(family, config): + if family == 'generation_history': + required = {'model', 'prompt', 'width', 'height', 'count', 'strategy'} + optional = {'seed', 'protocol', 'profile', 'prompt_model', 'size'} + else: + required = {'model', 'instruction', 'seed'} + optional = {'protocol', 'profile'} + if family != 'grounded_history': + required.add('provider') + if family == 'grounded_history': + required.add('count') + if family == 'text_proposal_history': + required |= {'labels', 'requested_model'} + shape(config, required, optional, 'producer configuration') + pumas.alias(config['model']) + mode = pumas.configuration(config, image=family == 'generation_history') + if (family == 'generation_history' and config.get('seed') is not None + or family != 'generation_history' and not mode): + integer(config.get('seed'), 0, 2**32-1) + if family == 'generation_history': + dimensions = synthetic.saved_dimensions(config) + if (dimensions['width'], dimensions['height']) != (config['width'], config['height']): + fail('Saved producer geometry differs.') + integer(config['count'], 1, 10000) + text_value(config['prompt'], 'Generation prompt', 4000) + if config['strategy'] not in ('varied', 'repeat') or mode and config['strategy'] != 'repeat': + fail('Unsupported saved prompt strategy.') + if config['strategy'] == 'varied': + pumas.alias(config.get('prompt_model')) + else: + text_value(config['instruction'], 'Producer guidance', 2000) + if family == 'grounded_history': + integer(config['count'], 1, 10) + if family == 'text_proposal_history': + text_classification_proposals.validate_labels(config['labels']) + text_value(config['requested_model'], 'Requested model identity', 200) + if family != 'grounded_history' and config['provider'] not in ( + 'pumas_chat_compatible', 'pumas_image_to_text', pumas.PROTOCOL): + fail('Unsupported producer identity.') + + +def _entry(entry, job): + shape(entry, ENTRY_REQUIRED, ENTRY_OPTIONAL, 'generation entry') + identity(entry['id']); identity(entry['job_id']) + if entry['job_id'] != job['id'] or entry['model'] != job['config']['model']: + fail('Generation entry belongs to another job/model.') + integer(entry['ordinal'], 0, job['config']['count']-1) + dimensions = synthetic.saved_dimensions(entry) + if (dimensions['width'], dimensions['height']) != (job['config']['width'], job['config']['height']): + fail('Generation entry geometry differs from its job.') + text_value(entry['prompt'], 'Saved generation prompt', 4000) + text_value(entry['error'], 'Generation error', 800, empty=True) + if entry['status'] not in ('pending', 'generating', 'completed', 'failed', 'deleted'): + fail('Unsupported saved generation entry status.') + if entry['sample_id'] is not None: + identity(entry['sample_id']) + if ((entry['status'] == 'completed') != (entry['sample_id'] is not None)): + fail('Generation entry source/deletion state differs.') + expected_seed = ((job['config']['seed'] + entry['ordinal']) % 2**32 + if job['config'].get('seed') is not None else None) + if entry['requested_seed'] != expected_seed: + fail('Generation entry seed differs from its saved ordinal.') + if 'deleted_at' in entry: + date(entry['deleted_at']) + if 'request_id' in entry: + identity(entry['request_id']) + if 'provider_outcome' in entry and entry['provider_outcome'] not in ('not_admitted', 'unknown', 'result_received'): + fail('Unsupported generation provider outcome.') + for field in ('metadata', 'attempt_evidence'): + if field in entry: + value = entry[field] + optional = {'seed', 'steps', 'guidance', 'duration_seconds', 'memory_policy', 'protocol'} | EVIDENCE_FIELDS + shape(value, (), optional if field == 'metadata' else EVIDENCE_FIELDS, 'generation result evidence') + for number in ('seed', 'steps', 'guidance', 'duration_seconds'): + if number in value and type(value[number]) not in (int, float): + fail('Generation numeric observation differs from its owner.') + if 'memory_policy' in value and value['memory_policy'] not in ('sequential_cpu_offload', 'model_cpu_offload', 'gpu'): + fail('Unsupported generation memory policy observation.') + if 'protocol' in value and value['protocol'] != pumas.PROTOCOL: + fail('Unsupported generation protocol observation.') + _observations(dict(value, config=job['config'])) + _safe(value) + _safe(entry) + + +def _validate_job_value(family, job): + if family == 'generation_history': + shape(job, {'id', 'config', 'meta', 'status', 'error', 'completed'}, EVIDENCE_FIELDS, + 'generation job') + from image_sources import metadata + if metadata(job['meta']) != job['meta']: + fail('Generation source metadata differs from its owner.') + _config(family, job['config']) + integer(job['completed'], 0, job['config']['count']) + states = ('preparing', 'generating', 'stopping', 'completed', 'failed', 'cancelled', 'interrupted') + else: + extra = {'candidates', 'raw_response_text_available'} if family == 'grounded_history' else {'intent_sha256', 'annotation', 'application'} + if family == 'caption_proposal_history': + extra.add('input_image') + if family == 'text_proposal_history': + extra |= {'labels_sha256', 'source_text_sha256'} + shape(job, JOB_COMMON | extra, JOB_OPTIONAL - {'canonical_request'}, 'proposal history') + if job['schema_version'] != 1: + fail('Unsupported producer schema version.', 'unsupported', 422) + _config(family, job['config']) + integer(job['revision'], 1) + if date(job['updated_at']) < date(job['created_at']): + fail('Producer update precedes creation.') + from project_data import validate_record_snapshot + validate_record_snapshot(job['source']) + expected_kind = 'image' if family == 'caption_proposal_history' else 'text' + if job['source']['kind'] != expected_kind: + fail('Captured source has the wrong producer modality.') + text_value(job['system_prompt'], 'Captured system prompt', 20000) + module = {'grounded_history': grounded_candidates, 'caption_proposal_history': caption_proposals, 'text_proposal_history': text_classification_proposals}[family] + if job['prompt_version'] != module.PROMPT_VERSION or job['system_prompt'] != module.SYSTEM_PROMPT: + fail('Unsupported historical producer prompt contract.', 'unsupported', 422) + if _sha(job['system_prompt'].encode()) != job['prompt_sha256']: + fail('Captured system prompt digest differs.') + digest(job['canonical_request_sha256']) + if job['response_sha256'] is not None: + digest(job['response_sha256']) + if family == 'text_proposal_history': + if _sha(encode(job['config']['labels']).encode()) != job['labels_sha256'] or _sha(job['source']['text'].encode()) != job['source_text_sha256']: + fail('Frozen labels or source text digest differs.') + if family != 'grounded_history': + digest(job['intent_sha256']) + text_value(job['verification'], 'Producer verification qualification', 2000) + if job.get('reported_model') is not None: + text_value(job['reported_model'], 'Reported model', 200) + if 'catalog_qualification' in job: + text_value(job['catalog_qualification'], 'Model catalog qualification', 2000) + if 'listed_models' in job: + if type(job['listed_models']) is not list or len(job['listed_models']) > 10000: + fail('Invalid observed serving model catalog.') + for model in job['listed_models']: + text_value(model, 'Observed serving model', 256) + if family == 'grounded_history': + if type(job['raw_response_text_available']) is not bool: + fail('Invalid exact-response text-view observation.') + if job['source']['task'] != 'text_classification' or job['source']['review'] != 'human_reviewed' or job['source']['annotation'] is None: + fail('Grounded source lacks its captured human-reviewed class.') + if len(job['source']['text']) > grounded_candidates.MAX_SOURCE: + fail('Captured grounded source exceeds its owner bound.') + _candidates(job) + elif job['application'] is not None: + shape(job['application'], {'record_id', 'revision', 'source_revision'}, name='proposal application') + application = job['application'] + if (application['record_id'] != job['source']['id'] or application['revision'] != job['source']['revision']+1 + or application['source_revision'] != job['source']['source_revision'] or job['status'] != 'applied'): + fail('Proposal application differs from its captured revisions.') + elif job['status'] == 'applied': + fail('Applied producer observation lacks its application facts.') + if family == 'caption_proposal_history': + shape(job['input_image'], {'mime', 'size', 'bytes', 'sha256', 'preparation'}, name='prepared image') + image = job['input_image'] + if image['mime'] != 'image/jpeg' or type(image['size']) is not list or len(image['size']) != 2: + fail('Invalid prepared image format.') + for size in image['size']: + integer(size, 1, 1600) + integer(image['bytes'], 1, caption_proposals.MAX_IMAGE); digest(image['sha256']) + if image['preparation'] != 'oriented canonical RGB; thumbnail max 1600; JPEG quality 95': + fail('Unsupported prepared image procedure.', 'unsupported', 422) + if job['annotation'] is not None: + validate_annotation('image_caption', job['annotation'], job['source']) + if family == 'text_proposal_history' and job['annotation'] is not None: + if job['annotation'] not in [{'label': label} for label in job['config']['labels']]: + fail('Saved target is outside its frozen label set.') + states = ('preparing', 'generating', 'stopping', 'completed', 'failed', 'cancelled', 'interrupted', 'applied', 'rejected') + if family == 'text_proposal_history': + states += ('abstained',) + identity(job['id']) + if job['status'] not in states: + fail('Unsupported producer lifecycle observation.') + text_value(job['error'], 'Producer error observation', 800, empty=True) + _observations(job); _safe(job) + + +def _job(family, job): + try: + _validate_job_value(family, job) + except WorkbenchError: + raise + except (ValueError, TypeError, KeyError, AttributeError, OverflowError): + fail('Invalid retained producer value.') + + +def _candidates(job): + values = job['candidates'] + if type(values) is not list or len(values) > job['config']['count']: + fail('Invalid retained grounded candidate list.') + if job['status'] == 'completed' and len(values) != job['config']['count']: + fail('Completed grounded output is incomplete.') + seen, texts = set(), {job['source']['text']} + for candidate in values: + shape(candidate, {'id', 'text', 'label', 'evidence', 'status', 'record_id', 'review_note'}, name='grounded candidate') + identity(candidate['id']) + if candidate['id'] in seen: + fail('Duplicate grounded candidate identity.') + seen.add(candidate['id']) + grounded_candidates.validate_candidate_values([candidate], job['source'], existing_texts=texts) + texts.add(candidate['text']) + if candidate['status'] not in ('pending_review', 'admitted', 'rejected'): + fail('Invalid grounded review disposition.') + if candidate['record_id'] is not None: + identity(candidate['record_id']) + if (candidate['status'] == 'admitted') != (candidate['record_id'] is not None): + fail('Grounded admission identity differs from its disposition.') + text_value(candidate['review_note'], 'Grounded review note', 1000, empty=True) + + +def _declarations(records): + result = {} + for envelope in records: + if envelope.get('family') == 'project_artifact': + from project_artifacts import validate_declaration + declaration = validate_declaration(envelope['document']) + if envelope['id'] != declaration['sha256'] or declaration['sha256'] in result: + fail('Invalid duplicate producer artifact declaration.') + result[declaration['sha256']] = declaration + return result + + +def _edge(edge, declarations, role): + shape(edge, {'artifact', 'media_type', 'kind', 'role', 'preservation', 'required_for'}, name='producer artifact edge') + shape(edge['artifact'], {'namespace', 'sha256'}, name='producer artifact identity') + identity(edge['artifact']['namespace']); digest(edge['artifact']['sha256']) + if edge['kind'] != 'producer_evidence' or edge['role'] != role or edge['preservation'] != 'unique' or edge['required_for'] != ['verify_producer']: + fail('Producer payload role or retention differs.') + expected_media = 'image/jpeg' if role == 'input_image' else 'application/octet-stream' if role == 'raw_response' else 'application/json' + if edge['media_type'] != expected_media: + fail('Producer payload media type differs from its role.') + declaration = declarations.get(edge['artifact']['sha256']) + if declaration is None: + fail('Producer payload declaration is missing.') + return declaration + + +def _payloads(value, declarations, roles): + shape(value, (), roles, 'producer payloads') + for role, edge in value.items(): + _edge(edge, declarations, role) + + +def decode_records(records, *, artifacts=None): + """Validate four fixed history families; absent payloads remain declared. + + With an artifact capability, every available payload is decoded by its + original owner. Missing bytes permit metadata inspection, never inference + or a new verified result. Corrupt available bytes fail closed. + """ + if type(records) is not list or len(records) > MAX_RECORDS or any(type(item) is not dict for item in records): + fail('Producer inventory exceeds its record bound.') + declarations = _declarations(records) + result = [] + seen, observed_order = set(), set() + for envelope in records: + family = envelope.get('family') + if family not in FAMILIES: + continue + shape(envelope, {'family', 'id', 'document'}, name='producer envelope') + doc = envelope['document'] + shape(doc, {'format', 'origin_catalog_id', 'ordinal', 'job', 'payloads', 'observation_integrity'} | ({'entries'} if family == 'generation_history' else set()), {'prompt_batches'} if family == 'generation_history' else (), name='producer document') + if doc['format'] != FAMILIES[family]: + fail('Unsupported producer document format.', 'unsupported', 422) + identity(doc['origin_catalog_id']); _job(family, doc['job']) + integer(doc['ordinal'], 1, 2**63-1) + order_key = (family, doc['origin_catalog_id'], doc['ordinal']) + if order_key in observed_order: + fail('Duplicate historical producer ordering.') + observed_order.add(order_key) + if doc['observation_integrity'] != _observation_integrity(doc['job']): + fail('Producer observation wire-integrity classification differs.') + if envelope['id'] != doc['origin_catalog_id'] + '.' + doc['job']['id'] or (family, envelope['id']) in seen: + fail('Producer origin/identity differs or repeats.') + seen.add((family, envelope['id'])) + roles = {'raw_response', 'canonical_request'} | ({'input_image'} if family == 'caption_proposal_history' else set()) + _payloads(doc['payloads'], declarations, roles) + if family != 'generation_history' and 'canonical_request' not in doc['payloads']: + fail('Producer canonical request declaration is missing.') + if doc['job'].get('response_sha256') is not None and 'raw_response' not in doc['payloads']: + fail('Producer response declaration is missing.') + if family == 'caption_proposal_history' and 'input_image' not in doc['payloads']: + fail('Prepared producer input declaration is missing.') + if family == 'generation_history': + _prompt_batches(doc,declarations) + if type(doc['entries']) is not list or len(doc['entries']) > doc['job']['config']['count']: + fail('Invalid generation queue closure.') + ordinals, identities, storage_orders = set(), set(), set() + for value in doc['entries']: + shape(value, {'entry', 'history', 'payloads', 'observation_integrity', 'storage_order'}, {'history_payloads'}, name='generation entry history') + integer(value['storage_order'], 1, 2**63-1) + if value['storage_order'] in storage_orders: + fail('Duplicate historical generation storage ordering.') + storage_orders.add(value['storage_order']) + if value['observation_integrity'] != _entry_integrity(value['entry']): + fail('Generation observation wire-integrity classification differs.') + _entry_history(value, doc['job']) + _entry(value['entry'], doc['job']) + ordinal, entry_id = value['entry']['ordinal'], value['entry']['id'] + if ordinal in ordinals or entry_id in identities: + fail('Duplicate generation queue entry.') + ordinals.add(ordinal); identities.add(entry_id) + _payloads(value['payloads'], declarations, {'attempt_request', 'result_request'}) + histories=value.get('history_payloads',[value['payloads'] for _ in value['history']]) + if type(histories) is not list or len(histories)!=len(value['history']) or histories[-1]!=value['payloads']: + fail('Generation evidence history differs from its observations.') + for entry,mapping in zip(value['history'],histories): + _payloads(mapping,declarations,{'attempt_request','result_request'}) + for field,role in (('attempt_evidence','attempt_request'),('metadata','result_request')): + sha=entry.get(field,{}).get('canonical_request_sha256') + if sha is not None and (role not in mapping or mapping[role]['artifact']['sha256']!=sha): + fail('Generation request declaration is missing or differs.') + if sorted(ordinals) != list(range(len(ordinals))): + fail('Generation queue ordinals have a gap.') + if doc['job']['completed'] != sum(value['entry']['status'] in ('completed', 'deleted') for value in doc['entries']): + fail('Generation completed count differs from its retained entries.') + if doc['job']['status'] == 'completed' and doc['job']['completed'] != doc['job']['config']['count']: + fail('Completed generation job lacks its complete queue.') + try: + synthetic.validate_saved_queue(doc['job'], [value['entry'] for value in doc['entries']]) + except ValueError: + fail('Retained generation geometry differs from its queue owner.') + if artifacts is not None: + _verify_payloads(family, doc, declarations, artifacts) + result.append(copy.deepcopy(envelope)) + _source_closure(result, records) + return result + + +def _read(edge, declarations, artifacts, maximum): + declaration = declarations[edge['artifact']['sha256']] + state = artifacts.status(declaration)['state'] + if state == 'missing': + return None + if state != 'verified': + fail('Producer evidence payload is corrupt.', 'integrity', 409) + return artifacts.read_blob({'sha256': declaration['sha256'], 'bytes': declaration['bytes']}, maximum_bytes=maximum) + + +def _verify_payloads(family, doc, declarations, artifacts): + job = copy.deepcopy(doc['job']) + payloads = doc['payloads'] + for role in ('raw_response', 'canonical_request', 'input_image'): + if role not in payloads: + continue + maximum = caption_proposals.MAX_IMAGE if role == 'input_image' else 32*1024*1024 if role == 'canonical_request' else image_generation.MAX_JSON if family == 'generation_history' else MAX_RESPONSE + raw = _read(payloads[role], declarations, artifacts, maximum) + if raw is None: + continue + if role == 'canonical_request': + parsed = pumas.strict_json(raw); _safe(parsed) + if _sha(raw) != job.get('canonical_request_sha256'): + fail('Canonical producer request digest differs.') + job['canonical_request'] = parsed + elif role == 'input_image': + if _sha(raw) != job['input_image']['sha256'] or len(raw) != job['input_image']['bytes']: + fail('Prepared producer image differs from its observation.') + job['input_image_base64'] = base64.b64encode(raw).decode('ascii') + else: + if _sha(raw) != job.get('response_sha256'): + fail('Producer response digest differs.') + _response_values(raw) + job['raw_response_base64'] = base64.b64encode(raw).decode('ascii') + if family == 'generation_history': + _verify_prompt_batches(doc,declarations,artifacts) + for wrapper in doc['entries']: + histories=wrapper.get('history_payloads',[wrapper['payloads'] for _ in wrapper['history']]) + for entry,mapping in zip(wrapper['history'],histories): + for field,role in (('attempt_evidence','attempt_request'),('metadata','result_request')): + if role not in mapping:continue + raw=_read(mapping[role],declarations,artifacts,32*1024*1024) + if raw is None:continue + evidence=copy.deepcopy(entry.get(field,{})) + evidence['canonical_request']=pumas.strict_json(raw) + _safe(evidence) + try:synthetic.validate_retained_attempt(job,entry,evidence) + except (ValueError,TypeError,KeyError):fail('Generation request differs from its retained entry intent.') + + else: + module = {'grounded_history': grounded_candidates, 'caption_proposal_history': caption_proposals, + 'text_proposal_history': text_classification_proposals}[family] + module.validate_retained_result(job) + if family != 'caption_proposal_history' or 'input_image_base64' in job: + expected = module.request_payload(job) if family == 'grounded_history' else module.payload(job) + if pumas.typed(job['config']) and 'capability_observation' in job: + expected['profile'] = job['capability_observation']['capabilities']['profile'] + if _sha(encode(expected).encode()) != job['canonical_request_sha256']: + fail('Producer request differs from its captured source/configuration.') + if 'canonical_request' in job and expected != job['canonical_request']: + fail('Retained producer request body differs from its owner.') + + +class InertGenerationEntries: + """Source-deletion projection; deliberately has no Jobs lifecycle methods.""" + def __init__(self, owner): + self.owner, self.db, self.access = owner, owner.session.db, owner.session.access + self.lock = self.db.lock + + @authorized('inspect', 'synthetic.snapshot') + def snapshot(self): + with self.lock: + jobs = [copy.deepcopy(item['document']['job']) for item in sorted(self.owner._records, + key=lambda item: (item['document']['origin_catalog_id'], item['document']['ordinal'])) + if item['family'] == 'generation_history'] + entries = [json.loads(row[0]) for row in self.db.execute('SELECT data FROM generation_entries ORDER BY storage_order')] + return {'jobs': jobs, 'entries': entries} + + def capture_records(self): + return self.owner.capture() + + def _entry(self, entry): + self.access.require('edit', 'dataset.delete', entry.get('sample_id')) + previous = self.db.execute('SELECT data FROM generation_entries WHERE id=%s', (entry.get('id'),)).fetchone() + if previous is None: + fail('Only an existing inert generation entry can be changed.') + old = json.loads(previous[0]) + expected = dict(old, status='deleted', sample_id=None, deleted_at=entry.get('deleted_at')) + if entry != expected or old['status'] not in ('completed', 'deleted'): + fail('Only source-deletion facts may change an inert generation entry.') + date(entry['deleted_at']) + self.db.execute('UPDATE generation_entries SET data=%s WHERE id=%s', (encode(entry), entry['id'])) + + +class ProjectProducers: + def __init__(self, session): + self.session = session + self._records, self._artifacts = [], {} + self._pending_payloads = {} + self.generation_jobs = InertGenerationEntries(self) + self.generation_entries = self.generation_jobs + + def _read(self, action, invoke): + with self.session.access.lock, self.session.lock, self.session.db.lock: + self.session.access.require('inspect', action) + self.session.check() + result = invoke(self.session.snapshot['records']) + self.session.check() + return result + + def list(self, family, *, origin_catalog_id=None, offset=0, limit=40): + if family not in FAMILIES: + fail('Unsupported producer family.') + if origin_catalog_id is not None: + identity(origin_catalog_id) + integer(offset); integer(limit, 1, 100) + def read(records): + values = sorted((item['document'] for item in records if item['family'] == family + and (origin_catalog_id is None or item['document']['origin_catalog_id'] == origin_catalog_id)), + key=lambda doc: (doc['origin_catalog_id'], doc['ordinal'])) + return dict(items=[_summary(family, doc) for doc in values[offset:offset+limit]], + total=len(values), offset=offset, limit=limit) + return self._read('project_files.producers', read) + + def get(self, family, id, *, origin_catalog_id, entries_offset=0, entries_limit=40): + if family not in FAMILIES: + fail('Unsupported producer family.') + identity(id); identity(origin_catalog_id) + integer(entries_offset); integer(entries_limit, 1, 100) + def read(records): + doc = next((item['document'] for item in records if item['family'] == family + and item['id'] == origin_catalog_id + '.' + id), None) + if doc is None: + fail('Historical producer was not found.', 'not_found', 404) + declarations = _declarations(records) + result = _summary(family, doc) + result['job'] = {key:copy.deepcopy(value) for key,value in doc['job'].items() if key != 'source'} + source = doc['job'].get('source') + result['source_reference'] = ({key:source[key] for key in + ('id','kind','revision','source_revision','content_hash','source_sha256')} if source else None) + result['payloads'] = self._payload_status(doc['payloads'], declarations) + result['observation_integrity'] = copy.deepcopy(doc['observation_integrity']) + entries = doc.get('entries', []) + page, used = [], 0 + for value in entries[entries_offset:entries_offset+entries_limit]: + item = copy.deepcopy(value) + item['payloads'] = self._payload_status(item['payloads'], declarations) + if 'history_payloads' in item:item['history_payloads']=[self._payload_status(value,declarations) for value in item['history_payloads']] + size = len(encode(item).encode()) + if used + size > 8*1024*1024: + break + page.append(item); used += size + next_offset = entries_offset + len(page) + batches=[] + for value in doc.get('prompt_batches',[])[entries_offset:entries_offset+entries_limit]: + item=copy.deepcopy(value) + item['payloads']=self._payload_status(item['payloads'],declarations) + if len(encode(batches+[item]).encode())>8*1024*1024:break + batches.append(item) + next_batch=entries_offset+len(batches) + result['prompt_batches']=dict(items=batches,total=len(doc.get('prompt_batches',[])),offset=entries_offset,limit=entries_limit, + next_offset=next_batch if next_batch0)) + self._records = records + all_declarations = _declarations(snapshot['records']) + referenced = _referenced_artifacts(records) + self._artifacts = {sha: all_declarations[sha] for sha in referenced} + return self + + def refresh(self, snapshot): + """Refresh canonical science after proven current custody drainage. + + This never creates producer jobs/admissions, deletes a local job table, + or turns a retained endpoint/identity into current execution authority. + """ + records=decode_records(snapshot['records'],artifacts=self.session.artifacts) + seen=set();orders=set() + for envelope in records: + if envelope['family']=='generation_history': + for value in envelope['document']['entries']: + if value['entry']['id'] in seen or value['storage_order'] in orders: + fail('Ambiguous generation entry identity across origins.','unsupported',422) + seen.add(value['entry']['id']);orders.add(value['storage_order']) + with self.session.db.transaction(): + for envelope in records: + if envelope['family']!='generation_history':continue + for value in envelope['document']['entries']: + entry=value['entry'] + self.session.db.execute('INSERT INTO generation_entries(id,job_id,ordinal,data,storage_order) VALUES(%s,%s,%s,%s,%s) ON CONFLICT(id) DO UPDATE SET job_id=excluded.job_id,ordinal=excluded.ordinal,data=excluded.data,storage_order=excluded.storage_order', + (entry['id'],entry['job_id'],entry['ordinal'],encode(entry),value['storage_order'])) + maximum=self.session.db.execute('SELECT COALESCE(max(storage_order),0) FROM generation_entries').fetchone()[0] + self.session.db.execute('SELECT setval(pg_get_serial_sequence(%s,%s),%s,%s)',('generation_entries','storage_order',max(1,maximum),maximum>0)) + self._records=records + declarations=_declarations(snapshot['records']) + self._artifacts={sha:declarations[sha] for sha in _referenced_artifacts(records)} + return self + + def capture(self): + result = copy.deepcopy(self._records) + by_id = {entry['id']: entry for entry in [json.loads(row[0]) for row in self.session.db.execute('SELECT data FROM generation_entries ORDER BY storage_order')]} + for envelope in result: + if envelope['family'] == 'generation_history': + for value in envelope['document']['entries']: + if value['entry']['id'] not in by_id: + fail('An inert generation entry disappeared from its projection.') + current = copy.deepcopy(by_id.pop(value['entry']['id'])) + if current != value['entry']: + value['history'].append(current) + if 'history_payloads' in value:value['history_payloads'].append(copy.deepcopy(value['payloads'])) + value['entry'] = current + if by_id: + fail('Unclassified generation entries appeared in the project projection.') + result.extend(dict(family='project_artifact', id=sha, document=copy.deepcopy(value)) for sha, value in sorted(self._artifacts.items())) + decode_records(_composed(self.session.snapshot['records'], result), artifacts=self.session.artifacts) + return result + + +def _summary(family, document): + job = document['job'] + return dict(family=family, origin_catalog_id=document['origin_catalog_id'], id=job['id'], + format=document['format'], ordinal=document['ordinal'], status=job['status'], + revision=job.get('revision'), created_at=job.get('created_at'), updated_at=job.get('updated_at'), + entry_count=len(document.get('entries', [])), candidate_count=len(job.get('candidates', [])), + prompt_batch_count=len(document.get('prompt_batches',[]))) + + +def _observation_integrity(value): + return {key: 'unverified_missing_original_wire' for key in ('capability_observation', 'build_observation') if key in value} + + +def _entry_integrity(entry): + result = {field + '.' + key: state for field in ('metadata', 'attempt_evidence') + for key, state in _observation_integrity(entry.get(field, {})).items()} + # Live image generation retains the successful response digest and decoded + # PNG, but not the original response envelope. Do not invent that envelope. + for field in ('metadata', 'attempt_evidence'): + if entry.get(field, {}).get('response_sha256') is not None: + result[field + '.response_sha256'] = 'unverified_missing_original_wire' + return result + + +def _entry_history(value, job): + history = value['history'] + if type(history) is not list or not 1 <= len(history) <= 100_000 or history[-1] != value['entry']: + fail('Invalid generation observation history.') + for entry in history: + _entry(entry, job) + if any(entry[k] != history[0][k] for k in ('id', 'job_id', 'ordinal', 'prompt', 'model', 'width', 'height', 'requested_seed')): + fail('Generation history rewrites its immutable producer intent.') + for previous, current in zip(history, history[1:]): + if previous['status'] == 'deleted': + fail('Deleted generation history cannot resume.') + if current['status'] == 'deleted': + expected = dict(previous, status='deleted', sample_id=None, deleted_at=current.get('deleted_at')) + if previous['status'] != 'completed' or current != expected: + fail('Unsupported generation deletion transition.') + elif previous['status'] == 'completed': + fail('Completed generation history cannot be rewritten.') + + +def _prompt_batches(doc, declarations): + values=doc.get('prompt_batches',[]) + if type(values) is not list or len(values)>10000: + fail('Generation prompt batch observations exceed their bound.') + if values and doc['job']['config']['strategy']!='varied': + fail('Repeat generation cannot import variation observations.') + seen=set();last_offset=0 + for wrapper in values: + shape(wrapper,{'batch','payloads'},name='prompt batch observation') + batch=wrapper['batch'] + shape(batch,{'id','offset','count','status','error','request_sha256','response_sha256','prompts'},name='prompt batch') + identity(batch['id']);digest(batch['request_sha256']) + if batch['id'] in seen:fail('Prompt batch identity repeats.') + seen.add(batch['id']) + integer(batch['offset'],0,doc['job']['config']['count']-1) + integer(batch['count'],1,min(synthetic.BATCH_SIZE,doc['job']['config']['count']-batch['offset'])) + if batch['offset']4000 or p!=p.strip() for p in batch['prompts'])): + fail('Prompt batch result shape differs.') + elif batch['prompts'] is not None or not batch['error']: + fail('Failed prompt batch claims valid prompts.') + _safe(batch) + roles={'canonical_request'} | ({'raw_response'} if batch['response_sha256'] is not None else set()) + if set(wrapper['payloads'])!=roles:fail('Prompt batch exact payload declarations differ.') + _payloads(wrapper['payloads'],declarations,roles) + for role,field in (('canonical_request','request_sha256'),('raw_response','response_sha256')): + if role in wrapper['payloads'] and wrapper['payloads'][role]['artifact']['sha256']!=batch[field]: + fail('Prompt batch payload digest differs.') + + +def _verify_prompt_batches(doc,declarations,artifacts): + entries=[v['entry'] for v in doc['entries']] + expected=None;available=True + for wrapper in doc.get('prompt_batches',[]): + batch=wrapper['batch'] + request=_read(wrapper['payloads']['canonical_request'],declarations,artifacts,32*1024*1024) + response=(_read(wrapper['payloads']['raw_response'],declarations,artifacts,256*1024) + if 'raw_response' in wrapper['payloads'] else None) + if response is not None:_response_values(response) + if request is None: + available=False;continue + parsed=caption_proposals.strict_json(request);_safe(parsed) + try:synthetic.validate_retained_prompt_batch(doc['job']['config'],batch,parsed,response) + except (ValueError,KeyError,TypeError,IndexError):fail('Retained prompt batch wire differs from its queue intent.') + if expected is None:expected=[v['prompt'] for v in entries[:batch['offset']]] + if available and batch['offset']!=len(expected):fail('Prompt batch offset differs from retained unique results.') + if available and json.loads(parsed['messages'][1]['content'])['recent_prompts_to_avoid']!=expected[-synthetic.BATCH_SIZE:]: + fail('Prompt batch context differs from preceding retained queue prompts.') + if batch['status']=='completed' and available: + seen={' '.join(value.casefold().split()) for value in expected} + for prompt in batch['prompts']: + key=' '.join(prompt.casefold().split()) + if key not in seen:expected.append(prompt);seen.add(key) + if available and expected is not None and [v['prompt'] for v in entries]!=expected[:len(entries)]: + fail('Saved generation prompts differ from exact variation results.') + + +def _capture_prompt_batches(owner,job,batches): + result=[] + for original in batches: + batch=copy.deepcopy(original) + request=batch.pop('canonical_request') + raw=batch.pop('raw_response_base64') + synthetic.validate_retained_prompt_batch(job['config'],batch,request,_bytes(raw,256*1024) if raw is not None else None) + _safe(request) + payloads={'canonical_request':_publish(owner,encode(request).encode(),'canonical_request')} + if raw is not None: + data=_bytes(raw,256*1024);_response_values(data) + payloads['raw_response']=_publish(owner,data,'raw_response','application/octet-stream') + result.append(dict(batch=batch,payloads=payloads)) + return result + + +def _referenced_artifacts(records): + result = set() + for envelope in records: + doc = envelope['document'] + mappings = [doc['payloads']] + mappings.extend(value['payloads'] for value in doc.get('prompt_batches',[])) + if envelope['family'] == 'generation_history': + mappings.extend(value['payloads'] for value in doc['entries']) + mappings.extend(mapping for value in doc['entries'] for mapping in value.get('history_payloads',[])) + result.update(edge['artifact']['sha256'] for mapping in mappings for edge in mapping.values()) + return result + + +def _portable_observations(value): + value = copy.deepcopy(value) + if 'build_observation' in value: + build = value['build_observation'] + shape(build, {'advertisement', 'observed_sha256'}, {'server_url'}, 'stored build observation') + build.pop('server_url', None) + return value + + +def _historical_urls(value): + """Known attribution URLs are historical facts, never current resolvers.""" + if type(value) is dict: + for key, child in value.items(): + if key in ('server_url', 'requested_server_url', 'prompt_url'): + import ai_http + ai_http.validate_url('llamacpp', child) + _historical_urls(child) + elif type(value) is list: + for child in value: + _historical_urls(child) + + +def _publish(owner, raw, role, media='application/json'): + declaration = dict(format='project_artifact_v1', sha256=_sha(raw), bytes=len(raw)) + previous = owner._pending_payloads.get(declaration['sha256']) + if previous is not None and previous != raw: + fail('Conflicting exact producer payload bytes.') + owner._pending_payloads[declaration['sha256']] = raw + owner._artifacts[declaration['sha256']] = declaration + return dict(artifact=dict(namespace=owner.session.snapshot['project_id'], sha256=declaration['sha256']), + media_type=media, kind='producer_evidence', role=role, + preservation='unique', required_for=['verify_producer']) + + +def _capture_payloads(owner, family, job): + payloads = {} + for field, role in PAYLOAD_FIELDS.items(): + value = job.pop(field, None) + if value is None: + continue + if field == 'canonical_request': + _safe(value) + raw = encode(value).encode() + else: + maximum = caption_proposals.MAX_IMAGE if role == 'input_image' else image_generation.MAX_JSON if family == 'generation_history' else MAX_RESPONSE + raw = _bytes(value, maximum) + if role == 'raw_response': + _response_values(raw) + payloads[role] = _publish(owner, raw, role, 'image/jpeg' if role == 'input_image' else 'application/octet-stream' if role == 'raw_response' else 'application/json') + return payloads + + +def _response_values(raw): + try: + # The existing chat owner accepts JSON's historical UTF-16 byte + # encodings too. Inspect values without rewriting original bytes. + parsed = caption_proposals.strict_json(raw) + except (UnicodeError, ValueError, RecursionError): + fail('Opaque provider response bytes lack a supported credential-free classification.', 'unsupported', 422) + _safe(parsed) + return parsed + + +def _capture_entry(owner, entry, storage_order): + result, payloads = copy.deepcopy(entry), {} + for field, role in (('attempt_evidence', 'attempt_request'), ('metadata', 'result_request')): + if field in result: + result[field] = _portable_observations(result[field]) + request = result[field].pop('canonical_request', None) + if request is not None: + _safe(request) + raw = encode(request).encode() + if _sha(raw) != result[field]['canonical_request_sha256']: + fail('Generation request body digest differs.') + payloads[role] = _publish(owner, raw, role) + return dict(entry=result, history=[copy.deepcopy(result)], payloads=payloads, storage_order=storage_order, + observation_integrity=_entry_integrity(result)) + + +def _capture_job(owner, family, original, ordinal, entries): + job = copy.deepcopy(original) + prompt_batches = job.pop('prompt_batches',None) if family == 'generation_history' else None + # These are the complete classified local fields, not a denylist fallback. + # All remaining source fields must satisfy the owned closed codec below. + for key in LOCAL_JOB_FIELDS: + if key in job: + (identity if key == 'admission_id' else digest)(job[key]) + job.pop(key, None) + if type(job.get('config')) is not dict: + fail('Missing producer configuration.') + for field in LOCAL_CONFIG_FIELDS: + if field in job['config']: + import ai_http + ai_http.validate_url('llamacpp', job['config'][field]) + job['config'].pop(field, None) + if family != 'caption_proposal_history' and 'input_image_base64' in job: + fail('Unexpected image payload in this producer family.') + job = _portable_observations(job) + if 'source' in job: + _historical_urls(job['source']) + if family == 'grounded_history': + view = job.pop('raw_response', None) + job['raw_response_text_available'] = view is not None + if view is not None: + if type(view) is not str: + fail('Invalid retained grounded response view.') + raw = _bytes(job['raw_response_base64'], MAX_RESPONSE) + if raw.decode('utf-8') != view: + fail('Grounded response text view differs from its exact bytes.') + if family != 'generation_history' and 'canonical_request' not in job: + module = {'grounded_history': grounded_candidates, 'caption_proposal_history': caption_proposals, + 'text_proposal_history': text_classification_proposals}[family] + job['canonical_request'] = module.request_payload(job) if family == 'grounded_history' else module.payload(job) + if _sha(encode(job['canonical_request']).encode()) != job['canonical_request_sha256']: + fail('Reconstructed canonical producer request differs from its stored identity.') + payloads = _capture_payloads(owner, family, job) + document = dict(format=FAMILIES[family], origin_catalog_id=owner._origin, ordinal=ordinal, + job=job, payloads=payloads, observation_integrity=_observation_integrity(job)) + if prompt_batches is not None: + document['prompt_batches'] = _capture_prompt_batches(owner,job,prompt_batches) + if family == 'generation_history': + document['entries'] = [_capture_entry(owner, entry, storage_order) for entry, storage_order in entries if entry['job_id'] == job['id']] + return dict(family=family, id=owner._origin+'.'+job['id'], document=document) + + +def _capture_source(self, *, origin_catalog_id=None): + """Explicit historical extraction primitive; never called by inert Open. + + Reads the five named producer tables. It may publish exact evidence bytes, + but neither alters the source rows nor constructs any producer supervisor. + """ + self._origin = identity(origin_catalog_id or self.session.db.dataset_id) + self._pending_payloads = {} + entries = [] + for row_id, job_id, ordinal, raw, storage_order in self.session.db.execute( + 'SELECT id,job_id,ordinal,data,storage_order FROM generation_entries ORDER BY storage_order'): + entry = json.loads(raw) + if (entry.get('id'), entry.get('job_id'), entry.get('ordinal')) != (row_id, job_id, ordinal): + fail('Generation row identity differs from its observed document.') + entries.append((entry, storage_order)) + records = [] + for family, table in TABLES.items(): + for row_id, raw, ordinal in self.session.db.execute('SELECT id,data,ordinal FROM ' + table + ' ORDER BY ordinal'): + job = json.loads(raw) + if job.get('id') != row_id: + fail('Producer row identity differs from its observed document.') + records.append(_capture_job(self, family, job, ordinal, entries)) + known_jobs = {item['document']['job']['id'] for item in records if item['family'] == 'generation_history'} + if any(entry['job_id'] not in known_jobs for entry, _ in entries): + fail('Generation entry has no retained producer job.') + records.extend(dict(family='project_artifact', id=sha, document=copy.deepcopy(declaration)) + for sha, declaration in sorted(self._artifacts.items())) + composed = _composed(self.session.snapshot['records'], records) + decode_records(composed, artifacts=_CapturedEvidence(self)) + for sha, raw in self._pending_payloads.items(): + self.session.artifacts.publish_bytes(raw, self._artifacts[sha], maximum_bytes=32*1024*1024) + self._pending_payloads = {} + return records + + + +def _producer_document(records, family, job_id, source_id=None): + candidates = [item['document'] for item in decode_records(records) + if item['family'] == family and item['document']['job']['id'] == job_id + and (source_id is None or item['document']['job'].get('source', {}).get('id') == source_id)] + if len(candidates) != 1: + fail('Producer evidence has a missing or ambiguous historical producer closure.') + return candidates[0] + + +def _same_payload(value, document, role): + edge = document['payloads'].get(role) + if edge is None or _sha(encode(value).encode()) != edge['artifact']['sha256']: + fail('Retained request evidence differs from its declared producer payload.') + + +def validate_target_proposal(evidence, record, records): + """Validate owner-generated draft evidence, never mint current approval.""" + if type(evidence) is not dict: + fail('Applied target proposal evidence must be an object.') + text_target = 'source_text_sha256' in evidence + required = {'job_id', 'applied_revision', 'captured_revision', 'source_revision', 'source_sha256', + 'config', 'prompt_version', 'prompt_sha256', 'canonical_request_sha256', + 'response_sha256', 'reported_model', 'verification'} + required |= {'source_text_sha256', 'labels_sha256'} if text_target else {'canonical_image_sha256', 'input_image'} + shape(evidence, required, EVIDENCE_FIELDS - required, 'applied proposal evidence') + _safe(evidence); _historical_urls(evidence) + family = 'text_proposal_history' if text_target else 'caption_proposal_history' + doc = _producer_document(records, family, identity(evidence['job_id']), record['id']) + expected_verification = ('Exact label shape only; applied as draft, never approved.' if text_target + else 'Caption shape only; applied as draft, never approved.') + if evidence['verification'] != expected_verification: + fail('Applied target verification qualification differs from its owner.') + job, source = doc['job'], doc['job']['source'] + for key in ('prompt_version', 'prompt_sha256', 'canonical_request_sha256', 'response_sha256'): + if evidence[key] != job[key]: + fail('Applied target evidence differs from its producer ' + key + '.') + if type(evidence['config']) is not dict: + fail('Applied target configuration must be an object.') + config = {key: value for key, value in evidence['config'].items() if key not in LOCAL_CONFIG_FIELDS} + if config != job['config'] or evidence['reported_model'] != job.get('reported_model'): + fail('Applied target evidence rewrites its producer configuration/result.') + if (type(evidence['applied_revision']) is not int or evidence['applied_revision'] != source['revision'] + 1 + or evidence['captured_revision'] != source['revision'] + or evidence['source_revision'] != source['source_revision'] + or evidence['source_sha256'] != source['source_sha256'] + or evidence['applied_revision'] > record['revision']): + fail('Applied target evidence belongs to different captured revisions.') + if job['status'] != 'applied' or job['application'] != { + 'record_id': record['id'], 'revision': evidence['applied_revision'], 'source_revision': evidence['source_revision']}: + fail('Applied target lacks its historical application disposition.') + if record['revision'] == evidence['applied_revision'] and ( + record['annotation'] != job['annotation'] or record['review'] != 'draft' + or record['task'] != ('text_classification' if text_target else 'image_caption')): + fail('A proposal application cannot establish human review or a different target.') + if text_target: + if evidence['source_text_sha256'] != job['source_text_sha256'] or evidence['labels_sha256'] != job['labels_sha256']: + fail('Applied target source/label identity differs.') + elif evidence['canonical_image_sha256'] != source['content_hash'] or evidence['input_image'] != job['input_image']: + fail('Applied target prepared/oriented image identity differs.') + for key in EVIDENCE_FIELDS - {'canonical_request', 'canonical_request_sha256', 'response_sha256'}: + if key in evidence and evidence[key] != job.get(key): + fail('Applied target provider observation differs.') + if 'canonical_request' in evidence: + _same_payload(evidence['canonical_request'], doc, 'canonical_request') + return True + + +def validate_record_origin(origin, record, records): + """Recognize only the two producer provenance variants owned here.""" + if type(origin) is not dict or origin.get('method') not in ('model_generated', 'model_grounded_rewrite'): + return False + _safe(origin); _historical_urls(origin) + if origin['method'] == 'model_generated': + shape(origin, {'method', 'source_sha256', 'rights', 'generation'}, {'rights_note_correction'}, 'generated source provenance') + generation = origin['generation'] + shape(generation, {'job_id', 'ordinal', 'prompt', 'model', 'width', 'height', 'requested_seed'}, {'metadata', 'size'}, 'generated source binding') + docs = [item['document'] for item in decode_records(records) if item['family'] == 'generation_history' + and item['document']['job']['id'] == generation['job_id']] + matched = [] + for doc in docs: + for wrapper in doc['entries']: + for entry in wrapper['history']: + if entry['sample_id'] == record['id'] and entry['ordinal'] == generation['ordinal']: + matched.append((wrapper, entry)) + if len(matched) != 1: + fail('Generated source lacks an exact producer entry/source closure.') + wrapper, entry = matched[0] + for key in ('job_id', 'ordinal', 'prompt', 'model', 'width', 'height', 'requested_seed', 'size'): + if key in generation and generation[key] != entry.get(key): + fail('Generated source intent differs from its retained entry.') + if 'metadata' in generation: + metadata = copy.deepcopy(generation['metadata']) + request = metadata.pop('canonical_request', None) + if request is not None: + edge = wrapper['payloads'].get('result_request') + if edge is None or _sha(encode(request).encode()) != edge['artifact']['sha256']: + fail('Generated source result request differs.') + if _portable_observations(metadata) != entry.get('metadata'): + fail('Generated source result metadata differs.') + if record['kind'] != 'image' or origin['source_sha256'] not in {record['source_sha256'], *( + row['sha256'] for item in records if item['family'] == 'workbench_record' and item['id'] == record['id'] + for row in item['document']['source'].get('revisions', []))}: + fail('Generated source hash differs from its retained image lineage.') + else: + required = {'method', 'rights', 'normalization', 'provider', 'server_url', 'requested_model', 'requested_seed', + 'instruction', 'system_prompt', 'prompt_version', 'prompt_sha256', 'reported_model', + 'canonical_request_sha256', 'requested_count', 'max_tokens', 'job_id', 'candidate_id', + 'response_sha256', 'source', 'evidence', 'verification', 'admission_note'} + shape(origin, required, EVIDENCE_FIELDS | {'profile', 'rights_note_correction'}, 'grounded source provenance') + doc = _producer_document(records, 'grounded_history', identity(origin['job_id'])) + job = doc['job'] + values = [candidate for candidate in job['candidates'] if candidate['id'] == origin['candidate_id']] + if len(values) != 1: + fail('Grounded source candidate closure is missing.') + candidate = values[0] + if candidate['status'] != 'admitted' or candidate['record_id'] != record['id'] or candidate['text'] != record['text']: + fail('Grounded source differs from its reviewed candidate.') + for key, expected in {'requested_model': job['config']['model'], 'requested_seed': job['config']['seed'], + 'instruction': job['config']['instruction'], 'requested_count': job['config']['count'], 'max_tokens': 6000, + 'system_prompt': job['system_prompt'], 'prompt_version': job['prompt_version'], + 'prompt_sha256': job['prompt_sha256'], 'canonical_request_sha256': job['canonical_request_sha256'], + 'response_sha256': job['response_sha256'], 'reported_model': job.get('reported_model'), + 'evidence': candidate['evidence'], 'admission_note': candidate['review_note'], 'verification': job['verification']}.items(): + if origin[key] != expected: + fail('Grounded source provenance differs from its producer ' + key + '.') + if (origin['provider'] != (pumas.PROTOCOL if pumas.typed(job['config']) else 'pumas_chat_compatible') + or origin['normalization'] != 'NFC; LF newlines' + or pumas.typed(job['config']) and origin.get('profile') != job['config'].get('profile')): + fail('Grounded source producer or normalization attribution differs.') + if record['revision'] == 1 and (record['review'] != 'draft' or record['task'] != 'text_classification' + or record['annotation'] != {'label':candidate['label']}): + fail('Grounded admission cannot establish human review or a different target.') + for key in EVIDENCE_FIELDS - {'canonical_request', 'canonical_request_sha256', 'response_sha256'}: + if key in origin and origin[key] != job.get(key): + fail('Grounded source provider observation differs.') + expected_source = {key: job['source'][key] for key in ('id', 'revision', 'source_revision', 'content_hash', 'text', 'annotation')} + if origin['source'] != expected_source: + fail('Grounded source provenance rewrites its captured input.') + if 'canonical_request' in origin: + _same_payload(origin['canonical_request'], doc, 'canonical_request') + return True + + +def validate_operation_result(operation, target, result, records): + from project_producer_execution import validate_operation_result as validate_action + return validate_action(operation, target, result, records) + + +def _composed(current, upserts): + result = {(item['family'], item['id']): item for item in current} + result.update({(item['family'], item['id']): item for item in upserts}) + return [result[key] for key in sorted(result)] + + +def _source_closure(producers, records): + assets = {item['id']: item['document'] for item in records + if item['family'] in ('workbench_record', 'workbench_text')} + for envelope in producers: + doc, family = envelope['document'], envelope['family'] + job = doc['job'] + if family == 'generation_history': + for wrapper in doc['entries']: + for entry in wrapper['history']: + if entry['sample_id'] is not None and entry['sample_id'] not in assets: + fail('Generated source canonical record is missing.') + continue + source = job['source'] + asset = assets.get(source['id']) + if asset is None or source not in asset.get('history', []): + fail('Producer captured source differs from its retained Workbench history.') + if family == 'grounded_history': + for candidate in job['candidates']: + if candidate['record_id'] is not None: + target = assets.get(candidate['record_id']) + if target is None or target['current']['text'] != candidate['text']: + fail('Admitted grounded candidate canonical record is missing or differs.') + + +class _CapturedEvidence: + """Immutable source buffers prove owner semantics before any publication.""" + def __init__(self, owner): + self.owner = owner + + def status(self, declaration): + raw = self.owner._pending_payloads.get(declaration['sha256']) + if raw is None: + return self.owner.session.artifacts.status(declaration) + if len(raw) != declaration['bytes'] or _sha(raw) != declaration['sha256']: + fail('Captured producer payload identity differs.') + return {'state': 'verified'} + + def read_blob(self, descriptor, *, maximum_bytes): + raw = self.owner._pending_payloads.get(descriptor['sha256']) + if raw is None: + return self.owner.session.artifacts.read_blob(descriptor, maximum_bytes=maximum_bytes) + if len(raw) != descriptor['bytes'] or len(raw) > maximum_bytes: + fail('Captured producer payload exceeds its owner bound.') + return raw diff --git a/project_projection.py b/project_projection.py new file mode 100644 index 00000000..b3f05b29 --- /dev/null +++ b/project_projection.py @@ -0,0 +1,334 @@ +"""Concrete inert composition of external-project projection owners. + +No registry, worker constructor, process recovery, dependency installation or +project-script import belongs here. The fixed codecs validate the same candidate +checkpoint before reconstruction and before each canonical publication. +""" +import copy +from datetime import datetime +import re +from types import SimpleNamespace + +from project_artifacts import ProjectArtifacts, validate_declaration +from project_data import ProjectData, FAMILIES as DATA_FAMILIES, decode_data_records +from project_data import validate_operation_result as validate_data_result +from project_history import ProjectHistory, FAMILY_FORMATS as HISTORY_FAMILIES +from project_history import decode_records as decode_history_records +from project_history import validate_operation_result as validate_history_result +from project_material import ProjectMaterial, FAMILIES as MATERIAL_FAMILIES +from project_material import decode_records as decode_material_records +from project_material import validate_operation_result as validate_material_result +from project_producers import ProjectProducers, FAMILIES as PRODUCER_FAMILIES +from project_producers import decode_records as decode_producer_records +from project_session import ProjectSession, operation_documents, operation_records +from project_locks import ProjectLock +from project_execution import ProjectExecution, validate_operation_result as validate_execution_result +from project_producer_execution import ProjectProducerExecution, validate_operation_result as validate_producer_result +from project_files import ProjectFiles, decode_records as decode_file_records +from project_files import validate_operation_result as validate_file_result +from project_study_execution import ProjectStudyExecution, validate_operation_result as validate_study_execution_result +from project_runtime import ProjectRuntime, validate_operation_result as validate_runtime_result +from project_native_execution import FAMILY as NATIVE_FAMILY, decode_records as decode_native_records +from project_native_preparation import ProjectNativePreparation, validate_operation_result as validate_preparation_result +from project_sources import SourceAdapter, validate_operation_result as validate_source_result +from workbench import WorkbenchError +from workbench import timestamp + + +def validate_artifact_resolution(operation, result, records): + if operation != 'artifacts.resolve': + return False + if (type(result) is not dict or set(result) != { + 'format', 'sha256', 'bytes', 'actor_id', 'observed_at'} + or result['format'] != 'project_artifact_resolution_v1' + or type(result['actor_id']) is not str or not re.fullmatch('[a-f0-9]{32}', result['actor_id']) + or type(result['observed_at']) is not str): + raise WorkbenchError('Invalid artifact resolution observation.') + try: + if datetime.fromisoformat(result['observed_at']).utcoffset() is None: + raise ValueError() + except ValueError: + raise WorkbenchError('Artifact observation timestamp requires an offset.') from None + declaration = validate_declaration(dict(format='project_artifact_v1', sha256=result['sha256'], bytes=result['bytes'])) + if not any(item['family'] == 'project_artifact' and item['id'] == declaration['sha256'] + and item['document'] == declaration for item in records): + raise WorkbenchError('Artifact resolution observation has no exact declaration.') + return True + + +class ProjectProjection: + def __init__(self, store, db, access, roots, *, custody=None, restore=True, runtime_sources=None): + self.store, self.db, self.access = store, db, access + self.custody = custody + self.pending_original = [] + self.project_id = store.snapshot()['project_id'] + self.artifacts = ProjectArtifacts(store) + self.sources = None + self._disclosures = set() + # Only installation configuration supplies these aliases. Neither a + # project descriptor nor an HTTP request supplies filesystem roots. + self._runtime_sources = copy.deepcopy(runtime_sources or {}) + try: + self.session = ProjectSession(store, db, access, self.artifacts, + validate_records=self.validate_records) + # Install before domain constructors retain their lock references. + db.lock = ProjectLock(access.lock, self.session.lock, db.lock) + self.producers = ProjectProducers(self.session) + self.data = ProjectData(self.session, generation_entries=self.producers.generation_jobs) + self.materials = ProjectMaterial(self.session) + self.history = ProjectHistory(self.session, materials=self.materials, + authorize_disclosure=self.authorize_disclosure) + if restore: + self.producers.restore(self.session.snapshot) + self.data.restore() + self.materials.restore() + # These owners create only disposable local directories. Their + # semantic capture/preparation methods run solely on explicit calls. + from projects import Projects + from model_definitions import ModelDefinitions + from pipeline import Pipelines + self.context = SimpleNamespace(path=db.path, db=db, lock=db.lock, + access=self.materials.owner_access, + workbench=self.data.workbench, + releases=self.data.releases, runs=self.history) + self.sources = Projects(self.context, roots) + self.context.projects = self.sources + self.context.pipelines = self.pipelines = Pipelines(self.context) + self.context.models = self.models = ModelDefinitions(self.context) + self.source_adapter = SourceAdapter(self) + self.text = _TextView(self) + self.execution = ProjectExecution(self) + self.native_preparation=ProjectNativePreparation(self) + self.context.project_native_preparation=self.native_preparation + self.runtime = ProjectRuntime(self, resolve_configuration=self.runtime_configuration) + self.execution.producer_execution = ProjectProducerExecution( + self.session, self.data, self.producers, + runtime_access=self.execution.runtime_access, execution=self.execution) + self.files = ProjectFiles(self.session) + self.study_execution = ProjectStudyExecution(self, self.execution) + if restore: + self.study_execution.restore_authoring() + else: + # Explicit original-host recovery validates custody before any + # science cache reconstruction or local execution constructor. + self.session.available = False + except BaseException: + self.close() + raise + + @property + def schema(self): + return self.db.schema + + def execute(self, *args, **kwargs): + return self.db.execute(*args, **kwargs) + + def validate_records(self, records, *, project_id=None): + project_id = self.project_id if project_id is None else project_id + claimed = {'workbench_text', 'project_artifact', 'project_operation', 'project_file', 'project_fork', 'project_extraction', NATIVE_FAMILY} | set(DATA_FAMILIES) | set( + MATERIAL_FAMILIES) | set(HISTORY_FAMILIES) | set(PRODUCER_FAMILIES) + identities = set() + declarations = {} + for item in records: + identity = (item['family'], item['id']) + if item['family'] not in claimed or identity in identities: + raise WorkbenchError('Unknown or duplicate owned project family.', 'unsupported', 409) + identities.add(identity) + if item['family'] == 'project_artifact': + declaration = validate_declaration(item['document']) + if declaration['sha256'] != item['id']: + raise WorkbenchError('Artifact inventory identity differs.') + declarations[item['id']] = declaration + decode_data_records(records) + from project_fork import decode_fork + decode_fork(records, project_id=project_id) + from project_transfer import decode_records as decode_extraction_records + decode_extraction_records(records) + operation_records(records, project_id=project_id) + decode_file_records(records) + decode_native_records(records,artifacts=self.artifacts) + materials = decode_material_records(records) + for document in materials.values(): + for edge in document.get('artifacts', []): + blob = edge['blob'] + if declarations.get(blob['sha256']) != dict(format='project_artifact_v1', **blob): + raise WorkbenchError('Material artifact declaration is missing or inconsistent.') + # Resolve every cross-owner reference against THIS candidate, rather + # than a resolver that still sees the preceding committed checkpoint. + candidate = SimpleNamespace(snapshot=dict(project_id=project_id, records=records), + artifacts=self.artifacts, check=lambda: None) + decode_producer_records(records, artifacts=self.artifacts) + decode_history_records(records, materials=ProjectMaterial(candidate), artifacts=self.artifacts) + for receipt in operation_documents(records): + operation, result = receipt['operation'], receipt['result'] + if (validate_data_result(operation, result, records) + or validate_material_result(operation, result, records) + or validate_history_result(operation, result, records) + or validate_source_result(operation, result, records) + or validate_execution_result(operation, result, records) + or validate_producer_result(operation, receipt['target'], result, records) + or validate_file_result(operation, result, records) + or validate_study_execution_result(operation, result, records) + or validate_runtime_result(operation, result, records) + or validate_preparation_result(operation, result, records) + or validate_artifact_resolution(operation, result, records)): + continue + raise WorkbenchError('No project owner accepts this operation receipt.', 'unsupported', 409) + + def resolve_artifact(self, body, expected_checkpoint, key): + """Explicitly supply exact bytes from a configured current local root.""" + if type(body) is not dict or set(body) != {'sha256', 'root', 'path'}: + raise WorkbenchError('Choose one declared artifact and a current rooted file.') + with self.access.lock, self.session.lock, self.db.lock: + actor = self.access.require('edit', 'artifacts.resolve', body['sha256'], owner_only=True) + self.session.check() + declaration = next((item['document'] for item in self.session.snapshot['records'] + if item['family'] == 'project_artifact' and item['id'] == body['sha256']), None) + if declaration is None: + raise WorkbenchError('Artifact is not declared by this project.', 'not_found', 404) + def invoke(): + with self.sources._file(body['root'], body['path']) as (descriptor, info): + if info.st_size != declaration['bytes']: + raise WorkbenchError('Supplied file length differs from the declared artifact.', 'conflict', 409) + self.artifacts.publish_fd(descriptor, declaration) + return dict(format='project_artifact_resolution_v1', sha256=declaration['sha256'], + bytes=declaration['bytes'], actor_id=actor.actor_id, observed_at=timestamp()) + return self.session.mutate('artifacts.resolve', body['sha256'], body, expected_checkpoint, key, + invoke, lambda result, before: dict(records=[], result=result)) + + def runtime_manifest(self, descriptor): + """Read only a current local registration during explicit preparation. + + An imported descriptor's root is never a filesystem capability. Captured + manifests already in canonical artifacts need no current registration. + """ + from pathlib import Path + import hashlib + if descriptor.get('catalog_id') != self.project_id: + raise WorkbenchError('Register this runtime under the current project before preparation.', + 'unavailable', 409) + if descriptor.get('profile') == 'captured_cpu_python_v1': + from runtime_snapshots import RuntimeSnapshots + from recipe_process import read_regular, CPU_MANIFEST_LIMIT + with self.execution.runtime_inspection(): + current = RuntimeSnapshots(self.db, self.execution.runtime_access, self.db.path).get( + descriptor['manifest_sha256'], verify=False) + if current != descriptor: + raise WorkbenchError('Current runtime registration differs from the selected descriptor.', 'stale', 409) + raw = read_regular(Path(current['root']) / 'manifest.json', CPU_MANIFEST_LIMIT) + elif descriptor.get('profile') == 'managed_torch_python_v1': + from torch_runtimes import TorchRuntimes + current, raw = TorchRuntimes(self.db, self.access, self.db.path).get( + descriptor['manifest_sha256'], verify=False) + if current != descriptor: + raise WorkbenchError('Current runtime registration differs from the selected descriptor.', 'stale', 409) + else: + raise WorkbenchError('Unsupported current runtime registration.', 'unsupported', 409) + if len(raw) != descriptor['manifest_bytes'] or hashlib.sha256(raw).hexdigest() != descriptor['manifest_sha256']: + raise WorkbenchError('Current runtime manifest changed during capture.', 'stale', 409) + return raw + + def runtime_configuration(self, request): + if (type(request) is not dict or set(request) != {'source'} + or type(request['source']) is not str or request['source'] not in self._runtime_sources): + raise WorkbenchError('Choose an explicitly configured current runtime source.', 'unavailable', 409) + return copy.deepcopy(self._runtime_sources[request['source']]) + + def runtime_sources(self): + with self.access.lock, self.session.lock, self.db.lock: + self.access.require('admin', 'runtime.sources', owner_only=True) + self.session.check() + return dict(sources=sorted(self._runtime_sources)) + + def authorize_disclosure(self, run, population): + self.access.require('inspect', 'project_history.artifact', run['id']) + self.access.require('expose-holdout', 'project_history.disclosure', run['id']) + origin = run['dataset_id'] + digest = self.history.payload_set_sha256(run['id'], origin_catalog_id=origin) + actor = self.access._context().actor_id + if (actor, origin, run['id'], digest) not in self._disclosures: + raise WorkbenchError('Explicitly disclose this retained Run under current local authority first.', + 'forbidden', 403) + + def disclose(self, run_id, body, expected_checkpoint, key): + with self.access.lock, self.session.lock, self.db.lock: + result = self.history.record_disclosure(run_id, body, expected_checkpoint, key) + actor = self.access._context().actor_id + self._disclosures.add((actor, result['origin_catalog_id'], run_id, result['payload_set_sha256'])) + return result + + def reconcile_inert(self, snapshot): + """Rebuild selected science after actual local custody has drained. + + This preserves existing local Run/admission rows solely for the current + reconciler's terminal proof. No imported authority table is populated. + """ + execution = self.execution + with self.access.lock, self.session.lock, self.db.lock: + if execution._reconstructing: + raise RuntimeError('Project reconstruction already has an owner.') + execution._reconstructing = True + try: + self.session.reconcile_selection(snapshot) + execution.abandon_pending() + self.study_execution.abandon_pending() + execution.producer_execution.abandon_pending() + self.producers.refresh(snapshot) + self.data.restore(snapshot) + self.materials.reconcile_projection() + self.study_execution.restore_authoring(snapshot['records']) + except BaseException: + self.session.available = False + raise + finally: + execution._reconstructing = False + + def close(self): + execution = getattr(self, 'execution', None) + if execution is not None: + execution.close() + if self.sources is not None: + self.sources.close() + self.artifacts.close() + self._disclosures.clear() + self.db.close() + + +class _TextView: + """Preserve the M1 text wire projection over the complete data owner.""" + def __init__(self, projection): + self.projection = projection + + @property + def available(self): + return self.projection.session.available + + def _tag(self, value): + snapshot = self.projection.session.snapshot + return dict(value, project_id=snapshot['project_id'], checkpoint=snapshot['checkpoint']) + + def records(self): + self.projection.session.check() + from project_fork import active_records + snapshot = self.projection.session.snapshot + records = active_records(snapshot['records'], project_id=snapshot['project_id']) + return self._tag(dict(items=[copy.deepcopy(item['document']['current']) for item in records + if item['family'] in ('workbench_record', 'workbench_text') + and item['document']['current']['kind'] == 'text'])) + + def get(self, identity): + result = self.projection.data.get(identity) + if result['kind'] != 'text': + raise WorkbenchError('Use the modality-aware data operation for this record.', 'unsupported', 409) + return self._tag(result) + + def history(self, identity): + self.get(identity) + return self.projection.data.history(identity) + + def import_text(self, body, expected_checkpoint, key): + return self._tag(self.projection.data.import_text(body, expected_checkpoint, key)) + + def annotate(self, identity, body, expected_checkpoint, key): + self.get(identity) + return self._tag(self.projection.data.annotate(identity, body, expected_checkpoint, key)) diff --git a/project_runtime.py b/project_runtime.py new file mode 100644 index 00000000..a94cc6e6 --- /dev/null +++ b/project_runtime.py @@ -0,0 +1,319 @@ +"""Explicit current-host CPU runtime capture through a canonical effect intent. + +Open never constructs this adapter's filesystem capabilities from retained roots. +The injected resolver supplies current trusted local configuration only. Captured +binaries remain an ignored local cache; portable metadata declares exact bytes. +""" +import copy +from datetime import datetime +import hashlib +import os + +import agent_api +from model_definitions import reference +from project_material import decode_records as material_records +from project_session import operation_records, REQUEST_KEY, _digest as operation_digest +from runtime_snapshots import RuntimeSnapshots, DEFAULT_RESERVE, MAX_FILES, MAX_BYTES, MAX_MANIFEST +from workbench import WorkbenchError, timestamp + +OPERATIONS = frozenset(('runtime.capture_intent', 'runtime.capture', 'runtime.abandon')) +RESULT_FIELDS = frozenset(('format','project_id','manifest_sha256','file_count','total_bytes', + 'actor_id','observed_at','request_key')) + + +def _fail(message, code='invalid', status=400): + raise WorkbenchError(message, code, status) + + +def _sha(value): + if type(value) is not str or len(value) != 64 or any(c not in '0123456789abcdef' for c in value): + _fail('Runtime capture requires an exact SHA256.') + + +def _id(value): + if type(value) is not str or len(value) != 32 or any(c not in '0123456789abcdef' for c in value): + _fail('Runtime capture requires a project or actor UUID.') + + +def _key(value): + if type(value) is not str or not REQUEST_KEY.fullmatch(value): + _fail('Invalid runtime capture request key.') + + +def _intent_key(key): + return 'runtime-intent.' + hashlib.sha256(key.encode('utf-8')).hexdigest() + + +def _body(config_request, expected_inventory_sha256, reserve_bytes): + _sha(expected_inventory_sha256) + if type(reserve_bytes) is not int or not 0 <= reserve_bytes <= 2**63-1: + _fail('Runtime capture reserve must be a nonnegative integer.') + try: + if type(config_request) is not dict or len(agent_api.encoded(config_request)) > 65536: + raise ValueError() + except (agent_api.AgentAPIError, TypeError, ValueError, RecursionError, UnicodeError): + _fail('Runtime capture requires a bounded current-local configuration request.') + return dict(configuration=copy.deepcopy(config_request), expected_inventory_sha256=expected_inventory_sha256, + reserve_bytes=reserve_bytes) + + +def _intent_input(body): + """Use the canonical intent owner's exact request identity for local custody.""" + return operation_digest(dict(operation='runtime.capture_intent', + target=body['expected_inventory_sha256'], body=body)) + + +def _summary(project_id, observed, actor_id, key, *, runtime_ref=None): + value = dict(format='project_runtime_capture_intent_v1' if runtime_ref is None else 'project_runtime_capture_v1', + project_id=project_id,manifest_sha256=observed['inventory_sha256'],file_count=observed['file_count'], + total_bytes=observed['total_bytes'],actor_id=actor_id,observed_at=timestamp(),request_key=key) + if runtime_ref is not None: value['runtime_ref'] = copy.deepcopy(runtime_ref) + return value + + +def validate_operation_result(operation, result, records=None): + """Validate inert capture facts; no retained path becomes a local binding.""" + if operation not in OPERATIONS: return False + try: + if operation == 'runtime.abandon': + fields = {'format','project_id','capture_key','input_sha256','manifest_sha256', + 'actor_id','reason','observed_checkpoint','observed_at'} + if type(result) is not dict or set(result) != fields or result['format'] != 'project_runtime_capture_abandon_v1': + _fail('Invalid runtime abandonment observation.') + _id(result['project_id']); _id(result['actor_id']); _key(result['capture_key']) + for field in ('input_sha256','manifest_sha256','observed_checkpoint'): _sha(result[field]) + if type(result['reason']) is not str or not result['reason'].strip() or len(result['reason']) > 1000: + _fail('Runtime abandonment requires the retained owner reason.') + if type(result['observed_at']) is not str or datetime.fromisoformat(result['observed_at']).utcoffset() is None: + _fail('Runtime abandonment requires an offset timestamp.') + if records is not None: + intents = [r['document'] for r in records if r['family']=='project_operation' + and r['document']['operation']=='runtime.capture_intent' + and r['document']['request_key']==_intent_key(result['capture_key']) + and r['document']['input_sha256']==result['input_sha256'] + and r['document']['result'].get('project_id')==result['project_id'] + and r['document']['result'].get('manifest_sha256')==result['manifest_sha256']] + if len(intents) != 1: _fail('Runtime abandonment has no exact original capture intent.') + if any(r['family']=='project_operation' + and r['document']['operation']=='runtime.capture' + and r['document']['request_key']==result['capture_key'] + and r['document']['result'].get('project_id')==result['project_id'] + for r in records): + _fail('Completed runtime capture cannot be abandoned.') + return True + complete = operation == 'runtime.capture' + fields = RESULT_FIELDS | ({'runtime_ref'} if complete else set()) + if type(result) is not dict or set(result) != fields: + _fail('Invalid runtime capture observation fields.') + expected_format = 'project_runtime_capture_v1' if complete else 'project_runtime_capture_intent_v1' + if result['format'] != expected_format: _fail('Unsupported runtime capture observation.') + _id(result['project_id']); _id(result['actor_id']); _sha(result['manifest_sha256']); _key(result['request_key']) + if (type(result['file_count']) is not int or not 1 <= result['file_count'] <= MAX_FILES or + type(result['total_bytes']) is not int or not 0 <= result['total_bytes'] <= MAX_BYTES): + _fail('Runtime capture observation exceeds the owner inventory bounds.') + instant = result['observed_at'] + if type(instant) is not str or len(instant) > 80 or datetime.fromisoformat(instant).utcoffset() is None: + _fail('Runtime capture observation requires an offset timestamp.') + if not complete: return True + ref = reference(result['runtime_ref'], 'runtime', result['project_id']) + if records is None: return True + doc = material_records(records).get(('runtime_material', result['project_id']+'.'+ref['sha256'])) + descriptor = None if doc is None else doc['value']['descriptor'] + if (doc is None or doc['value']['ref'] != ref or descriptor is None or + descriptor['profile'] != 'captured_cpu_python_v1' or + any(descriptor[field] != result[field] for field in ('manifest_sha256','file_count','total_bytes'))): + _fail('Runtime capture observation differs from its exact retained material.') + declarations = {r['id']:r['document'] for r in records if r['family']=='project_artifact'} + for edge in doc['artifacts']: + if declarations.get(edge['blob']['sha256']) != dict(format='project_artifact_v1', **edge['blob']): + _fail('Runtime capture lacks its complete exact artifact declarations.') + intents = [r['document']['result'] for r in records if r['family']=='project_operation' + and r['document']['operation']=='runtime.capture_intent' + and r['document']['request_key']==_intent_key(result['request_key'])] + matching = [intent for intent in intents if all(intent.get(field)==result[field] + for field in ('project_id','manifest_sha256','file_count','total_bytes','request_key'))] + if len(matching) != 1: _fail('Runtime capture lacks its unique durable publication intent.') + validate_operation_result('runtime.capture_intent', matching[0]) + return True + except WorkbenchError: raise + except (ValueError, TypeError, KeyError, AttributeError, OverflowError, RecursionError): + _fail('Malformed runtime capture observation.') + + +class ProjectRuntime: + def __init__(self, projection, *, resolve_configuration): + if not callable(resolve_configuration): + raise TypeError('An explicit current-local runtime configuration resolver is required.') + self.projection = projection + self.session, self.db, self.access = projection.session, projection.db, projection.access + self.execution, self.materials = projection.execution, projection.materials + self.resolve_configuration = resolve_configuration + self.owner = RuntimeSnapshots(self.db, self.execution.runtime_access, self.db.path) + + def _authorize(self, action): + return self.access.require('admin', action, owner_only=True) + + def inspect(self, config_request): + """Read an explicitly selected current source; never provision or execute.""" + with self.access.lock, self.session.lock, self.db.lock: + self._authorize('runtime_snapshots.inspect'); self.session.check() + _body(config_request, '0'*64, 0) + with self.execution.runtime_inspection(): + return self.owner.inspect(self.resolve_configuration(copy.deepcopy(config_request))) + + def status(self, key): + """Read historical outcome only; captured does not claim local readiness.""" + _key(key) + with self.access.lock, self.session.lock, self.db.lock: + self.access.require('inspect', 'runtime.capture_status'); self.session.check() + receipts = operation_records(self.session.snapshot['records']) + intent = receipts.get(_intent_key(key)); complete = receipts.get(key) + if intent is not None and intent['operation'] != 'runtime.capture_intent': intent = None + if complete is not None and complete['operation'] != 'runtime.capture': complete = None + abandoned = next((r['result'] for r in receipts.values() if r['operation']=='runtime.abandon' + and r['result']['capture_key']==key), None) + if abandoned is not None: + return dict(state='abandoned',intent=copy.deepcopy(intent['result']) if intent else None, + result=None,abandonment=copy.deepcopy(abandoned)) + return dict(state='captured' if complete else 'intent_recorded' if intent else 'absent', + intent=copy.deepcopy(intent['result']) if intent else None, + result=copy.deepcopy(complete['result']) if complete else None) + + def recover_capture(self, config_request, expected_inventory_sha256, expected_checkpoint, key, *, reserve_bytes=DEFAULT_RESERVE): + """Explicitly restore one original local copy before its exact retry.""" + body = _body(config_request, expected_inventory_sha256, reserve_bytes) + body_sha = _intent_input(body) + self._authorize('runtime_snapshots.create') + self.execution.prepare_runtime_reconciliation(key, body_sha, expected_inventory_sha256, expected_checkpoint) + return self.capture(config_request, expected_inventory_sha256, self.session.snapshot['checkpoint'], key, + reserve_bytes=reserve_bytes) + + def abandon(self, capture_key, reason, expected_checkpoint, key): + """Preserve partial bytes and acknowledge one stopped local copy.""" + _key(capture_key); _key(key); _sha(expected_checkpoint) + if key == capture_key or type(reason) is not str or not reason.strip() or len(reason) > 1000: + _fail('Use a separate abandonment key and a retained reason.') + actor = self._authorize('runtime.capture_abandon') + body = dict(capture_key=capture_key, reason=reason, actor_id=actor.actor_id) + marker = self.execution.runtime_pending(capture_key) + if marker is None: + # Pure receipt replay cannot clear a local writer or run anything. + def forbidden(): _fail('No original local runtime copy exists to abandon.', 'unavailable', 409) + return self.session.mutate('runtime.abandon', capture_key, body, expected_checkpoint, key, + forbidden, lambda *args: forbidden(), scope='admin') + with self.execution.runtime_reconciliation(capture_key, marker['input_sha256'], + marker['manifest_sha256'], expected_checkpoint) as guard: + receipts = operation_records(self.session.snapshot['records']) + intent = receipts.get(_intent_key(capture_key)) + if (intent is None or intent['operation']!='runtime.capture_intent' + or intent['input_sha256']!=marker['input_sha256'] + or intent['result']['project_id']!=self.session.project_id + or intent['result']['manifest_sha256']!=marker['manifest_sha256']): + _fail('Runtime intent differs from its exact original local writer.', 'conflict', 409) + if capture_key in receipts: + _fail('Capture completed; reconcile its acknowledgement with the exact capture request.', 'conflict', 409) + observed = self.session.snapshot['checkpoint'] + def invoke(): + current = self._authorize('runtime.capture_abandon') + if current.actor_id != actor.actor_id: _fail('Runtime abandonment caller changed.', 'conflict', 409) + return dict(format='project_runtime_capture_abandon_v1',project_id=self.session.project_id, + capture_key=capture_key,input_sha256=marker['input_sha256'],manifest_sha256=marker['manifest_sha256'], + actor_id=actor.actor_id,reason=reason,observed_checkpoint=observed,observed_at=timestamp()) + result = self.session.mutate('runtime.abandon', capture_key, body, observed, key, invoke, + lambda value,before:dict(records=[],result=value),scope='admin') + guard.confirmed() + return result + + def _barrier(self, descriptor): + # Native creation fsyncs all staged files before its atomic rename. This + # explicit barrier also closes a previous parent-fsync unknown outcome. + from runtime_snapshots import sync_directory + root = self.owner.path / descriptor['manifest_sha256'] + if descriptor['root'] != str(root): _fail('Current runtime binding differs.', 'stale', 409) + for directory in (root, self.owner.path, self.owner.path.parent): sync_directory(directory) + + def _replay(self, key, body, expected_checkpoint): + def forbidden(): _fail('Runtime replay has no canonical receipt.', 'unavailable', 409) + return self.session.mutate('runtime.capture', body['expected_inventory_sha256'], body, + expected_checkpoint, key, forbidden, lambda *args: forbidden(), scope='admin') + + def capture(self, config_request, expected_inventory_sha256, expected_checkpoint, key, *, reserve_bytes=DEFAULT_RESERVE): + """Explicitly capture bytes after durable intent and current quiescence. + + The resolver is trusted current-host configuration authority. It must not + derive source directories from canonical descriptors or historical roots. + """ + _key(key); _sha(expected_checkpoint) + body = _body(config_request, expected_inventory_sha256, reserve_bytes) + body_sha = _intent_input(body) + intent = None + with self.access.lock, self.session.lock, self.db.lock: + actor = self._authorize('runtime_snapshots.create'); self.session.check() + receipts = operation_records(self.session.snapshot['records']) + if any(r['operation']=='runtime.abandon' and r['result']['capture_key']==key for r in receipts.values()): + _fail('This capture was abandoned; use a fresh key for a new measurement.', 'conflict', 409) + if (_intent_key(key) in receipts and key not in receipts + and self.execution.runtime_pending(key) is None): + _fail('This incomplete capture intent is inert history; use a fresh key for a new capture.', 'unavailable', 409) + try: + with self.execution.runtime_effect(key, body_sha, expected_inventory_sha256) as guard: + receipts = operation_records(self.session.snapshot['records']) + if key in receipts: + result = self._replay(key, body, expected_checkpoint) + descriptor = self.owner.get(expected_inventory_sha256) + from pipeline import digest + if digest(descriptor) != result['runtime_ref']['sha256']: + _fail('Acknowledged runtime belongs to a different local binding; capture with a new key.', 'unavailable', 409) + guard.publication_started() + self._barrier(descriptor); guard.confirmed() + return result + config = None + if _intent_key(key) in receipts: + def invalid(): _fail('Runtime intent replay is unavailable.', 'unavailable', 409) + intent = self.session.mutate('runtime.capture_intent', expected_inventory_sha256, body, + expected_checkpoint, _intent_key(key), invalid, lambda *args: invalid(), scope='admin') + else: + if expected_checkpoint != self.session.snapshot['checkpoint']: + _fail('Expected project checkpoint changed.', 'stale_project', 409) + config = self.resolve_configuration(copy.deepcopy(config_request)) + observed = self.owner.inspect(config) + if observed['inventory_sha256'] != expected_inventory_sha256: + _fail('Runtime inventory changed after inspection.', 'stale', 409) + intent = self.session.mutate('runtime.capture_intent', expected_inventory_sha256, body, + expected_checkpoint, _intent_key(key), + lambda:_summary(self.session.snapshot['project_id'], observed, actor.actor_id, key), + lambda result,before:dict(records=[],result=result), scope='admin') + guard.publication_started() + if os.path.lexists(self.owner.path / expected_inventory_sha256): + descriptor = self.owner.get(expected_inventory_sha256) + else: + if config is None: config = self.resolve_configuration(copy.deepcopy(config_request)) + descriptor = self.owner.create(config, expected_inventory_sha256, quiesced=True, reserve_bytes=reserve_bytes) + self._barrier(descriptor) + from recipe_process import read_regular, decode + from pipeline import digest + from pathlib import Path + raw = read_regular(Path(descriptor['root'])/'manifest.json', MAX_MANIFEST) + runtime_ref = dict(kind='runtime',catalog_id=self.session.snapshot['project_id'], + id=digest(descriptor),sha256=digest(descriptor)) + value = dict(ref=runtime_ref,descriptor=descriptor,environment=None,manifest=decode(raw)) + observed = dict(inventory_sha256=descriptor['manifest_sha256'],file_count=descriptor['file_count'],total_bytes=descriptor['total_bytes']) + def capture(result, before): + record = self.materials.capture_runtime(value, raw) + return dict(records=[*self.materials.declarations(record),record],result=result) + result = self.session.mutate('runtime.capture', expected_inventory_sha256, body, + self.session.snapshot['checkpoint'], key, + lambda:_summary(self.session.snapshot['project_id'], observed, actor.actor_id, key, runtime_ref=runtime_ref), capture, scope='admin') + guard.confirmed() + return result + except BaseException as error: + if isinstance(error, (OSError, RuntimeError)): + failure = WorkbenchError('Runtime capture outcome requires explicit same-key reconciliation.', + 'outcome_unknown' if intent else 'unavailable', 503) + else: failure = error + if intent is not None and not hasattr(failure, 'receipt'): + snapshot = self.session.snapshot + failure.receipt = dict(committed=True,selected=True,project_id=snapshot['project_id'], + checkpoint=snapshot['checkpoint'],request_key=key) + if failure is error: raise + raise failure from error diff --git a/project_session.py b/project_session.py new file mode 100644 index 00000000..46c106cd --- /dev/null +++ b/project_session.py @@ -0,0 +1,295 @@ +"""Canonical publication shared by the fixed external-project domain owners. + +The supplied validator composes those owners' closed codecs and reference +closure. This boundary owns transaction ordering and receipts, not domain policy. +""" +import copy +import hashlib +import re +import threading + +import agent_api +from workbench import WorkbenchError + + +OPERATION_FAMILY = 'project_operation' +OPERATION_FORMAT = 'project_operation_v2' +REQUEST_KEY = re.compile(r'^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$') +SHA = re.compile(r'^[a-f0-9]{64}$') +OPERATION = re.compile(r'^[a-z][a-z0-9_.]{0,119}$') + + +class ProjectSessionError(WorkbenchError): + pass + + +def _digest(value): + return hashlib.sha256(agent_api.encoded(value)).hexdigest() + + +def operation_documents(records): + """Validate all historical receipts without collapsing independent keys.""" + result, seen = [], set() + for envelope in records: + if envelope['family'] != OPERATION_FAMILY: + continue + value = envelope['document'] + base = {'format', 'operation', 'target', 'request_key', 'input_sha256', 'result'} + namespaced = type(value) is dict and value.get('format') == 'project_operation_v2' + if (type(value) is not dict or set(value) != base | ({'project_id'} if namespaced else set()) + or value['format'] not in ('project_operation_v1', 'project_operation_v2') + or type(value['operation']) is not str or not OPERATION.fullmatch(value['operation']) + or value['target'] is not None and (type(value['target']) is not str or len(value['target']) > 512) + or type(value['request_key']) is not str or not REQUEST_KEY.fullmatch(value['request_key']) + or type(value['input_sha256']) is not str or not SHA.fullmatch(value['input_sha256'])): + raise ProjectSessionError('Invalid canonical project operation receipt.') + identity = hashlib.sha256(value['request_key'].encode('utf-8')).hexdigest() + if namespaced: + if type(value['project_id']) is not str or not re.fullmatch('[a-f0-9]{32}', value['project_id']): + raise ProjectSessionError('Invalid project operation namespace.') + identity = value['project_id'] + '.' + identity + if envelope['id'] != identity or identity in seen: + raise ProjectSessionError('Invalid or duplicate canonical project operation identity.') + seen.add(identity) + if len(agent_api.encoded(value['result'])) > agent_api.RESPONSE_LIMIT: + raise ProjectSessionError('Canonical operation result exceeds its transport bound.', 'too_large', 413) + result.append(copy.deepcopy(value)) + return result + + +def operation_records(records, *, project_id=None): + """Current request keys are independent of exactly retained fork history.""" + operation_documents(records) + from project_fork import active_records, decode_fork + overlay = decode_fork(records, project_id=project_id) + if project_id is None and overlay is not None: + project_id = overlay['destination_project_id'] + result = {} + for value in operation_documents(active_records(records, project_id=project_id)): + if overlay is not None and value['format'] == 'project_operation_v1': + raise ProjectSessionError('A fork must retain legacy operation receipts as archived historical facts.') + if project_id is not None and value.get('project_id', project_id) != project_id: + raise ProjectSessionError('Active project operation belongs to another namespace.') + if value['request_key'] in result: + raise ProjectSessionError('Duplicate active project operation request key.') + result[value['request_key']] = value + return result + + +class ProjectSession: + def __init__(self, store, db, access, artifacts, *, validate_records): + if not callable(validate_records): + raise TypeError('A complete owner-codec validator is required.') + self.store, self.db, self.access, self.artifacts = store, db, access, artifacts + self.lock = threading.RLock() + self._validate = validate_records + self.available = False + self._snapshot = None + self.project_id = store.snapshot()['project_id'] + self.extra_capture = None + self.publication_intent = None + self.runtime_access = None + self._foreground_publication = False + self._pending_observation = None + self._recovery_records = None + self._recovery_request = None + self.adopt(store.snapshot()) + + @property + def foreground_publication(self): + return self._foreground_publication + + @staticmethod + def _merge(records, upserts): + merged = {(item['family'], item['id']): copy.deepcopy(item) for item in records} + seen = {} + for item in upserts: + if type(item) is not dict or set(item) != {'family', 'id', 'document'}: + raise ProjectSessionError('Invalid owned project envelope.') + identity = (item['family'], item['id']) + if item['family'] == OPERATION_FAMILY: + raise ProjectSessionError('Owned capture cannot replace a project operation receipt.') + if identity in seen and seen[identity] != item: + raise ProjectSessionError('Owned captures disagree about the same project record.') + seen[identity] = item + merged[identity] = copy.deepcopy(item) + return merged + + def _publish_observation(self, records, key, digest, *, authorize): + """Publish fixed background science while its owned SQL TX is open. + + This is a private trusted-owner seam, not a transport operation or an + imported receipt capability. The callback verifies current local job + custody/admission. SQL outcome selects or fences the resulting cache. + """ + if (self.foreground_publication or not self.db.in_transaction + or not self.db.lock.depth or self._pending_observation is not None): + raise ProjectSessionError('Background publication requires its own outer project transaction.') + if (type(records) is not list or type(key) is not str or not REQUEST_KEY.fullmatch(key) + or type(digest) is not str or not SHA.fullmatch(digest) or not callable(authorize)): + raise ProjectSessionError('Invalid background publication identity.') + authorize() + current = self.check() + merged = self._merge(current['records'], records) + candidate = [merged[identity] for identity in sorted(merged)] + self._validated(candidate) + if self.publication_intent is not None: + self.publication_intent(copy.deepcopy(current), copy.deepcopy(candidate), key, digest) + try: + self._pending_observation = self.store.commit(candidate, current['checkpoint'], key, digest) + except BaseException as error: + if getattr(error, 'code', None) in ('unknown', 'outcome_unknown'): + self.available = False + self._recovery_records = copy.deepcopy(candidate) + self._recovery_request = (key, digest) + raise + return copy.deepcopy(self._pending_observation) + + def _observation_outcome(self, outcome): + if outcome not in ('committed', 'rolled_back', 'unknown'): + raise ValueError('Invalid project SQL outcome.') + pending, self._pending_observation = self._pending_observation, None + if pending is not None: + self._snapshot = pending + if outcome != 'committed': + self.available = False + if outcome == 'unknown': + self.available = False + + def reconcile_selection(self, snapshot): + """Bind only this owner's selected or uncertain publication, never a checkout. + + The execution controller first proves local cessation. Callers retain + all ordered guards until fixed inert reconstruction succeeds or fences. + """ + with self.access.lock, self.lock, self.db.lock: + known = snapshot == self._snapshot + attempted = (self._recovery_records is not None + and snapshot['project_id'] == self._snapshot['project_id'] + and snapshot['name'] == self._snapshot['name'] + and snapshot['records'] == self._recovery_records + and self._recovery_request is not None + and self.store.reconcile(*self._recovery_request) is not None) + if not known and not attempted: + raise ProjectSessionError('Canonical selection differs from this local execution outcome.', 'stale_project', 409) + self.adopt(snapshot) + self._recovery_records = None + self._recovery_request = None + + @property + def snapshot(self): + with self.lock: + return copy.deepcopy(self._snapshot) + + def _validated(self, records): + operation_records(records, project_id=self.project_id) + self._validate(records) + + def adopt(self, snapshot): + """Bind a fully validated checkpoint after inert reconstruction/cutover. + + The caller owns SQL reconstruction. This method never repairs or writes + canonical bytes, and is not authorization to trust an imported receipt. + """ + with self.access.lock, self.lock, self.db.lock: + self._validated(snapshot['records']) + current = self.store.snapshot() + if current != snapshot: + raise ProjectSessionError('Project changed while binding its projection.', 'stale_project', 409) + self._snapshot = copy.deepcopy(snapshot) + self.available = True + + def check(self): + """Check freshness while a caller holds host/session/projection guards.""" + if not self.available: + raise ProjectSessionError('Reopen this project to reconstruct its projection.', 'projection_unavailable', 503) + current = self.store.snapshot() + if current['checkpoint'] != self._snapshot['checkpoint']: + raise ProjectSessionError('Canonical project changed. Close and reopen it.', 'stale_project', 409) + return current + + def mutate(self, operation, target, body, expected_checkpoint, key, invoke, capture, *, scope='edit'): + """Invoke existing policy, capture fixed-owner upserts, publish, commit SQL. + + invoke() returns the owner's result. capture(result, before_records) + returns {'records': upserts, 'result': original bounded domain result}. + Wrappers check any stronger scope/owner/disclosure requirement before + entering, including before replay. The supplied current scope belongs to + the wrapper's existing policy (for example freeze rather than edit). + """ + if (type(operation) is not str or not OPERATION.fullmatch(operation) + or target is not None and (type(target) is not str or len(target) > 512) + or type(key) is not str or not REQUEST_KEY.fullmatch(key) + or type(expected_checkpoint) is not str or not SHA.fullmatch(expected_checkpoint)): + raise ProjectSessionError('Invalid project mutation identity.') + digest = _digest(dict(operation=operation, target=target, body=body)) + committed = None + attempted_records = None + with self.access.lock, self.lock, self.db.lock: + self.access.require(scope, operation, target) + current = self.check() + if self.db.in_transaction: + raise ProjectSessionError('Project mutation must own its outer SQL transaction.', 'unavailable', 409) + receipts = operation_records(current['records']) + prior = receipts.get(key) + if prior is not None: + if prior['operation'] != operation or prior['input_sha256'] != digest or prior['target'] != target: + raise ProjectSessionError('Request key already has different input.', 'conflict', 409) + # The current composed codec validates the exact historical + # result too; receipt facts can never authorize execution. + self._validated(current['records']) + return copy.deepcopy(prior['result']) + if self.store.reconcile(key, digest) is not None: + raise ProjectSessionError('Request key belongs to another canonical operation.', 'conflict', 409) + if expected_checkpoint != current['checkpoint']: + raise ProjectSessionError('Expected project checkpoint changed.', 'stale_project', 409) + try: + self._foreground_publication = True + with self.db.transaction(): + result = invoke() + captured = capture(result, copy.deepcopy(current['records'])) + if type(captured) is not dict or set(captured) != {'records', 'result'} or type(captured['records']) is not list: + raise ProjectSessionError('Invalid owned project capture.') + if len(agent_api.encoded(captured['result'])) > agent_api.RESPONSE_LIMIT: + raise ProjectSessionError('Project result exceeds its transport bound.', 'too_large', 413) + upserts = captured['records'] + if self.extra_capture is not None: + extra = self.extra_capture(copy.deepcopy(current['records'])) + if type(extra) is not list: + raise ProjectSessionError('Invalid fixed runtime capture.') + upserts = upserts + extra + merged = self._merge(current['records'], upserts) + document = dict(format=OPERATION_FORMAT, project_id=self.project_id, operation=operation, target=target, + request_key=key, input_sha256=digest, result=copy.deepcopy(captured['result'])) + identity = (OPERATION_FAMILY, self.project_id + '.' + hashlib.sha256(key.encode('utf-8')).hexdigest()) + merged[identity] = dict(family=identity[0], id=identity[1], document=document) + records = [merged[identity] for identity in sorted(merged)] + self._validated(records) + # Unique payloads have already reached artifact custody; + # SQL callbacks can delete only disposable projection files. + attempted_records = records + if self.publication_intent is not None: + self.publication_intent(copy.deepcopy(current), copy.deepcopy(records), key, digest) + committed = self.store.commit(records, current['checkpoint'], key, digest) + # Outcome hooks see canonical selection before SQL outcome + # and before any deferred dispatch/wake can run. + self._pending_observation = committed + self._snapshot = committed + return copy.deepcopy(captured['result']) + except BaseException as error: + if committed is not None: + self._snapshot, self.available = committed, False + failure = ProjectSessionError('Canonical operation committed; reopen to reconstruct the projection.', + 'projection_unavailable', 503) + failure.receipt = dict(committed=True, selected=True, project_id=committed['project_id'], + checkpoint=committed['checkpoint'], request_key=key) + raise failure from error + if getattr(error, 'code', None) in ('unknown', 'outcome_unknown'): + self.available = False + if attempted_records is not None: + self._recovery_records = copy.deepcopy(attempted_records) + self._recovery_request = (key, digest) + raise + finally: + self._foreground_publication = False + self._pending_observation = None diff --git a/project_sources.py b/project_sources.py new file mode 100644 index 00000000..8aef085f --- /dev/null +++ b/project_sources.py @@ -0,0 +1,198 @@ +"""Explicit current source bindings, separate from portable snapshot facts. + +Opening a project never restores workspace_projects. Only an authenticated owner +registering an anchored current manifest can create that disposable binding. +Canonical receipts describe retained facts; they are not filesystem capabilities. +""" +import copy +from datetime import datetime +import hashlib +import os + +from project_material import decode_records as material_records +from project_session import ProjectSessionError, REQUEST_KEY +from projects import (DATA_LIMIT, ID, INSPECTION_LIMIT, MANIFEST_LIMIT, NAME, SHA, decode, + decode_retained_snapshot, manifest, relative_path, text) +from workbench import WorkbenchError, encode, timestamp + +RESULT_FIELDS = frozenset(('origin_catalog_id','project_id','manifest_sha256','name', + 'schema_version','actor_id','registered_at')) + + +def _closed(value, fields): + if type(value) is not dict or set(value)!=set(fields): + raise WorkbenchError('Invalid explicit source operation fields.') + + +def _identity(value, pattern, label): + if type(value) is not str or not pattern.fullmatch(value): + raise WorkbenchError('Invalid source '+label+'.') + return value + + +def _summary(saved, origin): + return dict(origin_catalog_id=origin,project_id=saved['id'], + manifest_sha256=saved['manifest_sha256'],name=saved['name'], + schema_version=saved['manifest']['schema_version']) + + +def validate_operation_result(operation,result,records=None): + """Validate the fixed source receipt against its exact retained snapshot.""" + if operation!='source.register':return False + try: + _closed(result,RESULT_FIELDS) + for key in ('origin_catalog_id','project_id','actor_id'):_identity(result[key],ID,key) + _identity(result['manifest_sha256'],SHA,'manifest SHA256') + text(result['name'],'Source project name',120) + if type(result['schema_version']) is not int or result['schema_version'] not in (1,2): + raise WorkbenchError('Unsupported source registration schema.','unsupported',400) + if type(result['registered_at']) is not str or len(result['registered_at'])>80: + raise WorkbenchError('Source registration requires an attributed timestamp.') + if datetime.fromisoformat(result['registered_at']).utcoffset() is None: + raise WorkbenchError('Source registration timestamp requires an offset.') + if records is not None: + document=material_records(records).get(('project_snapshot',result['origin_catalog_id']+'.'+result['manifest_sha256'])) + expected={key:result[key] for key in ('origin_catalog_id','project_id','manifest_sha256','name','schema_version')} + if document is None or _summary(document['value'],document['origin_catalog_id'])!=expected: + raise WorkbenchError('Source receipt differs from its exact retained snapshot.') + return True + except WorkbenchError:raise + except (ValueError,TypeError,KeyError,AttributeError,OverflowError,RecursionError): + raise WorkbenchError('Malformed retained source operation result.') from None + + +class SourceAdapter: + def __init__(self,projection): + self.projection=projection + self.session=projection.session + self.db,self.access=self.session.db,self.session.access + self.sources,self.materials,self.data=projection.sources,projection.materials,projection.data + + @staticmethod + def _mutation_identity(expected_checkpoint,key): + _identity(expected_checkpoint,SHA,'expected checkpoint') + _identity(key,REQUEST_KEY,'request key') + + @staticmethod + def _snapshot_reference(snapshot): + _closed(snapshot,('origin_catalog_id','project_id','manifest_sha256')) + for field in ('origin_catalog_id','project_id'):_identity(snapshot[field],ID,field) + _identity(snapshot['manifest_sha256'],SHA,'manifest SHA256') + return snapshot + + def _read_registration(self,body): + _closed(body,('root','manifest_path')) + root=_identity(body['root'],NAME,'root alias');path=relative_path(body['manifest_path']) + entry=self.sources.roots.get(root) + if entry is None:raise WorkbenchError('Choose a currently configured source root.','unavailable',409) + self.access.require('inspect','projects.open') + try: + actual=os.fstat(entry[0]);identity=[actual.st_dev,actual.st_ino] + if identity!=entry[1]:raise WorkbenchError('Configured source root identity changed.','stale',409) + # Reuse Projects' anchored before/after identity and no-follow read. + raw=self.sources._read_snapshot_file(dict(root=root,root_identity=identity),path,MANIFEST_LIMIT) + except OSError: + raise WorkbenchError('Current source manifest is unavailable.','unavailable',409) from None + value=manifest(decode(raw)) + saved=dict(id=value['id'],name=value['name'],root=root,root_identity=identity, + manifest_path=path,manifest_sha256=hashlib.sha256(raw).hexdigest(),manifest=value) + return decode_retained_snapshot(saved),raw + + def _bind(self,saved,raw): + if not self.db.in_transaction:raise RuntimeError('Source binding requires its outer SQL transaction.') + if self.sources._read_snapshot_file(saved,saved['manifest_path'],MANIFEST_LIMIT)!=raw: + raise WorkbenchError('Source manifest changed before current registration.','stale',409) + row=self.db.execute('SELECT document FROM workspace_projects WHERE id=%s',(saved['id'],)).fetchone() + if row is not None: + prior=decode_retained_snapshot(decode(row[0].encode())) + if prior['manifest_sha256']!=saved['manifest_sha256']: + raise WorkbenchError('This source ID already names a different current snapshot.','conflict',409) + if prior==saved:return + # Explicit same-manifest registration can renew a moved root. The + # original canonical registration facts are never rewritten. + self.db.execute('UPDATE workspace_projects SET document=%s WHERE id=%s',(encode(saved),saved['id'])) + else:self.db.execute('INSERT INTO workspace_projects VALUES(%s,%s)',(saved['id'],encode(saved))) + + def register(self,body,expected_checkpoint,key): + self._mutation_identity(expected_checkpoint,key) + with self.access.lock,self.session.lock,self.db.lock: + actor=self.access.require('edit','source.register',owner_only=True) + self.session.check() + saved,raw=self._read_registration(body) + origin=self.session.snapshot['project_id'];invoked=False + def invoke(): + nonlocal invoked + invoked=True;self._bind(saved,raw) + return dict(_summary(saved,origin),actor_id=actor.actor_id,registered_at=timestamp()) + def capture(result,before): + record=self.materials.capture_snapshot(saved,raw,catalog_id=origin) + return dict(records=[*self.materials.declarations(record),record],result=result) + result=self.session.mutate('source.register',saved['id'],copy.deepcopy(body),expected_checkpoint,key,invoke,capture) + if any(result[field]!=value for field,value in _summary(saved,origin).items()): + raise WorkbenchError('Current source manifest differs from the acknowledged registration.','stale',409) + if not invoked: + # Replay itself does not run invoke. This explicit owner action + # may renew a verified current binding after a cold Open; it is + # not a new historical registration event or canonical write. + try: + with self.db.transaction():self._bind(saved,raw) + except WorkbenchError:raise + except BaseException as error: + self.session.available=False + failure=ProjectSessionError('Source registration was acknowledged; reopen before binding its current root.', + 'projection_unavailable',503) + snapshot=self.session.snapshot + failure.receipt=dict(committed=True,selected=True,project_id=snapshot['project_id'], + checkpoint=snapshot['checkpoint'],request_key=key) + raise failure from error + return result + + def references(self,snapshotref,offset=0,limit=40): + snapshot=self._snapshot_reference(snapshotref) + if type(offset) is not int or not 0<=offset<=2**53-1 or type(limit) is not int or not 1<=limit<=40: + raise WorkbenchError('Invalid bounded source reference page.') + with self.access.lock,self.session.lock,self.db.lock: + self.access.require('inspect','source.references',snapshot['project_id']) + self.session.check() + historical=self.materials.snapshot_ref(snapshot['project_id'],snapshot['manifest_sha256'], + catalog_id=snapshot['origin_catalog_id']) + try:current=self.sources._saved(snapshot['project_id'],extensions=True) + except WorkbenchError as error: + if error.code=='not_found': + raise WorkbenchError('Explicitly register a current source root before inspecting its references.','unavailable',409) from None + raise + # Check current binding and exact retained selection before reading + # even the live manifest. Historical root/path fields grant nothing. + if not self.sources._bound(current): + raise WorkbenchError('The selected source has no current root binding.','unavailable',409) + if current['manifest_sha256']!=snapshot['manifest_sha256'] or current['manifest']!=historical['manifest']: + raise WorkbenchError('Current source registration differs from the selected snapshot.','stale',409) + self.sources.read_snapshot_manifest(snapshot['project_id'],snapshot['manifest_sha256']) + selected=current['manifest']['data'][offset:offset+limit];budget=[INSPECTION_LIMIT] + items=[self.sources._inspect(current['root'],copy.deepcopy(ref),budget,DATA_LIMIT) for ref in selected] + self.sources.read_snapshot_manifest(snapshot['project_id'],snapshot['manifest_sha256']) + self.session.check();self.access.require('inspect','source.references',snapshot['project_id']) + return dict(items=items,total=len(current['manifest']['data']),offset=offset,limit=limit) + + def import_scientific(self,body,expected_checkpoint,key): + self._mutation_identity(expected_checkpoint,key) + _closed(body,('snapshot','reference_id','expected_sha256','document')) + snapshot=self._snapshot_reference(body['snapshot']) + _identity(body['reference_id'],NAME,'data reference') + _identity(body['expected_sha256'],SHA,'data SHA256') + if type(body['document']) is not dict:raise WorkbenchError('Scientific import metadata must be an object.') + with self.access.lock,self.session.lock,self.db.lock: + self.access.require('edit','source.import_scientific',snapshot['project_id']) + self.session.check() + historical=self.materials.snapshot_ref(snapshot['project_id'],snapshot['manifest_sha256'], + catalog_id=snapshot['origin_catalog_id']) + current,_raw=self.sources.read_snapshot_manifest(snapshot['project_id'],snapshot['manifest_sha256']) + if current['manifest']!=historical['manifest']: + raise WorkbenchError('Current source differs from the selected retained snapshot.','stale',409) + raw,source=self.sources.read_snapshot_data_reference(snapshot['project_id'],body['reference_id'], + body['expected_sha256'],DATA_LIMIT) + if source['manifest_sha256']!=snapshot['manifest_sha256']: + raise WorkbenchError('Current source snapshot changed during capture.','stale',409) + # ProjectData owns the one canonical mutation and native parser; + # current rooted reads above never become an imported capability. + return self.data.import_scientific(raw,copy.deepcopy(body['document']),source,expected_checkpoint,key) diff --git a/project_store.py b/project_store.py new file mode 100644 index 00000000..c72bebc5 --- /dev/null +++ b/project_store.py @@ -0,0 +1,872 @@ +"""Canonical POSIX-local project checkpoints; domain codecs own document meaning. + +Only the inventory in .tuldok/project.json confers authority. Immutable revisions +are durable before its atomic replacement. The directory flock excludes cooperating +writers by physical root identity, including alternate spellings of the same root. +Uncooperative checkout/edit operations are forbidden while open and detected before +publication. An uncertain publication is reconciled by reopening and its receipt. +""" +import copy +import ctypes +from contextlib import contextmanager +import errno +import fcntl +import hashlib +import json +import math +import os +from pathlib import Path +import re +import stat +import threading +import uuid + +FORMAT = 'tuldok_project_v1' +GITIGNORE = b'/artifacts/\n/.tuldok-local/\n/.venv/\n/__pycache__/\n/PGDATA/\n/output/\n' +MAX_RECORD_BYTES = 40 * 1024 * 1024 +MAX_MANIFEST_BYTES = 16 * 1024 * 1024 +# Coordinated internal codecs; adding a family requires its owning decoder. +SUPPORTED_FORMATS = { + 'workbench_text': 'workbench_text_v1', + 'workbench_record': 'workbench_record_v1', + 'workbench_response': 'workbench_response_v1', + 'workbench_preference': 'workbench_preference_v1', + 'saved_selection': 'saved_selection_v1', + 'saved_search': 'saved_search_v1', + 'dataset_release': 'dataset_release_v1', + 'dataset_exposure': 'dataset_exposure_v1', + 'model_definition': 'model_definition_history_v1', + 'pipeline_graph': 'pipeline_graph_history_v1', + 'recipe_definition': 'recipe_definition_history_v1', + 'model_material': 'retained_model_material_v1', + 'recipe_material': 'retained_recipe_material_v1', + 'project_snapshot': 'retained_project_snapshot_v1', + 'runtime_material': 'retained_runtime_material_v1', + 'study_history': 'project_study_history_v1', + 'trial_history': 'project_trial_history_v1', + 'run_history': 'project_run_history_v1', + 'run_observation': 'project_run_observation_v1', + 'native_execution_history': 'native_execution_history_v1', + 'candidate_history': 'project_candidate_history_v1', + 'project_artifact': 'project_artifact_v1', + 'project_operation': 'project_operation_v1', + 'project_fork': 'project_fork_v1', + 'project_file': 'project_file_v1', + 'project_extraction': 'project_extraction_v1', + 'generation_history': 'project_generation_history_v1', + 'grounded_history': 'project_grounded_history_v1', + 'caption_proposal_history': 'project_caption_proposal_history_v1', + 'text_proposal_history': 'project_text_proposal_history_v1', +} +MAX_RECORDS = 100_000 +MAX_TOTAL_BYTES = 256 * 1024 * 1024 +IDENTIFIER = re.compile(r'^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$') +REQUEST_KEY = re.compile(r'^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$') +DOMAIN_FORMAT = re.compile(r'^[a-z][a-z0-9_]{0,95}_v([1-9][0-9]*)$') +SHA256 = re.compile(r'^[a-f0-9]{64}$') +DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC +FILE_FLAGS = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC + + +class ProjectStoreError(ValueError): + def __init__(self, message, code='invalid', status=400): + super().__init__(message) + self.code, self.status = code, status + + +def _fail(message, code='invalid', status=400): + raise ProjectStoreError(message, code, status) + + +def _sha(raw): + return hashlib.sha256(raw).hexdigest() + + +def _json_value(value, depth=0): + if depth > 64: + _fail('Project JSON exceeds its nesting limit.') + if value is None or type(value) is bool: + return + if type(value) is int: + if not -(2 ** 63) <= value < 2 ** 63: + _fail('Project JSON integer exceeds signed 64-bit range.') + return + if type(value) is float and math.isfinite(value): + return + if type(value) is str: + if any(0xD800 <= ord(c) <= 0xDFFF for c in value): + _fail('Project JSON contains an invalid Unicode surrogate.') + return + if type(value) is list: + for item in value: + _json_value(item, depth + 1) + return + if type(value) is dict and all(type(key) is str for key in value): + for key, item in value.items(): + _json_value(key, depth + 1) + _json_value(item, depth + 1) + return + _fail('Project value must contain finite JSON values.') + + +def _encode(value): + _json_value(value) + return (json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2, + allow_nan=False) + '\n').encode('utf-8') + + +def _pairs(pairs): + result = {} + for key, value in pairs: + if key in result: + _fail('Project JSON contains duplicate object keys.') + result[key] = value + return result + + +def _decode(raw): + try: + value = json.loads(raw.decode('utf-8'), object_pairs_hook=_pairs, + parse_constant=lambda _: _fail('Project JSON contains a nonfinite number.')) + if _encode(value) != raw: + _fail('Project JSON must use canonical UTF-8, sorted keys, two-space indentation and LF.') + return value + except ProjectStoreError: + raise + except (ValueError, RecursionError, OverflowError) as exc: + raise ProjectStoreError('Malformed project JSON.') from exc + + +def _keys(value, keys, label): + if type(value) is not dict or set(value) != set(keys): + _fail(f'{label} has invalid or unknown fields.') + + +def _identifier(value, label): + if type(value) is not str or not IDENTIFIER.fullmatch(value) or value in ('.', '..'): + _fail(f'{label} must be a bounded path-free identifier.') + + +def _digest(value, label): + if type(value) is not str or not SHA256.fullmatch(value): + _fail(f'{label} must be a SHA256 digest.') + + +def _domain_format(value, family): + match = DOMAIN_FORMAT.fullmatch(value) if type(value) is str else None + if not match: + _fail('Domain document requires a versioned format.') + if value != SUPPORTED_FORMATS[family] and not ( + family == 'project_operation' and value == 'project_operation_v2' + or family == 'project_file' and value == 'project_file_v2'): + _fail('Unsupported domain document format.', 'unsupported', 422) + + +def _records(records): + if type(records) is not list or len(records) > MAX_RECORDS: + _fail('Project records must be a bounded list.') + result, seen, total = [], set(), 0 + for record in records: + _keys(record, ('family', 'id', 'document'), 'Project record') + family, identity, document = (record[k] for k in ('family', 'id', 'document')) + _identifier(family, 'Record family') + if family not in SUPPORTED_FORMATS: + _fail('Unsupported project record family.', 'unsupported', 422) + _identifier(identity, 'Record ID') + if (family, identity) in seen: + _fail('Duplicate project record identity.') + seen.add((family, identity)) + if type(document) is not dict: + _fail('Domain document must be an object.') + _domain_format(document.get('format'), family) + document = copy.deepcopy(document) + raw = _encode(document) + total += len(raw) + if len(raw) > MAX_RECORD_BYTES or total > MAX_TOTAL_BYTES: + _fail('Project records exceed the synchronous byte limit.') + result.append((family, identity, document, raw)) + return sorted(result, key=lambda item: (item[0], item[1])) + + +def _request(key, digest): + if type(key) is not str or not REQUEST_KEY.fullmatch(key): + _fail('Request key must contain 1–128 supported characters.') + _digest(digest, 'Request body digest') + + +def _path_string(path): + try: + value = os.fspath(path) + except TypeError as exc: + raise ProjectStoreError('Project path must be text.') from exc + if type(value) is not str: + _fail('Project path must be text.') + return value + + +def _open_directory_path(path): + """Walk every component without following symlinks, retaining the final fd.""" + try: + raw = _path_string(path) + if type(raw) is not str or '\x00' in raw or '..' in raw.split('/'): + _fail('Unsafe project root path.') + absolute = os.path.abspath(raw) + fd = os.open('/', DIR_FLAGS) + try: + for component in absolute.split('/'): + if not component: + continue + next_fd = os.open(component, DIR_FLAGS, dir_fd=fd) + os.close(fd) + fd = next_fd + return absolute, fd + except BaseException: + os.close(fd) + raise + except OSError as exc: + _io_error(exc, 'resolve project root') + + +def _relative_parts(relative): + if type(relative) is not str or not relative or len(relative) > 4096 or '\x00' in relative: + _fail('Invalid relative project directory.') + parts = relative.split('/') + if any(part in ('', '.', '..') for part in parts): + _fail('Project directory must remain beneath its configured root.') + return parts + + +def _walk(root_fd, parts): + fd = os.dup(root_fd) + try: + for component in parts: + next_fd = _directory(fd, component) + os.close(fd) + fd = next_fd + return fd + except BaseException: + os.close(fd) + raise + + +def _verify_origin(root_fd, root_path): + absolute, check = _open_directory_path(root_path) + try: + if _physical(check) != _physical(root_fd): + _fail('Configured project root changed.', 'stale', 409) + finally: + os.close(check) + return absolute + + +@contextmanager +def anchored_directory(trusted_root_fd, relative='', *, root_path): + """Borrow a rooted no-follow directory capability; caller retains root fd.""" + parts = _relative_parts(relative) if relative else [] + descriptor = None + try: + _verify_origin(trusted_root_fd, root_path) + descriptor = _walk(trusted_root_fd, parts) + yield descriptor + _verify_origin(trusted_root_fd, root_path) + current = _walk(trusted_root_fd, parts) + try: + if _physical(current) != _physical(descriptor): + _fail('Anchored directory changed during use.', 'stale', 409) + finally: + os.close(current) + except OSError as exc: + _io_error(exc, 'access anchored directory') + finally: + if descriptor is not None: + os.close(descriptor) + + +def _directory_inside(candidate_fd, ancestor_fd): + """Compare physical directory ancestry without following path aliases.""" + current = os.dup(candidate_fd) + try: + ancestor = _physical(ancestor_fd) + while True: + if _physical(current) == ancestor: + return True + parent = os.open('..', DIR_FLAGS, dir_fd=current) + if _physical(parent) == _physical(current): + os.close(parent) + return False + os.close(current) + current = parent + finally: + os.close(current) + + +def assert_separate_project_control(trusted_root_fd, relative, *, root_path, control_path): + """Reject portable project/control overlap before creating any destination. + + The caller retains its configured root handle. Existing targets must be + physically disjoint from control in both directions; for an absent leaf, + its existing anchored parent must be outside control. A broad root may + still contain disjoint project and control siblings. + """ + parts = _relative_parts(relative) + control_fd = target_fd = None + try: + _, control_fd = _open_directory_path(control_path) + with anchored_directory(trusted_root_fd, '/'.join(parts[:-1]), root_path=root_path) as parent_fd: + try: + target_fd = _directory(parent_fd, parts[-1]) + except FileNotFoundError: + overlap = _directory_inside(parent_fd, control_fd) + else: + overlap = (_directory_inside(target_fd, control_fd) or + _directory_inside(control_fd, target_fd)) + if overlap: + _fail('Keep the private workspace control directory separate from portable project directories.', 'conflict', 409) + if target_fd is not None: + current = _directory(parent_fd, parts[-1]) + try: + if _physical(current) != _physical(target_fd): + _fail('Project directory changed during control separation check.', 'stale', 409) + finally: + os.close(current) + _verify_origin(control_fd, control_path) + except OSError as error: + _io_error(error, 'verify project/control separation') + finally: + if target_fd is not None: + os.close(target_fd) + if control_fd is not None: + os.close(control_fd) + + +def _rename_noreplace(parent_fd, staging, destination): + # Linux's RENAME_NOREPLACE provides the required atomic no-overwrite promise. + libc = ctypes.CDLL(None, use_errno=True) + rename = getattr(libc, 'renameat2', None) + if rename is None: + _fail('Atomic no-replace publication is unsupported.', 'unsupported', 422) + rename.argtypes = (ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint) + rename.restype = ctypes.c_int + if rename(parent_fd, os.fsencode(staging), parent_fd, os.fsencode(destination), 1): + number = ctypes.get_errno() + if number in (errno.ENOSYS, errno.EINVAL, errno.EOPNOTSUPP): + _fail('Filesystem lacks atomic no-replace publication.', 'unsupported', 422) + raise OSError(number, os.strerror(number)) + + +def _publish_directory(parent_fd, staging, destination): + _rename_noreplace(parent_fd, staging, destination) + + +def _io_error(exc, operation, unknown=False): + if unknown: + raise ProjectStoreError(f'Project publication outcome is unknown ({operation}); reopen and reconcile the request.', + 'unknown', 503) from exc + if exc.errno in (errno.ELOOP, errno.ENOTDIR, errno.ENXIO): + raise ProjectStoreError(f'Unsafe filesystem object during {operation}.') from exc + raise ProjectStoreError(f'Cannot {operation}.', 'unavailable', 503) from exc + + +def _physical(fd): + info = os.fstat(fd) + return info.st_dev, info.st_ino + + +def _regular(fd): + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1: + _fail('Project files must be ordinary files with no hard-link aliases.') + return info + + +def _read(directory, name, maximum): + fd = os.open(name, FILE_FLAGS, dir_fd=directory) + try: + info = _regular(fd) + if info.st_size > maximum: + _fail('Project file exceeds its byte limit.') + chunks, size = [], 0 + while True: + chunk = os.read(fd, min(1024 * 1024, maximum + 1 - size)) + if not chunk: + break + chunks.append(chunk) + size += len(chunk) + if size > maximum: + _fail('Project file exceeds its byte limit.') + after = _regular(fd) + if (info.st_size, info.st_mtime_ns, info.st_ctime_ns) != (after.st_size, after.st_mtime_ns, after.st_ctime_ns): + _fail('Project file changed while reading.', 'stale', 409) + return b''.join(chunks) + finally: + os.close(fd) + + +def _sync_file(directory, name): + fd = os.open(name, FILE_FLAGS, dir_fd=directory) + try: + _regular(fd) + os.fsync(fd) + finally: + os.close(fd) + + +def _directory(parent, name, create=False): + if create: + try: + os.mkdir(name, 0o700, dir_fd=parent) + os.fsync(parent) + except FileExistsError: + pass + return os.open(name, DIR_FLAGS, dir_fd=parent) + + +def _write_file(directory, name, raw): + fd = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, + 0o600, dir_fd=directory) + try: + _regular(fd) + remaining = memoryview(raw) + while remaining: + written = os.write(fd, remaining) + if not written: + raise OSError(errno.EIO, 'short project write') + remaining = remaining[written:] + os.fsync(fd) + finally: + os.close(fd) + + +class ProjectStore: + """Own one writable physical project root until close(). + + commit replaces the complete current record set. Retained unreferenced + revisions are never pruned. Callers validate domain semantics before commit + and after Open; this owner validates only the generic JSON/inventory contract. + """ + def __init__(self, path, root_fd, origin_fd, relative_parts, origin_path): + self.path = Path(path) + self._root_fd = root_fd + self._origin_fd = origin_fd + self._relative_parts = relative_parts + self._origin_path = Path(origin_path) + self._meta_fd = None + self._manifest = None + self._mutex = threading.RLock() + self._unknown = False + try: + fcntl.flock(root_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as exc: + os.close(root_fd) + os.close(origin_fd) + self._origin_fd = None + self._root_fd = None + if exc.errno in (errno.EACCES, errno.EAGAIN): + raise ProjectStoreError('Project already has a writer.', 'busy', 409) from exc + _io_error(exc, 'lock project root') + + @classmethod + def create(cls, path, name, request_key, request_sha256): + raw = _path_string(path) + if type(raw) is not str or '\x00' in raw or '..' in raw.split('/'): + _fail('Unsafe project destination.') + absolute = os.path.abspath(raw) + parent, leaf = os.path.split(absolute) + _, parent_fd = _open_directory_path(parent) + try: + return cls.create_at(parent_fd, leaf, name, request_key, request_sha256, root_path=parent) + finally: + os.close(parent_fd) + + @classmethod + def create_at(cls, trusted_root_fd, relative, name, request_key, request_sha256, *, root_path): + """Create under a configured root capability; duplicate its fd for store lifetime.""" + _request(request_key, request_sha256) + if type(name) is not str or not name.strip() or len(name) > 200: + _fail('Project name must contain 1–200 characters.') + _json_value(name) + parts = _relative_parts(relative) + absolute = _verify_origin(trusted_root_fd, root_path) + origin_fd = os.dup(trusted_root_fd) + parent_fd = None + store = None + published = False + try: + parent_fd = _walk(origin_fd, parts[:-1]) + # Existing destinations, including symlinks, are never merged or replaced. + try: + os.stat(parts[-1], dir_fd=parent_fd, follow_symlinks=False) + except FileNotFoundError: + pass + else: + _fail('Project destination already exists.', 'conflict', 409) + staging = '.tuldok-new-' + uuid.uuid4().hex + os.mkdir(staging, 0o700, dir_fd=parent_fd) + root_fd = _directory(parent_fd, staging) + owned_origin, origin_fd = origin_fd, None + store = cls(Path(absolute).joinpath(*parts), root_fd, owned_origin, + parts[:-1] + [staging], absolute) + store._meta_fd = _directory(root_fd, '.tuldok', create=True) + records_fd = _directory(store._meta_fd, 'records', create=True) + os.close(records_fd) + _write_file(root_fd, '.gitignore', GITIGNORE) + os.fsync(root_fd) + manifest = dict(format=FORMAT, project_id=uuid.uuid4().hex, name=name, + roots=[], inventory=[], receipts=[dict(request_key=request_key, + request_sha256=request_sha256, records_sha256=_sha(_encode([])))]) + manifest['checkpoint'] = _sha(_encode(manifest)) + store._publish(manifest, creating=True) + store._manifest = manifest + store._binding() + _publish_directory(parent_fd, staging, parts[-1]) + published = True + store._relative_parts = parts + os.fsync(parent_fd) + store._binding() + return store + except FileExistsError as exc: + if store is not None: + store.close() + raise ProjectStoreError('Project destination already exists.', 'conflict', 409) from exc + except ProjectStoreError as exc: + if store is not None: + store.close() + if published: + raise ProjectStoreError('Project destination was published but its binding changed; reopen and reconcile the request.', + 'unknown', 503) from exc + raise + except OSError as exc: + if store is not None: + store.close() + _io_error(exc, 'create project', unknown=published) + except BaseException: + if store is not None: + store.close() + raise + finally: + if parent_fd is not None: + os.close(parent_fd) + if origin_fd is not None: + os.close(origin_fd) + + @classmethod + def open(cls, path): + raw = _path_string(path) + if type(raw) is not str or '\x00' in raw or '..' in raw.split('/'): + _fail('Unsafe project root path.') + absolute = os.path.abspath(raw) + parent, leaf = os.path.split(absolute) + _, parent_fd = _open_directory_path(parent) + try: + return cls.open_at(parent_fd, leaf, root_path=parent) + finally: + os.close(parent_fd) + + @classmethod + def open_at(cls, trusted_root_fd, relative, *, root_path): + """Open a descendant without following any configured-root or child alias.""" + parts = _relative_parts(relative) + absolute = _verify_origin(trusted_root_fd, root_path) + origin_fd = os.dup(trusted_root_fd) + store = None + try: + fd = _walk(origin_fd, parts) + owned_origin, origin_fd = origin_fd, None + store = cls(Path(absolute).joinpath(*parts), fd, owned_origin, parts, absolute) + store._meta_fd = _directory(fd, '.tuldok') + store._manifest, _ = store._load() + return store + except OSError as exc: + if store is not None: + store.close() + _io_error(exc, 'open project') + except BaseException: + if store is not None: + store.close() + raise + finally: + if origin_fd is not None: + os.close(origin_fd) + + def _binding(self): + if self._root_fd is None: + _fail('Project store is closed.', 'unavailable', 409) + if self._unknown: + _fail('Reopen project to reconcile an unknown publication.', 'unknown', 503) + _verify_origin(self._origin_fd, self._origin_path) + check = _walk(self._origin_fd, self._relative_parts) + try: + if _physical(check) != _physical(self._root_fd): + _fail('Project root changed; close and reopen.', 'stale', 409) + meta = _directory(check, '.tuldok') + try: + if _physical(meta) != _physical(self._meta_fd): + _fail('Project metadata directory changed.', 'stale', 409) + finally: + os.close(meta) + finally: + os.close(check) + + @contextmanager + def root_directory(self): + """Borrow this project's physical root while its writer binding is pinned.""" + with self._mutex: + self.snapshot() + descriptor = os.dup(self._root_fd) + try: + yield descriptor + self._binding() + finally: + os.close(descriptor) + + def _load(self): + self._binding() + raw = _read(self._meta_fd, 'project.json', MAX_MANIFEST_BYTES) + manifest = _decode(raw) + if type(manifest) is not dict: + _fail('Project checkpoint must be an object.') + if manifest.get('format') != FORMAT: + if type(manifest.get('format')) is str and re.fullmatch(r'tuldok_project_v[1-9][0-9]*', manifest['format']): + _fail('Unsupported project format.', 'unsupported', 422) + _fail('Invalid project format.') + _keys(manifest, ('format', 'project_id', 'name', 'checkpoint', 'roots', 'inventory', 'receipts'), 'Project checkpoint') + identity = manifest['project_id'] + if type(identity) is not str or not re.fullmatch('[a-f0-9]{32}', identity): + _fail('Invalid project UUID.') + if type(manifest['name']) is not str or not manifest['name'].strip() or len(manifest['name']) > 200: + _fail('Invalid project display name.') + _digest(manifest['checkpoint'], 'Checkpoint') + core = {k: v for k, v in manifest.items() if k != 'checkpoint'} + if _sha(_encode(core)) != manifest['checkpoint']: + _fail('Project checkpoint digest does not match its contents.') + inventory = manifest['inventory'] + if type(inventory) is not list or len(inventory) > MAX_RECORDS: + _fail('Invalid project inventory.') + loaded, roots, total, previous = [], [], 0, None + for entry in inventory: + _keys(entry, ('family', 'id', 'format', 'namespace', 'path', 'bytes', 'sha256'), 'Inventory entry') + family, record_id, digest = entry['family'], entry['id'], entry['sha256'] + _identifier(family, 'Record family') + if family not in SUPPORTED_FORMATS: + _fail('Unsupported project record family.', 'unsupported', 422) + _identifier(record_id, 'Record ID') + _digest(digest, 'Record digest') + _domain_format(entry['format'], family) + key = (family, record_id) + if previous is not None and key <= previous: + _fail('Inventory must have unique sorted record identities.') + previous = key + if entry['namespace'] != identity or entry['path'] != f'records/{family}/{record_id}/{digest}.json': + _fail('Invalid inventory namespace or rooted record path.') + if type(entry['bytes']) is not int or not 0 < entry['bytes'] <= MAX_RECORD_BYTES: + _fail('Invalid inventory record byte count.') + total += entry['bytes'] + if total > MAX_TOTAL_BYTES: + _fail('Project records exceed their total byte limit.') + record_raw = self._record_file(family, record_id, digest) + if len(record_raw) != entry['bytes'] or _sha(record_raw) != digest: + _fail('Canonical record bytes changed or are corrupt.', 'stale' if self._manifest else 'invalid', 409 if self._manifest else 400) + document = _decode(record_raw) + if type(document) is not dict or document.get('format') != entry['format']: + _fail('Domain document format disagrees with its inventory.') + loaded.append(dict(family=family, id=record_id, document=document)) + roots.append(dict(family=family, id=record_id)) + if manifest['roots'] != roots: + _fail('Project membership roots disagree with the inventory.') + if type(manifest['receipts']) is not list or not manifest['receipts']: + _fail('Project requires operation receipts.') + seen = set() + for receipt in manifest['receipts']: + _keys(receipt, ('request_key', 'request_sha256', 'records_sha256'), 'Operation receipt') + _request(receipt['request_key'], receipt['request_sha256']) + _digest(receipt['records_sha256'], 'Receipt records digest') + if receipt['request_key'] in seen: + _fail('Duplicate operation receipt key.') + seen.add(receipt['request_key']) + if manifest['receipts'][-1]['records_sha256'] != _sha(_encode(loaded)): + _fail('Latest operation receipt disagrees with the current record set.') + # A second pointer read detects checkout/replacement during closure validation. + if _read(self._meta_fd, 'project.json', MAX_MANIFEST_BYTES) != raw: + _fail('Project checkpoint changed while reading.', 'stale', 409) + self._binding() + return manifest, loaded + + def _record_file(self, family, record_id, digest, raw=None, sync=False): + fds = [] + try: + parent = self._meta_fd + for part in ('records', family, record_id): + parent = _directory(parent, part, create=raw is not None) + fds.append(parent) + name = digest + '.json' + if raw is not None: + temporary = '.record-' + uuid.uuid4().hex + '.tmp' + _write_file(parent, temporary, raw) + try: + _rename_noreplace(parent, temporary, name) + except FileExistsError: + if _read(parent, name, MAX_RECORD_BYTES) != raw: + _fail('Immutable project revision already has different bytes.') + os.unlink(temporary, dir_fd=parent) + sync = True + if sync: + _sync_file(parent, name) + for fd in reversed(fds): + os.fsync(fd) + os.fsync(self._meta_fd) + return _read(parent, name, MAX_RECORD_BYTES) + finally: + for fd in reversed(fds): + os.close(fd) + + def _publish(self, manifest, creating=False): + raw = _encode(manifest) + if len(raw) > MAX_MANIFEST_BYTES: + _fail('Project checkpoint exceeds its byte limit.') + temporary = '.checkpoint-' + uuid.uuid4().hex + '.tmp' + replaced = False + try: + _write_file(self._meta_fd, temporary, raw) + self._binding() + if not creating: + current, _ = self._load() + if current != self._manifest: + _fail('Project checkpoint changed before publication.', 'stale', 409) + # Creation owns a new exclusive directory; commit owns its root flock. + if creating: + _rename_noreplace(self._meta_fd, temporary, 'project.json') + replaced = True + else: + os.replace(temporary, 'project.json', src_dir_fd=self._meta_fd, dst_dir_fd=self._meta_fd) + replaced = True + os.fsync(self._meta_fd) + os.fsync(self._root_fd) + except OSError as exc: + self._unknown = replaced + _io_error(exc, 'publish checkpoint', unknown=replaced) + # Temporary/orphan files remain inert after failures and process interruption. + + def _snapshot(self, manifest, records): + return dict(project_id=manifest['project_id'], name=manifest['name'], + checkpoint=manifest['checkpoint'], records=copy.deepcopy(records)) + + def snapshot(self): + with self._mutex: + try: + manifest, records = self._load() + if manifest != self._manifest: + _fail('Project checkpoint changed; close and reopen.', 'stale', 409) + return self._snapshot(manifest, records) + except OSError as exc: + _io_error(exc, 'read project snapshot') + + def reconcile(self, request_key, request_sha256): + """Inspect retry identity before a domain mutation; never repeat effects.""" + _request(request_key, request_sha256) + with self._mutex: + snapshot = self.snapshot() + for receipt in self._manifest['receipts']: + if receipt['request_key'] == request_key: + if receipt['request_sha256'] != request_sha256: + _fail('Request key was already used with different input.', 'conflict', 409) + return snapshot + return None + + def commit(self, records, expected_checkpoint, request_key, request_sha256): + _request(request_key, request_sha256) + _digest(expected_checkpoint, 'Expected checkpoint') + prepared = _records(records) + normalized = [dict(family=f, id=i, document=d) for f, i, d, _ in prepared] + records_digest = _sha(_encode(normalized)) + with self._mutex: + try: + current, loaded = self._load() + if current != self._manifest: + _fail('Project checkpoint changed; close and reopen.', 'stale', 409) + for receipt in current['receipts']: + if receipt['request_key'] == request_key: + if receipt['request_sha256'] != request_sha256 or receipt['records_sha256'] != records_digest: + _fail('Request key was already used with different input.', 'conflict', 409) + return self._snapshot(current, loaded) + if expected_checkpoint != current['checkpoint']: + _fail('Expected project checkpoint is stale.', 'stale', 409) + manifest = copy.deepcopy(current) + inventory = [] + for family, identity, document, raw in prepared: + digest = _sha(raw) + self._record_file(family, identity, digest, raw) + inventory.append(dict(family=family, id=identity, format=document['format'], + namespace=current['project_id'], path=f'records/{family}/{identity}/{digest}.json', + bytes=len(raw), sha256=digest)) + manifest['inventory'] = inventory + manifest['roots'] = [dict(family=f, id=i) for f, i, _, _ in prepared] + manifest['receipts'].append(dict(request_key=request_key, + request_sha256=request_sha256, records_sha256=records_digest)) + del manifest['checkpoint'] + manifest['checkpoint'] = _sha(_encode(manifest)) + self._publish(manifest) + self._manifest = manifest + return self._snapshot(manifest, normalized) + except OSError as exc: + _io_error(exc, 'commit project records') + + def save(self, expected_checkpoint): + _digest(expected_checkpoint, 'Expected checkpoint') + with self._mutex: + snapshot = self.snapshot() + if expected_checkpoint != snapshot['checkpoint']: + _fail('Expected project checkpoint is stale.', 'stale', 409) + try: + for entry in self._manifest['inventory']: + self._record_file(entry['family'], entry['id'], entry['sha256'], sync=True) + _sync_file(self._meta_fd, 'project.json') + os.fsync(self._meta_fd) + os.fsync(self._root_fd) + # Imported/copied roots may not yet have a durable directory entry. + parent_fd = _walk(self._origin_fd, self._relative_parts[:-1]) + try: + os.fsync(parent_fd) + finally: + os.close(parent_fd) + if self.snapshot() != snapshot: + _fail('Project changed during Save.', 'stale', 409) + except OSError as exc: + _io_error(exc, 'save project checkpoint') + return snapshot + + def recover_checkpoint(self): + """Explicitly re-read and sync this retained physical writer binding. + + Execution callers must first fence dispatch and drain actual local + custody. This method resolves only filesystem publication uncertainty; + it does not select a SQL outcome or authorize any work to resume. + """ + with self._mutex: + self._unknown = False + try: + manifest, records = self._load() + self._manifest = manifest + return self.save(self._snapshot(manifest, records)['checkpoint']) + except BaseException: + self._unknown = True + raise + + def close(self): + with self._mutex: + if self._meta_fd is not None: + os.close(self._meta_fd) + self._meta_fd = None + if self._root_fd is not None: + # Closing releases the flock. Explicit unlock would release it in + # an inherited descriptor shared by a forked process too. + os.close(self._root_fd) + self._root_fd = None + if self._origin_fd is not None: + os.close(self._origin_fd) + self._origin_fd = None + + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() diff --git a/project_study_execution.py b/project_study_execution.py new file mode 100644 index 00000000..9e19b3c2 --- /dev/null +++ b/project_study_execution.py @@ -0,0 +1,794 @@ +"""Current Study execution composed with exact inert project accounting. + +Only authoring metadata is reconstructed. Historical reservations and Runs stay +in canonical history; they contribute charges but never become worker, grant, +admission, replay, or cancellation rows in this physical runtime. +""" +import copy +from datetime import datetime, timezone +import hashlib +import json +import time +from types import SimpleNamespace + +from experiments import (Experiments, budget, request_key, validate_retained_reservation) +from model_definitions import natural, text +from pipeline import closed, identifier, digest +from project_history import (FAMILY_FORMATS, _audit, _sha, envelope, + decode_records as history_records) +from project_material import ProjectMaterial +from project_session import operation_records +from workbench import WorkbenchError, encode, timestamp + +PREPARATIONS={'study_execution.prepare_package':'studies.prepare_package', + 'study_execution.prepare_verifier':'studies.prepare_verifier'} +INTENTS={operation+'_intent':native for operation,native in PREPARATIONS.items()} +ABANDON='study_execution.abandon_preparation' +OPERATIONS=frozenset(('study_execution.admit','study_execution.stop','study_execution.approve',ABANDON,*PREPARATIONS,*INTENTS)) +GRANT_FIELDS=('id','catalog_id','approver_actor_id','approver_credential_id','consumer_actor_id', + 'material_sha256','created_at','expires_at','revoked_at') +RESERVATION_FIELDS=('id','study_id','admission_epoch','request_key','actor_id','trial_id','parent_run_id', + 'run_id','request_sha256','material_sha256','envelope','state','settled') + + +def _invalid(message,code='invalid'): + raise WorkbenchError(message,code,409) + + +def _facts(records): + view=SimpleNamespace(snapshot={'records':records},check=lambda:None) + return history_records(records,materials=ProjectMaterial(view)) + + +def _intent_key(operation,key): + return 'candidate-'+digest(dict(key=key,operation=operation))+'-intent' + + +def _preparation_body(intent,key): + body=dict(request_key=key,revision=intent['study_revision'],candidate_id=intent['candidate']['id'], + root=intent['root'],directory=intent['directory'],runtime_snapshot_sha256=intent['runtime_snapshot_sha256']) + if 'package_run_id' in intent: + body.update(package_run_id=intent['package_run_id'],package_sha256=intent['package_sha256']) + return body + + +def _preparation_reference(records,study_id,key): + receipts=operation_records(records) + matches=[(operation,receipts[_intent_key(operation,key)]) for operation in PREPARATIONS + if _intent_key(operation,key) in receipts] + if len(matches)!=1:_invalid('Preparation requires one exact retained intent.','unavailable') + operation,receipt=matches[0] + if receipt['operation']!=operation+'_intent' or receipt['result']['study_id']!=study_id: + _invalid('Preparation intent belongs to another operation or Study.','conflict') + intent=receipt['result'];complete=receipts.get(key) + if complete is None: + # The native final Trial transaction publishes its exact scientific + # receipt before the outer transport acknowledgement. That fact closes + # preparation even when the later wrapper receipt was never published. + native_sha=digest(dict(study_id=study_id,body=_preparation_body(intent,key))) + native=[] + for record in records: + if record['family']!='study_history':continue + doc=record['document'] + if doc['origin_catalog_id']!=intent['origin_catalog_id'] or doc['study']['id']!=study_id:continue + native.extend(value for value in doc['operations'] if value['actor_id']==intent['actor_id'] + and value['operation_id']==PREPARATIONS[operation] and value['request_key']==key) + if len(native)>1:_invalid('Preparation has duplicate native completion facts.') + if native: + if native[0]['request_sha256']!=native_sha:_invalid('Native preparation completion differs from its exact intent.','conflict') + validate_operation_result(operation,native[0]['result'],records) + complete=dict(operation=operation,result=copy.deepcopy(native[0]['result']),native_receipt=True) + return operation,intent,complete + + +def _validate_abandon(result,records): + closed(result,('format','origin_catalog_id','study_id','preparation_key','preparation_operation', + 'input_sha256','candidate','actor_id','reason','observed_checkpoint','occurred_at','state')) + if result['format']!='project_candidate_preparation_abandon_v1' or result['state']!='abandoned': + _invalid('Unsupported candidate preparation abandonment.','unsupported') + for field in ('origin_catalog_id','study_id','actor_id'):identifier(result[field]) + request_key(result['preparation_key']);text(result['reason'],'Abandonment reason',1000) + for field in ('input_sha256','observed_checkpoint'):_sha(result[field]) + if result['preparation_operation'] not in PREPARATIONS:_invalid('Unknown preparation operation.') + from model_definitions import reference + reference(result['candidate'],'candidate',result['origin_catalog_id']) + if datetime.fromisoformat(result['occurred_at']).utcoffset() is None:_invalid('Abandonment time requires an offset.') + if records is not None: + operation,intent,complete=_preparation_reference(records,result['study_id'],result['preparation_key']) + if (operation!=result['preparation_operation'] or intent['origin_catalog_id']!=result['origin_catalog_id'] + or intent['candidate']!=result['candidate'] or intent['request_sha256']!=result['input_sha256']): + _invalid('Abandonment differs from its exact retained preparation intent.') + if complete is not None:_invalid('Completed scientific preparation cannot be abandoned.') + return True + + +def _validate_preparation(operation,result,records): + from candidate_packages import NAME,decode_preparation_receipt + from experiments import validate_retained_trial + from model_definitions import reference + intent=operation in INTENTS;native=INTENTS[operation] if intent else PREPARATIONS[operation] + if intent: + fields=('format','origin_catalog_id','study_id','study_revision','candidate','actor_id', + 'request_sha256','root','directory','runtime_snapshot_sha256') + if native=='studies.prepare_verifier':fields+=('package_run_id','package_sha256') + closed(result,fields) + if result['format']!='project_candidate_preparation_intent_v1':_invalid('Unsupported candidate preparation intent.','unsupported') + for field in ('origin_catalog_id','study_id','actor_id'):identifier(result[field]) + natural(result['study_revision'],'Study revision',1) + for field in ('request_sha256','runtime_snapshot_sha256'):_sha(result[field]) + reference(result['candidate'],'candidate',result['origin_catalog_id']) + for field in ('root','directory'): + if type(result[field]) is not str or NAME.fullmatch(result[field]) is None:_invalid('Invalid preparation location.') + if native=='studies.prepare_verifier':identifier(result['package_run_id']);_sha(result['package_sha256']) + else: + origin=result['trial']['material']['catalog_id'] + decode_preparation_receipt(native,result,catalog_id=origin, + validate_trial=lambda value:validate_retained_trial(value,catalog_id=origin)) + if records is None:return True + facts=_facts(records) + if intent: + origin=result['origin_catalog_id'];candidate=facts.candidates[(origin,result['candidate']['id'])]['candidate'] + if candidate['study_id']!=result['study_id'] or digest(candidate)!=result['candidate']['sha256']: + _invalid('Preparation intent differs from its immutable candidate.') + study=facts.studies[(origin,result['study_id'])] + if not any(value['revision']==result['study_revision'] for value in study['revisions']): + _invalid('Preparation intent lacks its exact Study revision.') + if native=='studies.prepare_verifier': + run=facts.runs[(origin,result['package_run_id'])]['run'] + if not any(item['name']=='package.zip' and item['sha256']==result['package_sha256'] for item in run['artifacts']): + _invalid('Verifier intent differs from the retained package bytes.') + else: + from project_history import validate_operation_result as validate_history_result + validate_history_result(native,result,records) + trial=result['trial'];origin=trial['material']['catalog_id'] + candidate=facts.candidates[(origin,result['candidate_id'])]['candidate'] + if candidate['study_id']!=trial['study_id']:_invalid('Preparation candidate belongs to another Study.') + materials=ProjectMaterial(SimpleNamespace(snapshot={'records':records},check=lambda:None)) + composition=materials.recipe(trial['material'])['material'] + if (composition['model']!=result['model'] or composition['recipe_id']!=result['graph']['id'] + or composition['recipe_revision']!=result['graph']['revision']): + _invalid('Preparation model or graph differs from its retained composition.') + descriptor=materials.runtime(composition['runtime'])['descriptor'] + if descriptor is None or descriptor['manifest_sha256']!=result['runtime_snapshot_sha256']: + _invalid('Preparation runtime differs from its retained composition.') + return True + + +def validate_operation_result(operation,result,records=None): + """Closed scientific acknowledgements; none grants current authority.""" + if operation not in OPERATIONS:return False + try: + if operation==ABANDON:return _validate_abandon(result,records) + if operation in PREPARATIONS or operation in INTENTS: + return _validate_preparation(operation,result,records) + if operation=='study_execution.admit': + closed(result,('format','origin_catalog_id','study_id','trial_id','run_id','reservation')) + if result['format']!='project_study_admission_v1':_invalid('Unsupported Study admission receipt.','unsupported') + for key in ('origin_catalog_id','study_id','trial_id','run_id'):identifier(result[key]) + reservation=result['reservation'];closed(reservation,RESERVATION_FIELDS) + for key in ('id','study_id','actor_id','trial_id','run_id'):identifier(reservation[key]) + if reservation['parent_run_id'] is not None:identifier(reservation['parent_run_id']) + natural(reservation['admission_epoch'],'Admission epoch');request_key(reservation['request_key']) + for key in ('request_sha256','material_sha256'):_sha(reservation[key]) + budget(reservation['envelope']) + if reservation['state'] not in ('reserved','running','settled','unknown'):_invalid('Invalid reservation observation.') + if reservation['settled'] is not None:budget(reservation['settled']) + if any(result[key]!=reservation[key] for key in ('study_id','trial_id','run_id')): + _invalid('Study admission references differ from their reservation.') + elif operation=='study_execution.stop': + closed(result,('format','origin_catalog_id','study_id','receipt','study_revision','state','open')) + if result['format']!='project_study_stop_v1':_invalid('Unsupported Study stop receipt.','unsupported') + identifier(result['origin_catalog_id']);identifier(result['study_id']);natural(result['study_revision'],'Study revision',1) + receipt=result['receipt'];closed(receipt,('study_id','stop_receipt_id','admission_epoch','run_ids')) + identifier(receipt['study_id']);identifier(receipt['stop_receipt_id']);natural(receipt['admission_epoch'],'Stop epoch') + if (receipt['study_id']!=result['study_id'] or result['open'] is not False + or result['state'] not in ('stopping','stopped','unknown') + or type(receipt['run_ids']) is not list or len(receipt['run_ids'])>10000):_invalid('Invalid Study epoch closure.') + for identity in receipt['run_ids']:identifier(identity) + if len(set(receipt['run_ids']))!=len(receipt['run_ids']):_invalid('Duplicate stopped Run identity.') + else: + closed(result,('format','origin_catalog_id','study_id','trial_id','approval_id','material_sha256', + 'consumer_actor_id','approver_actor_id','created_at','expires_at')) + if result['format']!='project_study_approval_v1':_invalid('Unsupported Study approval observation.','unsupported') + identifier(result['study_id']);identifier(result['trial_id']) + from project_execution import validate_operation_result as validate_execution_result + base={key:result[key] for key in ('origin_catalog_id','approval_id','material_sha256', + 'consumer_actor_id','approver_actor_id','created_at','expires_at')} + validate_execution_result('runs.approve_external',dict(format='project_execution_approval_v1',**base)) + if records is None:return True + facts=_facts(records) + if operation=='study_execution.admit': + origin=result['origin_catalog_id'];reservation=result['reservation'] + saved=facts.reservations.get((origin,result['run_id'])) + if saved is None or any(saved[key]!=reservation[key] for key in RESERVATION_FIELDS if key not in ('state','settled')): + _invalid('Admission receipt has no exact canonical reservation.') + trial=facts.trials[(origin,result['trial_id'])]['trial'];run=facts.runs[(origin,result['run_id'])]['run'] + view=SimpleNamespace(snapshot={'records':records},check=lambda:None) + validate_retained_reservation(reservation,trial=trial, + composition=ProjectMaterial(view).recipe(trial['material']),run=run) + elif operation=='study_execution.stop': + doc=facts.studies[(result['origin_catalog_id'],result['study_id'])];receipt=result['receipt'] + matches=[value for value in doc['revisions'] if value['revision']==result['study_revision']] + if (len(matches)!=1 or matches[0]['stop_receipt_id']!=receipt['stop_receipt_id'] + or matches[0]['admission_epoch']!=receipt['admission_epoch'] + or matches[0]['state']!=result['state'] or matches[0]['open']!=result['open']): + _invalid('Stop receipt has no exact retained epoch closure.') + if not set(receipt['run_ids'])<={value['run_id'] for value in doc['reservations']}: + _invalid('Stop receipt targets an unrelated Run.') + else: + origin=result['origin_catalog_id'];doc=facts.studies[(origin,result['study_id'])] + trial=facts.trials[(origin,result['trial_id'])]['trial'] + if trial['study_id']!=result['study_id'] or trial['material']['sha256']!=result['material_sha256']: + _invalid('Approval observation differs from its exact retained Trial.') + expected={key:result[key] for key in ('trial_id','material_sha256','approval_id','consumer_actor_id','created_at','expires_at')} + matches=[fact for fact in doc['audit'] if fact['action']=='studies.approve' + and fact['target']==result['study_id'] and fact['actor_id']==result['approver_actor_id'] + and fact['details']==expected] + if len(matches)!=1:_invalid('Approval receipt has no exact historical Study approval observation.') + return True + except WorkbenchError:raise + except (KeyError,TypeError,ValueError,AttributeError,OverflowError): + _invalid('Malformed current Study operation receipt.') + + +class _LocalRunsReference: + """Refer only to an explicitly activated local owner; never construct one.""" + def __init__(self,execution):self.execution=execution + def __getattr__(self,name):return getattr(self.execution.runs,name) + + +class _PreparationRuns: + """Scientific inputs only; this object cannot allocate or recover Runs.""" + def __init__(self,adapter):self.adapter=adapter;self.access=adapter.session.access + def _load(self,run_id): + history=self.adapter.history;history._authorize('runs.get',run_id) + facts=history._facts();doc=history._record(facts.runs,run_id,self.adapter.origin) + history._transitive(facts,doc);history._disclose(doc['run'],'unknown') + return copy.deepcopy(doc['run']) + @staticmethod + def _external(run):return run.get('execution_kind')=='external_recipe' + @staticmethod + def _extension(run): + from recipe_contract import is_evidence + return run.get('execution_kind')=='external_recipe' and is_evidence(run['material']['recipe']) + def artifact(self,run_id,name): + raw=self.adapter.history.artifact(run_id,name,origin_catalog_id=self.adapter.origin, + maximum_bytes=512*1024**2) + return raw,'application/octet-stream' + def model_evidence(self,run_id,selectors,*,contract_version=2): + if contract_version!=2:_invalid('Candidate preparation requires model evidence version 2.','unsupported') + return self.adapter.history.evidence(run_id,selectors,origin_catalog_id=self.adapter.origin) + def capture_artifact(self,*args,**kwargs):return self.adapter.history.capture_artifact(*args,**kwargs) + + +class _PreparationProjects: + def __init__(self,adapter,phase): + self.adapter,self.phase=adapter,phase;self.source=adapter.projection.sources + self.access=adapter.access + def __getattr__(self,name):return getattr(self.source,name) + def open(self,body): + return self.adapter.projection.source_adapter.register(body, + self.adapter.session.snapshot['checkpoint'],self.phase+'-source') + + +class _PreparationModels: + def __init__(self,adapter,phase):self.adapter,self.phase=adapter,phase + def material(self,ref,*,verify=False):return self.adapter.materials.model(ref,verify=verify) + def read_blob(self,ref,item):return self.adapter.materials.blob(ref,item) + def _read_blob(self,ref,item,maximum=40*1024**2):return self.adapter.materials.blob(ref,item,maximum) + def create(self,body): + adapter=self.adapter + return adapter.materials.mutate_definition('model_definition','models.create',None,body, + adapter.session.snapshot['checkpoint'],self.phase+'-model',lambda:adapter.projection.models.create(body)) + def publish(self,identity,body): + adapter=self.adapter + return adapter.materials.publish_model(adapter.projection.models,identity,body, + adapter.session.snapshot['checkpoint'],self.phase+'-publish') + + +class _PreparationPipelines: + def __init__(self,adapter,phase,context): + from pipeline import Pipelines + self.adapter,self.phase=adapter,phase;self.owner=Pipelines(context) + self.material_path=self.owner.material_path + def get(self,*args,**kwargs):return self.owner.get(*args,**kwargs) + def recipe_material(self,ref,*,verify=False):return self.adapter.materials.recipe(ref,verify=verify) + def create_external(self,body): + adapter=self.adapter + return adapter.materials.mutate_definition('pipeline_graph','graphs.create_external',None,body, + adapter.session.snapshot['checkpoint'],self.phase+'-graph',lambda:self.owner.create_external(body)) + def prepare_external_material(self,*args,**kwargs):return self.owner.prepare_external_material(*args,**kwargs) + def _retain_material(self,prepared): + adapter=self.adapter;value=self.owner._retain_material(prepared) + record=adapter.materials.capture_recipe_material(value,self.owner.material_path) + adapter._package_records.extend([record,*adapter.materials.declarations(record), + *adapter.materials.capture_recipe_dependencies(record,adapter.session.snapshot['records'])]) + return value + + +class ProjectStudyExecution: + def __init__(self,projection,execution): + self.projection,self.execution=projection,execution + self.session,self.db,self.history=projection.session,projection.db,projection.history + self.materials=projection.materials;self.access=execution.runtime_access + self.origin=self.session.snapshot['project_id'] + self._dirty=set();self._revisions={};self._epochs={};self._pending_epochs={} + self._package_records=[] + self.context=copy.copy(projection.context) + self.context.access,self.context.runs=self.access,_LocalRunsReference(execution) + self.context.project_study_execution=self + self.context.project_execution=execution + self.studies=Experiments(self.context) + execution.study_execution=self + projection.study_execution=self + self.history.study_execution=self + + def _canonical(self,records=None): + records=self.session.snapshot['records'] if records is None else records + return history_records(records,materials=self.materials) + + def restore_authoring(self,records=None): + """Reconstruct intent only, under the current project guard.""" + with self.session.access.lock,self.session.lock,self.db.lock: + self.session.check();facts=self._canonical(records) + with self.db.transaction(): + for (origin,identity),doc in sorted(facts.studies.items()): + if origin!=self.origin:continue + for item in doc['policies']: + ref=item['ref'] + self.db.execute('INSERT INTO model_selection_policies VALUES(%s,%s,%s,%s) ON CONFLICT(id,revision) DO UPDATE SET sha256=EXCLUDED.sha256,document=EXCLUDED.document', + (ref['id'],ref['revision'],ref['sha256'],encode(item['policy']))) + value=doc['study'] + self.db.execute('INSERT INTO model_studies VALUES(%s,%s,%s,%s) ON CONFLICT(id) DO UPDATE SET revision=EXCLUDED.revision,admission_epoch=EXCLUDED.admission_epoch,document=EXCLUDED.document', + (identity,value['revision'],value['admission_epoch'],encode(value))) + for member in doc['memberships']: + self.db.execute('INSERT INTO model_study_memberships VALUES(%s,%s,%s) ON CONFLICT(study_id,revision) DO UPDATE SET document=EXCLUDED.document', + (identity,member['revision'],encode({'trial_ids':member['trial_ids']}))) + for (origin,identity),doc in sorted(facts.trials.items()): + if origin!=self.origin:continue + value=doc['trial'] + self.db.execute('INSERT INTO model_trials VALUES(%s,%s,%s,%s,%s) ON CONFLICT(id) DO UPDATE SET document=EXCLUDED.document', + (identity,value['study_id'],value['material']['sha256'],value['replicate'],encode(value))) + for (origin,identity),doc in sorted(facts.trials.items()): + if origin!=self.origin or doc['lineage'] is None:continue + value=doc['lineage'];intent=value['intent'] + # Native current authoring only supports same-project SQL + # parent FKs; foreign history remains in the canonical view. + if any(ref is not None and ref['catalog_id']!=self.origin for ref in (intent['parent'],intent['baseline'])):continue + self.db.execute('INSERT INTO model_trial_lineage VALUES(%s,%s,%s,%s) ON CONFLICT(trial_id) DO UPDATE SET document=EXCLUDED.document', + (identity,(intent['parent'] or {}).get('trial_id'),(intent['baseline'] or {}).get('trial_id'),encode(value))) + return None + + refresh_authoring=restore_authoring + + def local_run_ids(self): + return {row[0] for row in self.db.execute('SELECT run_id FROM model_attempt_reservations')} + + def historical_reservations(self,study_id): + doc=self._canonical().studies.get((self.origin,study_id)) + if doc is None:return [] + local=self.local_run_ids() + return [copy.deepcopy(value) for value in doc['reservations'] if value['run_id'] not in local] + + def reject_historical_request(self,study_id,actor,key,sha): + for value in self.historical_reservations(study_id): + if value['request_key']==key: + _invalid('This request identifies an inert historical attempt; use a fresh key for new execution.','conflict') + + def run_fact(self,run_id): + identifier(run_id) + if self.db.execute('SELECT id FROM workspace_runs WHERE id=%s',(run_id,)).fetchone() is not None: + return copy.deepcopy(self.execution.runs._load(run_id)) + value=self._canonical().runs.get((self.origin,run_id)) + if value is None:_invalid('Retained Run observation is unavailable.','not_found') + return copy.deepcopy(value['run']) + + def lineage_fact(self,trial_id): + value=self._canonical().trials.get((self.origin,trial_id)) + return copy.deepcopy(value['lineage']) if value is not None else None + + def epoch_current(self,study): + return study['open'] and self._epochs.get(study['id'])==study['admission_epoch'] + + def require_current_epoch(self,study): + if not self.epoch_current(study): + _invalid('Explicitly continue this Study in the current runtime before admission.','unavailable') + + def external_blocked(self): + owner=getattr(self.execution,'_runs',None) + return bool(owner is not None and owner._external_blocked) + + def mark_study(self,study_id): + identifier(study_id) + if not self.db.in_transaction:raise RuntimeError('Study publication requires its owning SQL transaction.') + value=self.studies._study(study_id) + self._dirty.add(study_id) + versions=self._revisions.setdefault(study_id,{}) + prior=versions.get(value['revision']) + if prior is not None and prior!=value:_invalid('A Study revision changed inside its publication transaction.') + versions[value['revision']]=copy.deepcopy(value) + + def _operation_study(self,operation,result): + if operation in ('studies.create','studies.save','studies.continue'):return result['id'] + if operation in ('studies.prepare_trial','studies.prepare_branch'):return result['study_id'] + if operation in ('studies.branch_trial','studies.stop'):return result['study_id'] + if operation=='studies.select_candidate':return result['candidate']['study_id'] + if operation in ('studies.prepare_package','studies.prepare_verifier'):return result['trial']['study_id'] + if operation=='studies.record_lineage':return self.studies.trial(result['trial']['trial_id'])['study_id'] + _invalid('Unclassified native Study operation result.','unsupported') + + def mark_operation(self,operation,result): + self.mark_study(self._operation_study(operation,result)) + + def settle_runs(self,run_ids): + if not run_ids:return + if not self.db.in_transaction:raise RuntimeError('Reservation settlement must share Run publication.') + studies={row['study_id'] for row in self.db.execute('SELECT study_id,run_id FROM model_attempt_reservations') if row['run_id'] in run_ids} + if studies: + with self.access._reconcile(): + for identity in sorted(studies):self.studies._reconcile_local(identity) + + @staticmethod + def _audit_fact(row): + return dict(actor_id=row['actor_id'],action=row['action'],target=json.loads(row['target_json']), + details=json.loads(row['details_json']),result=row['result'], + occurred_at=datetime.fromtimestamp(row['created_at'],timezone.utc).isoformat()) + + def capture_dirty(self,before_records): + # Execution has already qualified this observation against current + # local custody. These finite metadata reads must not ask the child + # continuation to acquire unrelated Study/policy capabilities. + with self.access._reconcile(): + return self._capture_dirty(before_records) + + def _capture_dirty(self,before_records): + if not self._dirty:return copy.deepcopy(self._package_records) + facts=self._canonical(before_records);records=copy.deepcopy(self._package_records) + audit_rows=list(self.db.execute('SELECT actor_id,action,target_json,details_json,result,created_at FROM access_audit ORDER BY created_at,id')) + operation_rows=list(self.db.execute('SELECT actor_id,operation_id,request_key,request_sha256,document FROM model_operation_receipts ORDER BY actor_id,operation_id,request_key')) + for study_id in sorted(self._dirty): + old=facts.studies.get((self.origin,study_id));value=self.studies._study(study_id) + doc=copy.deepcopy(old) if old is not None else dict(format=FAMILY_FORMATS['study_history'],origin_catalog_id=self.origin, + study=value,memberships=[],policies=[],reservations=[],operations=[],revisions=[],audit=[],revision_history_complete=True) + revisions={item['revision']:item for item in doc['revisions']} + for revision,item in self._revisions.get(study_id,{}).items(): + if revision in revisions and revisions[revision]!=item:_invalid('Published Study revision cannot change.') + revisions[revision]=copy.deepcopy(item) + if value['revision'] in revisions and revisions[value['revision']]!=value:_invalid('Current Study differs from captured revision.') + revisions[value['revision']]=copy.deepcopy(value) + doc['study']=copy.deepcopy(value);doc['revisions']=[revisions[key] for key in sorted(revisions)] + if old is None and min(revisions)!=1:doc['revision_history_complete']=False + doc['memberships']=[dict(revision=row['revision'],trial_ids=json.loads(row['document'])['trial_ids']) + for row in self.db.execute('SELECT revision,document FROM model_study_memberships WHERE study_id=%s ORDER BY revision',(study_id,))] + policies={item['ref']['sha256']:item for item in doc['policies']} + ref=value['policy'];policies[ref['sha256']]=dict(ref=copy.deepcopy(ref),policy=self.studies.policy(ref)) + doc['policies']=[policies[key] for key in sorted(policies)] + reservations={item['run_id']:item for item in doc['reservations']} + reservations.update((item['run_id'],item) for item in self.studies._local_reservations(study_id)) + doc['reservations']=sorted(reservations.values(),key=lambda item:item['id']) + operations={(item['actor_id'],item['operation_id'],item['request_key']):item for item in doc['operations']} + for row in operation_rows: + result=json.loads(row['document']) + if self._operation_study(row['operation_id'],result)!=study_id:continue + item=dict(actor_id=row['actor_id'],operation_id=row['operation_id'],request_key=row['request_key'], + request_sha256=row['request_sha256'],result=result) + key=item['actor_id'],item['operation_id'],item['request_key'] + if key in operations and operations[key]!=item:_invalid('A retained native operation fact changed.') + operations[key]=item + doc['operations']=[operations[key] for key in sorted(operations)] + targets={study_id,*value['trial_ids'],*(item['run_id'] for item in doc['reservations'])} + candidates=[] + for row in self.db.execute('SELECT id,document FROM model_candidates WHERE study_id=%s',(study_id,)): + candidate=json.loads(row['document']);targets.add(candidate['id']) + candidates.append(envelope('candidate_history',self.origin,candidate['id'],dict( + format=FAMILY_FORMATS['candidate_history'],origin_catalog_id=self.origin,candidate=candidate))) + for row in audit_rows: + if not (row['action'].startswith('studies.') or row['action'].startswith('runs.')):continue + fact=self._audit_fact(row) + if fact['target'] not in targets:continue + _audit([fact]) + if fact not in doc['audit']:doc['audit'].append(fact) + records.append(envelope('study_history',self.origin,study_id,doc));records.extend(candidates) + for trial_id in value['trial_ids']: + trial=self.studies.trial(trial_id) + row=self.db.execute('SELECT document FROM model_trial_lineage WHERE trial_id=%s',(trial_id,)).fetchone() + prior=facts.trials.get((self.origin,trial_id));lineage=json.loads(row[0]) if row is not None else (prior['lineage'] if prior else None) + records.append(envelope('trial_history',self.origin,trial_id,dict(format=FAMILY_FORMATS['trial_history'], + origin_catalog_id=self.origin,trial=trial,lineage=lineage))) + return records + + def abandon_pending(self): + """Discard only unpublished projection bookkeeping during guarded repair. + + This cannot activate an imported epoch or recreate any execution right. + The root recovery owner separately qualifies custody and exact selection. + """ + self._dirty.clear();self._revisions.clear();self._pending_epochs.clear() + self._package_records.clear() + + def outcome(self,outcome): + if outcome not in ('committed','rolled_back','unknown'):raise ValueError('Unsupported Study SQL outcome.') + if outcome=='unknown':return + if outcome=='committed':self._epochs.update(self._pending_epochs) + self._dirty.clear();self._revisions.clear();self._pending_epochs.clear() + self._package_records.clear() + + def _mutate(self,operation,study_id,body,expected_checkpoint,key,invoke,scope): + return self.execution.mutate(operation,study_id,body,expected_checkpoint,key,invoke, + lambda result,before:dict(records=[],result=result),scope=scope) + + def continue_study(self,study_id,body,expected_checkpoint,key): + closed(body,('revision','admission_epoch'));request_key(key) + self.access.require('execute','studies.continue',study_id,owner_only=True) + with self.session.access.lock,self.session.lock,self.db.lock: + self.refresh_authoring() + prior=operation_records(self.session.snapshot['records']).get(key) + if prior is not None and not self.epoch_current(self.studies._study(study_id)): + _invalid('A historical Continue receipt cannot activate this runtime; use a fresh key.','unavailable') + def invoke(): + value=self.studies.continue_study(study_id,dict(body,request_key=key)) + self._pending_epochs[study_id]=value['admission_epoch'] + return value + return self._mutate('studies.continue',study_id,body,expected_checkpoint,key,invoke,'execute') + + def _current_grant(self,value,approver): + row=self.db.execute('SELECT * FROM access_execution_grants WHERE id=%s',(value['approval_id'],)).fetchone() + if row is None:_invalid('This approval is historical; approve explicitly with a fresh key.','unavailable') + grant=dict(row);closed(grant,GRANT_FIELDS) + expected=dict(origin_catalog_id=grant['catalog_id'],approval_id=grant['id'], + **{key:grant[key] for key in ('material_sha256','consumer_actor_id','approver_actor_id','created_at','expires_at')}) + if (any(value[key]!=item for key,item in expected.items()) or grant['approver_actor_id']!=approver + or grant['revoked_at'] is not None or grant['expires_at']<=int(time.time())): + _invalid('An exact current local execution approval is unavailable.','unavailable') + return grant + + def _materialize_trial(self,study_id,trial_id,expected_sha256=None): + """Explicit preparation reconstructs only the current private caches.""" + study=self.studies._study(study_id);trial=self.studies.trial(trial_id) + if trial['study_id']!=study_id or trial_id not in study['trial_ids']: + _invalid('Prepare an exact member Trial.') + if expected_sha256 is not None and trial['material']['sha256']!=expected_sha256: + _invalid('Composed material differs. Review the exact Trial.','stale') + value=self.materials.recipe(trial['material']);material=value['material'] + snapshot=json.loads(self.materials.blob(trial['material'],material['recipe_document'],2*1024**2)) + if snapshot['format']!='builtin_recipe_snapshot_v1': + # Current permission is independent from the original input proof. + # Verify every protected producer before rebuilding captured bytes. + for item in snapshot['preflight']['material']['inputs'].values(): + if item.get('kind')=='run_artifact': + source=item['source'] + self.history.capture_artifact(source['run_id'],source['artifact_name'],source['measured_sha256'], + origin_catalog_id=source['catalog_id']) + self.materials.materialize_projection(material['model'],self.projection.models.path) + self.materials.materialize_projection(trial['material'],self.projection.pipelines.material_path) + return trial + + def approve(self,study_id,body,expected_checkpoint,key): + closed(body,('trial_id','expected_material_sha256','consumer_actor_id'),('ttl_seconds',));request_key(key) + current=self.access.require('approve-execution','studies.approve',study_id,owner_only=True) + effective=dict(body,approver_actor_id=current.actor_id) + with self.session.access.lock,self.session.lock,self.db.lock: + self.refresh_authoring();prior=operation_records(self.session.snapshot['records']).get(key) + if prior is not None: + if prior['operation']!='study_execution.approve':_invalid('Request key has another operation.','conflict') + validate_operation_result(prior['operation'],prior['result'],self.session.snapshot['records']) + self._current_grant(prior['result'],current.actor_id) + else: + if expected_checkpoint!=self.session.snapshot['checkpoint']:_invalid('Expected project checkpoint changed.','stale_project') + self._materialize_trial(study_id,body['trial_id'],body['expected_material_sha256']) + def invoke(): + grant=self.studies.approve(study_id,body) + return dict(format='project_study_approval_v1',origin_catalog_id=grant['catalog_id'], + study_id=study_id,trial_id=body['trial_id'],approval_id=grant['id'], + **{key:grant[key] for key in ('material_sha256','consumer_actor_id','approver_actor_id','created_at','expires_at')}) + value=self._mutate('study_execution.approve',study_id,effective,expected_checkpoint,key,invoke,'approve-execution') + grant=self._current_grant(value,current.actor_id) + return dict(format='project_current_execution_approval_v1',current_local=True,grant=grant) + + def admit(self,study_id,body,expected_checkpoint,key): + closed(body,('revision','admission_epoch','trial_id'),('approval_id','parent_run_id'));request_key(key) + authority=self.access.require_external_submission() + with self.session.access.lock,self.session.lock,self.db.lock: + builtin=None + self.session.check();self.refresh_authoring() + prior=operation_records(self.session.snapshot['records']).get(key) + if prior is not None: + result=prior['result'] + if (prior['operation']!='study_execution.admit' or self.db.execute( + 'SELECT run_id FROM model_attempt_reservations WHERE run_id=%s',(result.get('run_id'),)).fetchone() is None): + _invalid('A historical admission receipt cannot create a current local attempt.','unavailable') + else: + if expected_checkpoint!=self.session.snapshot['checkpoint']:_invalid('Expected project checkpoint changed.','stale_project') + self.require_current_epoch(self.studies._study(study_id)) + self.reject_historical_request(study_id,authority.actor_id,key,None) + trial=self._materialize_trial(study_id,body['trial_id']) + from pipeline import BUILTIN_TRAINERS + material=self.materials.recipe(trial['material']) + if material['material']['trainer']['id'] in BUILTIN_TRAINERS: + self.access.require('execute','studies.admit_builtin',study_id,owner_only=True) + from project_native_execution import native_admission_wall_seconds + if material['material']['resource_envelope']['admitted_wall_seconds'] 64: + _invalid('Invalid text timestamp.') + try: + parsed = datetime.fromisoformat(value) + if parsed.utcoffset() is None: + raise ValueError() + return parsed + except (ValueError, OverflowError): + _invalid('Invalid text timestamp.') + + +SNAPSHOT_FIELDS = set('id kind name text content_hash pixel_hash groups parents provenance task annotation review revision created_at updated_at width height source_revision source_sha256 book_id session_id source_split corner_annotation source_available source_lineage_known'.split()) +IMMUTABLE_FIELDS = set('id kind name text content_hash pixel_hash parents provenance created_at width height source_revision source_sha256 book_id session_id source_split corner_annotation source_available source_lineage_known'.split()) +AUDIT_ACTIONS = {'workbench.import_asset', 'workbench.save', 'workbench.approve_review'} +SHA256 = re.compile(r'^[a-f0-9]{64}$') +REQUEST_KEY = re.compile(r'^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$') + + +def _snapshot(value, identity, original): + _shape(value, SNAPSHOT_FIELDS, 'text snapshot') + if value['id'] != identity or value['kind'] != 'text': + _invalid('Text snapshot identity mismatch.') + if type(value['revision']) is not int or value['revision'] < 1: + _invalid('Invalid text revision.') + if type(value['source_revision']) is not int or value['source_revision'] != 1: + _invalid('Invalid text source revision.') + if any(value[key] is not None for key in ('pixel_hash', 'width', 'height', 'book_id', 'session_id', 'corner_annotation')): + _invalid('Text snapshot contains non-text source fields.') + if value['source_available'] is not True or value['source_lineage_known'] is not True or value['source_split'] != 'unassigned': + _invalid('Invalid text source state.') + if text_value(value['name'], 'Name') != value['name']: + _invalid('Text name must retain canonical spelling.') + normalized = unicodedata.normalize('NFC', original.replace('\r\n', '\n').replace('\r', '\n')) + if value['text'] != normalized or value['content_hash'] != _sha(normalized) or value['source_sha256'] != _sha(original): + _invalid('Text source, normalization or hash mismatch.') + for key in ('groups', 'parents'): + if strings(value[key], key) != value[key] or (key == 'groups' and not value[key]): + _invalid('Invalid text ' + key + '.') + if any(not IDENTIFIER.fullmatch(parent) for parent in value['parents']): + _invalid('Invalid text parent identity.') + origin = value['provenance'] + _shape(origin, {'method', 'rights', 'normalization'}, 'text provenance') + if origin['method'] != 'import' or origin['normalization'] != 'NFC; LF newlines' or text_value(origin['rights'], 'Rights', 1000) != origin['rights']: + _invalid('Unsupported text provenance.') + if value['task'] not in ('text_classification', 'text_entities', 'text_corpus') or value['review'] not in ('draft', 'human_reviewed'): + _invalid('Invalid text task or review.') + if value['annotation'] is None: + if value['revision'] != 1 or value['task'] != 'text_classification' or value['review'] != 'draft': + _invalid('Missing text annotation after import.') + elif validate_annotation(value['task'], value['annotation'], value) != value['annotation']: + _invalid('Text annotation is not canonical.') + if _time(value['updated_at']) < _time(value['created_at']): + _invalid('Text update precedes creation.') + + +def decode_records(records): + """Validate complete text closure before any SQL write; return an owned copy.""" + if type(records) is not list or len(records) > 100_000: + _invalid('Project text records must be a bounded list.') + documents, operations, hashes = {}, set(), set() + for envelope in records: + _shape(envelope, {'family', 'id', 'document'}, 'text envelope') + if envelope['family'] != 'workbench_text': + raise ProjectTextError('Unsupported project record family.', 'unsupported', 409) + identity, document = envelope['id'], envelope['document'] + if type(identity) is not str or not IDENTIFIER.fullmatch(identity) or identity in documents: + _invalid('Invalid or duplicate text identity.') + if type(document) is not dict: + _invalid('Text document must be an object.') + if document.get('format') != 'workbench_text_v1': + raise ProjectTextError('Unsupported text document format.', 'unsupported', 409) + _shape(document, {'format', 'original_text', 'current', 'history', 'audit', 'operations'}, 'text document') + original = document['original_text'] + text_value(original, 'Original text', MAX_TEXT) + history = document['history'] + if type(history) is not list or not history: + _invalid('Text history must contain every revision.') + previous = None + for revision, state in enumerate(history, 1): + _snapshot(state, identity, original) + if state['revision'] != revision: + _invalid('Text history revisions must be contiguous.') + if previous and (any(state[key] != previous[key] for key in IMMUTABLE_FIELDS) or _time(state['updated_at']) < _time(previous['updated_at'])): + _invalid('Text history changes immutable source or reverses time.') + previous = state + _snapshot(document['current'], identity, original) + if document['current'] != history[-1] or history[0]['annotation'] is not None: + _invalid('Text current value or initial history is inconsistent.') + if document['current']['content_hash'] in hashes: + _invalid('Duplicate canonical text.') + hashes.add(document['current']['content_hash']) + audit, receipts = document['audit'], document['operations'] + if type(audit) is not list or type(receipts) is not list or len(audit) != len(history) or len(receipts) != len(history): + _invalid('Every text revision requires its audit and operation facts.') + for revision, (fact, receipt, state) in enumerate(zip(audit, receipts, history), 1): + _shape(fact, {'action', 'target', 'details', 'result', 'actor_id', 'occurred_at'}, 'text audit') + expected_action = 'workbench.import_asset' if revision == 1 else 'workbench.approve_review' if state['review'] == 'human_reviewed' else 'workbench.save' + details = {'revision': revision, 'source_revision': 1, 'content_sha256': _sha(encode(state))} + if revision > 1: + details.update(before_revision=revision - 1, before_source_revision=1) + _shape(fact['details'], details, 'text audit details') + if any(type(fact['details'][key]) is not type(expected) for key, expected in details.items()): + _invalid('Invalid text audit detail types.') + if fact['action'] != expected_action or fact['target'] != identity or fact['details'] != details or fact['result'] != 'success': + _invalid('Invalid text audit evidence.') + if type(fact['actor_id']) is not str or not IDENTIFIER.fullmatch(fact['actor_id']): + _invalid('Invalid historical text actor identity.') + if _time(fact['occurred_at']) < _time(state['updated_at']): + _invalid('Text audit precedes its mutation.') + _shape(receipt, {'request_key', 'request_sha256', 'action', 'target', 'revision'}, 'text operation') + key, digest = receipt['request_key'], receipt['request_sha256'] + if type(key) is not str or not REQUEST_KEY.fullmatch(key) or key in operations or type(digest) is not str or not SHA256.fullmatch(digest): + _invalid('Invalid or duplicate text operation identity.') + if receipt['target'] != identity or type(receipt['revision']) is not int or receipt['revision'] != revision or receipt['action'] != ('import_text' if revision == 1 else 'annotate'): + _invalid('Text operation revision or target mismatch.') + operations.add(key) + documents[identity] = document + # Parents are authored source lineage. They must exist, precede children, + # and form a closed acyclic graph; no foreign family can supply authority. + for identity, document in documents.items(): + current = document['current'] + for parent in current['parents']: + if parent not in documents or _time(documents[parent]['current']['created_at']) > _time(current['created_at']): + _invalid('Text parent reference is missing or newer than its child.') + visiting, visited = set(), set() + for identity in documents: + stack = [(identity, False)] + while stack: + node, leaving = stack.pop() + if leaving: + visiting.remove(node) + visited.add(node) + elif node not in visited: + if node in visiting: + _invalid('Text parent lineage contains a cycle.') + visiting.add(node) + stack.append((node, True)) + stack.extend((parent, False) for parent in documents[node]['current']['parents']) + return copy.deepcopy(records) + + +class _HostAccess: + """Current authorization stays at the installation; only inert facts travel.""" + def __init__(self, host): + self.host, self.facts = host, None + + def require(self, *args, **kwargs): + return self.host.require(*args, **kwargs) + + def denial(self, *args, **kwargs): + return self.host.denial(*args, **kwargs) + + def audit(self, action, target=None, details=None, result='success'): + if self.facts is None or action not in AUDIT_ACTIONS or result != 'success': + raise ProjectTextError('This project operation is unavailable in M1.', 'unavailable', 409) + context = self.host.require('edit', action, target) + self.facts.append(dict(action=action, target=target, details=copy.deepcopy(details or {}), + result=result, actor_id=context.actor_id, occurred_at=timestamp())) + + +class ProjectText: + def __init__(self, store, db, access): + self.store, self.db, self.access = store, db, access + self._lock = threading.RLock() + self._authority = _HostAccess(access) + inert = SimpleNamespace(db=db, lock=db.lock, path=db.path, access=self._authority) + self._workbench = Workbench(inert) + self._snapshot = None + self.available = False + + @property + def checkpoint(self): + return self._snapshot['checkpoint'] if self._snapshot else None + + def _authorize(self, scope, action, target=None): + self.access.require(scope, action, target) + + def _bound(self): + if not self.available: + raise ProjectTextError('Project projection requires reconstruction.', 'projection_unavailable', 503) + current = self.store.snapshot() + if current['checkpoint'] != self.checkpoint: + raise ProjectTextError('Project changed outside this projection. Reload it.', 'stale_project', 409) + return current + + def _tag(self, value, request_key=None): + result = dict(value, project_id=self._snapshot['project_id'], checkpoint=self.checkpoint) + if request_key is not None: + result['request_key'] = request_key + return result + + def restore(self, snapshot=None): + """Replace only this disposable text projection after full validation.""" + with self._lock, self.db.lock: + if self.db.in_transaction: + raise ProjectTextError('Reconstruction must own its SQL transaction.', 'unavailable', 409) + current = self.store.snapshot() + if snapshot is not None and snapshot != current: + raise ProjectTextError('Restore snapshot is stale.', 'stale_project', 409) + records = decode_records(current['records']) + self.available = False + with self.db.transaction(): + if self.db.execute("SELECT 1 FROM workbench_records WHERE kind<>'text' LIMIT 1").fetchone() or self.db.execute('SELECT 1 FROM samples LIMIT 1').fetchone(): + raise ProjectTextError('M1 requires an isolated text projection.', 'unavailable', 409) + self.db.execute('DELETE FROM workbench_history') + self.db.execute('DELETE FROM workbench_rights_notes') + self.db.execute('DELETE FROM workbench_target_proposals') + self.db.execute('DELETE FROM workbench_records') + for envelope in records: + doc, row = envelope['document'], envelope['document']['current'] + self.db.execute('''INSERT INTO workbench_records + (id,kind,name,text,original_text,content_hash,pixel_hash,groups_json,parents_json,provenance_json,task,annotation_json,review,revision,created_at,updated_at) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)''', + (row['id'], 'text', row['name'], row['text'], doc['original_text'], row['content_hash'], None, + encode(row['groups']), encode(row['parents']), encode(row['provenance']), row['task'], + encode(row['annotation']) if row['annotation'] is not None else None, row['review'], row['revision'], row['created_at'], row['updated_at'])) + for state in doc['history']: + self._workbench._history(state) + if self.store.snapshot()['checkpoint'] != current['checkpoint']: + raise ProjectTextError('Project changed during reconstruction.', 'stale_project', 409) + self._snapshot, self.available = current, True + return copy.deepcopy(current) + + def query(self, options=None): + with self.access.lock, self._lock, self.db.lock: + self._authorize('inspect', 'workbench.query') + self._bound() + result = self._workbench.query(options or {}) + self._bound() + return self._tag(result) + + def records(self): + """Full current values for the controller's bounded public projection. + + Canonical values come from one verified snapshot, avoiding pagination + truncation and repeated full inventory reads for each individual row. + """ + with self.access.lock, self._lock, self.db.lock: + self._authorize('inspect', 'workbench.query') + current = self._bound() + return self._tag({'items': [envelope['document']['current'] for envelope in current['records']]}) + + def get(self, record_id): + with self.access.lock, self._lock, self.db.lock: + self._authorize('inspect', 'workbench.get', record_id) + self._bound() + result = self._workbench.get(record_id) + self._bound() + return self._tag(result) + + def history(self, record_id): + with self.access.lock, self._lock, self.db.lock: + self._authorize('inspect', 'workbench.history', record_id) + self._bound() + result = self._workbench.history(record_id) + self._bound() + return [self._tag(state) for state in result] + + def import_text(self, body, expected_checkpoint, request_key): + if type(body) is not dict or set(body) - {'kind', 'text', 'name', 'groups', 'parents', 'rights'} or body.get('kind', 'text') != 'text': + raise ProjectTextError('Only text import is available in M1.', 'unavailable', 409) + return self._mutate('import_text', None, body, expected_checkpoint, request_key) + + def annotate(self, record_id, body, expected_checkpoint, request_key): + _shape(body, {'revision', 'source_revision', 'task', 'annotation', 'groups', 'review'}, 'annotation request') + if body['task'] not in ('text_classification', 'text_entities', 'text_corpus') or body['review'] not in ('draft', 'human_reviewed'): + raise ProjectTextError('This annotation operation is unavailable in M1.', 'unavailable', 409) + return self._mutate('annotate', record_id, body, expected_checkpoint, request_key) + + def _mutate(self, action, record_id, body, expected_checkpoint, request_key): + # Current owner permission cannot be revoked between authorization and + # canonical publication. Match the controller's host-before-projection + # lock order, including standalone callers of this domain adapter. + with self.access.lock: + return self._mutate_authorized(action, record_id, body, expected_checkpoint, request_key) + + def _mutate_authorized(self, action, record_id, body, expected_checkpoint, request_key): + self._authorize('edit', 'workbench.import_asset' if action == 'import_text' else 'workbench.save', record_id) + if type(request_key) is not str or not REQUEST_KEY.fullmatch(request_key): + _invalid('Supply a bounded operation request key.') + body = copy.deepcopy(body) + try: + digest = _sha(encode(dict(action=action, target=record_id, body=body))) + except (ValueError, TypeError, UnicodeError): + _invalid('Project mutation must contain valid finite JSON.') + with self._lock, self.db.lock: + if self.db.in_transaction: + raise ProjectTextError('Project mutation must own its SQL transaction.', 'unavailable', 409) + current = self._bound() + replay = self.store.reconcile(request_key, digest) + if replay is not None: + for envelope in current['records']: + doc = envelope['document'] + for receipt in doc['operations']: + if receipt['request_key'] == request_key and receipt['request_sha256'] == digest: + return self._tag(doc['history'][receipt['revision'] - 1], request_key) + _invalid('Project operation receipt has no text result.') + if expected_checkpoint != self.checkpoint: + raise ProjectTextError('Expected project checkpoint changed.', 'conflict', 409) + committed = None + self._authority.facts = [] + try: + with self.db.transaction(): + result = (self._workbench.import_asset(dict(body, kind='text')) if action == 'import_text' + else self._workbench.save(record_id, body)) + identity = result['id'] + records = copy.deepcopy(current['records']) + envelope = next((item for item in records if item['id'] == identity), None) + if envelope is None: + original = self.db.execute('SELECT original_text FROM workbench_records WHERE id=%s', (identity,)).fetchone()[0] + envelope = dict(family='workbench_text', id=identity, + document=dict(format='workbench_text_v1', original_text=original, current=None, history=[], audit=[], operations=[])) + records.append(envelope) + document = envelope['document'] + document['current'] = result + document['history'].append(result) + document['audit'].extend(self._authority.facts) + document['operations'].append(dict(request_key=request_key, request_sha256=digest, action=action, + target=identity, revision=result['revision'])) + records = decode_records(records) + # Canonical publication is inside the outer SQL transaction, + # so Workbench savepoints cannot acknowledge DB-only work. + committed = self.store.commit(records, self.checkpoint, request_key, digest) + self._snapshot = committed + return self._tag(result, request_key) + except BaseException as error: + if committed is not None: + self._snapshot, self.available = committed, False + failure = ProjectTextError('Canonical edit committed; reconstruct the unavailable SQL projection.', 'projection_unavailable', 503) + failure.committed, failure.project_id = True, committed['project_id'] + failure.checkpoint, failure.request_key = committed['checkpoint'], request_key + failure.receipt = dict(committed=True, selected=True, project_id=committed['project_id'], + checkpoint=committed['checkpoint'], request_key=request_key) + raise failure from error + if getattr(error, 'code', None) in ('unknown', 'outcome_unknown'): + self.available = False + raise + finally: + self._authority.facts = None diff --git a/project_transfer.py b/project_transfer.py new file mode 100644 index 00000000..c7aee05b --- /dev/null +++ b/project_transfer.py @@ -0,0 +1,770 @@ +"""Explicit, report-first extraction of a stopped legacy catalog. + +Preview creates no domain supervisors and writes no source files. Only the +currently owned controller can stop and hand over a live stopped-source lease. +The private backup remains a legacy backup, never a portable authority dump. +""" +import copy +from contextlib import contextmanager +import hashlib +import json +import os +from pathlib import Path +import re +import stat +import threading +import time +import uuid +import weakref + +import psycopg +from psycopg import sql + +from metadata import Metadata, SCHEMA_VERSION, row_factory, _key +from project_store import (DIR_FLAGS, FILE_FLAGS, ProjectStore, + _rename_noreplace, _encode) +from project_artifacts import ProjectArtifacts, validate_declaration +from workbench import WorkbenchError, timestamp + +UUID=re.compile(r'^[a-f0-9]{32}$') +SHA=re.compile(r'^[a-f0-9]{64}$') +ALIAS=re.compile(r'^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$') +ROOT_FAMILIES={ + 'records':'workbench_record','responses':'workbench_response','preferences':'workbench_preference', + 'selections':'saved_selection','searches':'saved_search','graphs':'pipeline_graph', + 'recipe_drafts':'recipe_definition','model_definitions':'model_definition','studies':'study_history', + 'runs':'run_history','releases':'dataset_release','exposures':'dataset_exposure', + 'model_materials':'model_material','recipe_materials':'recipe_material','runtime_materials':'runtime_material'} +NAMESPACED={'pipeline_graph','recipe_definition','model_definition','study_history','trial_history', + 'run_history','candidate_history','model_material','recipe_material','runtime_material','project_snapshot', + 'generation_history','grounded_history','caption_proposal_history','text_proposal_history'} +PRODUCERS={'generation':'generation_history','grounded':'grounded_history', + 'caption':'caption_proposal_history','text_proposal':'text_proposal_history'} +RUNTIME_NAMES=('generation_jobs','grounded_jobs','caption_proposals','text_classification_proposals','workspace_runs') +_LEASE_TOKEN=object() +_ISSUED_STOPPED=weakref.WeakSet() +MAX_SOURCE_FILE=40*1024*1024 + + +def fail(message,code='invalid',status=400):raise WorkbenchError(message,code,status) +def closed(value,fields,label): + if type(value) is not dict or set(value)!=set(fields):fail('Invalid '+label+' fields.') +def digest(raw):return hashlib.sha256(raw).hexdigest() +def canonical(value):return _encode(value) +def sha(value): + if type(value) is not str or not SHA.fullmatch(value):fail('Invalid extraction digest.') +def identity(value): + if type(value) is not str or not UUID.fullmatch(value):fail('Invalid extraction identity.') +def leaf(value): + if type(value) is not str or not 1<=len(value)<=200 or value in ('.','..') or any(c in value for c in '/\\') or any(ord(c)<32 for c in value):fail('Choose one exact destination directory name.') + return value + +def parts(value): + if type(value) is not str or len(value)>512 or '\\' in value or any(ord(c)<32 for c in value):fail('Invalid source-relative path.') + result=value.split('/') + if not 1<=len(result)<=16 or any(p in ('','.','..') for p in result):fail('Source reference must remain inside its configured root.') + return result + + +def _fd_identity(fd): + value=os.fstat(fd) + return value.st_dev,value.st_ino + + +@contextmanager +def _file(root,relative,maximum): + path=parts(relative);parent=os.dup(root);descriptor=None + try: + for name in path[:-1]: + child=os.open(name,DIR_FLAGS,dir_fd=parent);os.close(parent);parent=child + descriptor=os.open(path[-1],FILE_FLAGS,dir_fd=parent) + before=os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode) or before.st_nlink!=1 or before.st_size>maximum:fail('Source payload is unsafe or exceeds its owner bound.','unavailable',409) + yield descriptor,before + after=os.fstat(descriptor);current=os.stat(path[-1],dir_fd=parent,follow_symlinks=False) + key=lambda v:(v.st_dev,v.st_ino,v.st_mode,v.st_nlink,v.st_size,v.st_mtime_ns,v.st_ctime_ns) + if key(before)!=key(after) or key(after)!=key(current):fail('Source payload changed during capture.','stale_source',409) + except OSError:fail('Source payload is missing or unsafe.','unavailable',409) + finally: + if descriptor is not None:os.close(descriptor) + os.close(parent) + + +def _read(root,relative,maximum): + with _file(root,relative,maximum) as (fd,info): + chunks=[];remaining=maximum+1 + while remaining: + raw=os.read(fd,min(65536,remaining)) + if not raw:break + chunks.append(raw);remaining-=len(raw) + result=b''.join(chunks) + if len(result)!=info.st_size or len(result)>maximum:fail('Source payload changed or exceeds its owner bound.','unavailable',409) + return result + + +class ReadonlyCatalog: + """The exact schema proof, without Metadata construction or source writes.""" + _signature=Metadata._signature + validate_schema=Metadata.validate_schema + def __init__(self,dsn,schema,catalog_id,path,*,exclude_peers=False): + identity(catalog_id) + if schema!='catalog_'+catalog_id:fail('Only a current legacy catalog can be extracted.','unsupported',409) + self.dsn,self.schema,self.dataset_id,self.path=dsn,schema,catalog_id,Path(path) + self.schema_version=SCHEMA_VERSION;self.lock=threading.RLock() + self.connection=psycopg.connect(dsn,autocommit=True,row_factory=row_factory) + try: + self.connection.execute(sql.SQL('SET search_path TO {}').format(sql.Identifier(schema))) + self.connection.execute("SET statement_timeout='60000'") + self.connection.execute("SET lock_timeout='1000'") + if exclude_peers: + for key in [_key(schema),*(_key(schema+':worker:'+name) for name in RUNTIME_NAMES)]: + if not self.connection.execute('SELECT pg_try_advisory_lock(%s)',(key,)).fetchone()[0]: + fail('A peer acquired legacy catalog ownership after stop.','busy',409) + self.connection.execute('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY') + self.validate_schema() + except BaseException:self.connection.close();raise + def execute(self,query,parameters=None): + # PostgreSQL READ ONLY is the enforcement boundary, including CTEs. + return self.connection.execute(query,parameters) + @property + def tables(self): + from metadata import tables_for_version + return tables_for_version(self.schema_version) + def close(self):self.connection.close() + + +class CapturedArtifacts: + """Private verified evidence custody; no preview file publication.""" + def __init__(self):self.payloads={};self.declarations={} + def publish_bytes(self,raw,declaration,*,maximum_bytes=None): + declaration=validate_declaration(declaration) + if type(raw) is not bytes or len(raw)!=declaration['bytes'] or digest(raw)!=declaration['sha256']:fail('Captured source bytes differ from their declaration.') + if maximum_bytes is not None and len(raw)>maximum_bytes:fail('Captured source exceeds its owner bound.','too_large',413) + self._add(declaration,raw);return copy.deepcopy(declaration) + def publish_fd(self,descriptor,declaration,*,maximum_bytes=None): + declaration=validate_declaration(declaration);info=os.fstat(descriptor) + if not stat.S_ISREG(info.st_mode) or info.st_size!=declaration['bytes'] or maximum_bytes is not None and info.st_size>maximum_bytes:fail('Captured source descriptor differs.') + hasher=hashlib.sha256();offset=0 + while offsetmaximum_bytes:fail('Captured source bytes are unavailable.','unavailable',409) + payload=self.payloads[key] + raw=payload if type(payload) is bytes else os.pread(payload,value['bytes'],0) + if len(raw)!=value['bytes'] or digest(raw)!=key:fail('Captured source changed.','stale_source',409) + return raw + def status(self,declaration): + value=validate_declaration(declaration);actual=self.declarations.get(value['sha256']);present=value['sha256'] in self.payloads + state='missing' if not present else 'verified' if actual==value else 'corrupt' + return dict(state=state,expected_sha256=value['sha256'],expected_bytes=value['bytes'],observed_sha256=actual['sha256'] if present else None,observed_bytes=actual['bytes'] if present else None) + inspect=status + @contextmanager + def open_verified(self,declaration,*,maximum_bytes=None): + value=validate_declaration(declaration);payload=self.payloads.get(value['sha256']) + if payload is None or self.declarations.get(value['sha256'])!=value:fail('Unique source payload is missing or differs.','unavailable',409) + if maximum_bytes is not None and value['bytes']>maximum_bytes:fail('Captured payload exceeds its owner bound.','too_large',413) + if type(payload) is bytes: + import tempfile + with tempfile.TemporaryFile() as temporary: + temporary.write(payload);temporary.flush();temporary.seek(0) + yield temporary.fileno() + else: + self.publish_fd(payload,value,maximum_bytes=maximum_bytes) + duplicate=os.dup(payload) + try:yield duplicate + finally:os.close(duplicate) + def close(self): + for value in self.payloads.values(): + if type(value) is int:os.close(value) + self.payloads.clear() + + +class SourceView: + def __init__(self,db,root_fd,root_path,roots,*,actor_id,captured_at=None): + self.db,self.root_fd,self.root_path=db,root_fd,Path(root_path) + self.roots=roots;self.actor_id=actor_id;self.captured_at=captured_at or timestamp() + self.artifacts=CapturedArtifacts();self._closed=False;self._payload_locations={};self._authorize=None + self.snapshot=dict(project_id=db.dataset_id,checkpoint='0'*64,records=[]) + self.fingerprint=None;self.backup_receipt=None;self.external_inventory=[];self._observed_roots={} + def require(self): + if self._authorize is not None:self._authorize() + if self._closed or self.db.connection.closed:fail('Source snapshot lease is closed.','unavailable',409) + current=os.open(self.root_path,DIR_FLAGS) + try: + if _fd_identity(current)!=_fd_identity(self.root_fd):fail('Source catalog root changed.','stale_source',409) + finally:os.close(current) + def read_catalog(self,relative,maximum_bytes=MAX_SOURCE_FILE): + self.require();return _read(self.root_fd,relative,maximum_bytes) + def read_root(self,alias,relative,maximum_bytes=MAX_SOURCE_FILE): + self.require();entry=self.roots.get(alias) + if entry is None:fail('An explicit source root binding is required.','unavailable',409) + raw=_read(entry[0],relative,maximum_bytes) + key=(alias,relative);observation=(len(raw),digest(raw)) + if key in self._observed_roots and self._observed_roots[key]!=observation:fail('External source bytes changed after capture.','stale_source',409) + self._observed_roots[key]=observation + fact=dict(root=alias,relative=relative,sha256=observation[1],bytes=observation[0]) + if fact not in self.external_inventory:self.external_inventory.append(fact) + return raw + def resolve_payload(self,sha256,size): + sha(sha256) + if sha256 in self.artifacts.declarations:return self.artifacts.read_blob(dict(sha256=sha256,bytes=size)) + location=self._payload_locations.get(sha256) + if location is None or location['bytes']!=size:fail('An explicit external payload inventory binding is required.','unavailable',409) + raw=self.read_root(location['root'],location['relative'],MAX_SOURCE_FILE) + if len(raw)!=size or digest(raw)!=sha256:fail('External source payload differs from its declaration.') + return raw + @contextmanager + def payload_fd(self,sha256,size): + sha(sha256) + location=self._payload_locations.get(sha256) + if location is None or location['bytes']!=size:fail('An explicit payload descriptor binding is required.','unavailable',409) + with _file(self.roots[location['root']][0],location['relative'],size) as (fd,info): + declaration=dict(format='project_artifact_v1',sha256=sha256,bytes=size) + self.artifacts.publish_fd(fd,declaration,maximum_bytes=size) + yield fd + + def bind_payload(self,sha256,size,root,relative): + # Internal capability owner call, never inferred from a historical path. + self.require();sha(sha256) + if type(size) is not int or not 0<=size<=2**63-1 or root not in self.roots:fail('Invalid external payload binding.') + with _file(self.roots[root][0],relative,max(size,1)) as (fd,info): + if info.st_size!=size:fail('External payload binding differs.') + self.artifacts.publish_fd(fd,dict(format='project_artifact_v1',sha256=sha256,bytes=size),maximum_bytes=size) + self._observed_roots[(root,relative)]=(size,sha256) + self._payload_locations[sha256]=dict(root=root,relative=relative,bytes=size) + fact=dict(root=root,relative=relative,sha256=sha256,bytes=size) + if fact not in self.external_inventory:self.external_inventory.append(fact) + def close(self): + if self._closed:return + self._closed=True;self.artifacts.close();self.db.close();os.close(self.root_fd) + for fd,_ in self.roots.values():os.close(fd) + self.roots.clear() + def __enter__(self):self.require();return self + def __exit__(self,*exc):self.close() + + +class StoppedSourceLease(SourceView): + def __init__(self,*args,_token=None,**kwargs): + if _token is not _LEASE_TOKEN:fail('A stopped source must be issued by its current trusted owner.','forbidden',403) + super().__init__(*args,**kwargs) + def require(self): + super().require() + if self not in _ISSUED_STOPPED or self.backup_receipt is None:fail('Verified stopped backup is unavailable.','unavailable',409) + + +def preview_source(dataset): + """Read a currently opened source without constructing or stopping owners.""" + actor=dataset.access.require('inspect','project_transfer.preview') + root=os.open(dataset.path,DIR_FLAGS);roots={} + try: + for alias,(fd,value) in dataset.projects.roots.items():roots[alias]=(os.dup(fd),list(value)) + view=ReadonlyCatalog(dataset.db.dsn,dataset.db.schema,dataset.db.dataset_id,dataset.path) + source=SourceView(view,root,dataset.path,roots,actor_id=actor.actor_id) + source._authorize=lambda:dataset.access.require('inspect','project_transfer.preview') + return source + except BaseException: + os.close(root) + for fd,_ in roots.values():os.close(fd) + raise + + +def _snapshot(view): + from backup_metadata import read_catalog,read_sequences,summaries + tables=read_catalog(view.db);sequences=read_sequences(view.db) + return tables,sequences,digest(canonical(dict(tables=summaries(tables),sequences=sequences))) + + +def _verified_backup(source,parent_fd,directory): + from backup_metadata import tree_manifest,copy_tree,tree_modes,summaries + leaf(directory) + from project_fork import _inside + if _inside(parent_fd,source.root_fd):fail('Backup cannot overlap its source.','conflict',409) + stage='.tuldok-extraction-backup-'+uuid.uuid4().hex + os.mkdir(stage,0o700,dir_fd=parent_fd) + path=Path('/proc/self/fd')/str(parent_fd)/stage + tables,sequences,fingerprint=_snapshot(source) + before=tree_manifest(source.root_path) + copy_tree(source.root_path,path/'catalog',before) + metadata=dict(format_version=1,schema_version=source.db.schema_version,dataset_id=source.db.dataset_id,tables=tables,sequences=sequences) + from backup_metadata import write_json,file_hash + write_json(path/'metadata.json',metadata) + receipt=dict(format_version=1,kind='postgresql',schema_version=source.db.schema_version,dataset_id=source.db.dataset_id, + postgresql_server_version=source.db.connection.info.server_version,driver='psycopg '+psycopg.__version__, + restore_privileges='CONNECT and CREATE on a new PostgreSQL database; catalog schema must be absent', + metadata_sha256=file_hash(path/'metadata.json'),files=before,tables=summaries(tables),sequences=sequences) + write_json(path/'backup-receipt.json',receipt) + source.require() + if before!=tree_manifest(source.root_path) or before!=tree_manifest(path/'catalog') or tree_modes(source.root_path,before)!=tree_modes(path/'catalog',before):fail('Source changed while capturing its stopped backup.','stale_source',409) + directory_fd=os.open(stage,DIR_FLAGS,dir_fd=parent_fd) + try:os.fsync(directory_fd) + finally:os.close(directory_fd) + _rename_noreplace(parent_fd,stage,directory);os.fsync(parent_fd) + return receipt,fingerprint + + +def stop_source(dataset,*,backup_parent_fd,backup_directory,external_roots=None,controller_guard): + """Only the already running, explicitly gated owner may mint this lease.""" + from app import Dataset, _LegacyExtractionGuard + if not isinstance(dataset,Dataset) or type(controller_guard) is not _LegacyExtractionGuard or dataset._extraction_guard is not controller_guard: + fail('An exclusive current-controller stop guard is required.','forbidden',403) + controller_guard.require(dataset) + actor=dataset.access.require('admin','project_transfer.stop_source',owner_only=True) + leaf(backup_directory) + if not stat.S_ISDIR(os.fstat(backup_parent_fd).st_mode):fail('A backup parent capability is required.') + try:os.stat(backup_directory,dir_fd=backup_parent_fd,follow_symlinks=False) + except FileNotFoundError:pass + else:fail('Stopped backup destination already exists.','conflict',409) + if any(not dataset.db.runtime_owned(name) for name in RUNTIME_NAMES):fail('A peer owns a legacy runtime; stop its actual owner first.','busy',409) + if dataset.runs._blocked or dataset.runs._external_blocked:fail('Legacy worker cessation remains unknown.','unknown',409) + root=os.open(dataset.path,DIR_FLAGS);roots={};source=None + dsn,schema,catalog_id=dataset.db.dsn,dataset.db.schema,dataset.db.dataset_id + try: + for alias,(fd,value) in (external_roots if external_roots is not None else dataset.projects.roots).items(): + if type(alias) is not str or not ALIAS.fullmatch(alias) or list(_fd_identity(fd))!=list(value):fail('Invalid explicit external root capability.') + roots[alias]=(os.dup(fd),list(value)) + with dataset.image_requests.lock:pending=list(dataset.image_requests.active.items()) + for request_id,operation in pending:operation.cancel() + deadline=time.monotonic()+60 + for request_id,operation in pending: + if not operation.done.wait(max(0,deadline-time.monotonic())):fail('An owned image request did not drain.','unknown',409) + controller_guard.require(dataset) + # Existing owner close may change lifecycle observations. The preservation + # baseline is captured afterwards, never before this explicit stop. + dataset.close_for_project_extraction(deadline=deadline) + for owner in (dataset.generation_jobs,dataset.grounded,dataset.caption_proposals,dataset.text_classification_proposals): + worker=getattr(owner,'worker',None) + if worker is not None and worker.is_alive():fail('A legacy producer did not drain.','unknown',409) + if dataset.runs._blocked or dataset.runs._external_blocked:fail('Legacy process cessation is unknown.','unknown',409) + with dataset.image_requests.lock: + if dataset.image_requests.active:fail('An image request remains active.','unknown',409) + controller_guard.require(dataset) + db=ReadonlyCatalog(dsn,schema,catalog_id,dataset.path,exclude_peers=True) + # Base view permits backup verification before the stopped lease is minted. + source=SourceView(db,root,dataset.path,roots,actor_id=actor.actor_id) + import recipe_process + for row in db.execute('SELECT document FROM workspace_runs'): + run=json.loads(row[0]);custody=run.get('custody_identity') + if custody is None:continue + if run.get('state')=='completed': + raw=source.read_catalog('workspace-runs/'+run['id']+'/custody-receipt.json',16384) + receipt=recipe_process.validate_receipt(raw,custody) + else:receipt=dataset.runs._external_drain_receipt(run) + if receipt is None or not receipt['drained']:fail('An external custodian has no exact drained receipt.','unknown',409) + receipt,fingerprint=_verified_backup(source,backup_parent_fd,backup_directory) + source.backup_receipt=receipt;source.fingerprint=fingerprint;source.snapshot['checkpoint']=fingerprint + source.__class__=StoppedSourceLease + _ISSUED_STOPPED.add(source) + source.require() + return source + except BaseException: + if source is not None:source.close() + else: + os.close(root) + for fd,_ in roots.values():os.close(fd) + raise + + +def normalize_selection(selection): + closed(selection,{'project_snapshot','roots','decisions'},'extraction selection') + snapshot=selection['project_snapshot'];closed(snapshot,{'id','manifest_sha256'},'selected project snapshot') + identity(snapshot['id']);sha(snapshot['manifest_sha256']) + roots=selection['roots'];known=set(ROOT_FAMILIES)|{'producer_jobs','working_files'} + if type(roots) is not dict or set(roots)-known:fail('Unsupported extraction root family.') + normalized={} + for name in ROOT_FAMILIES: + values=roots.get(name,[]) + if type(values) is not list or len(values)>10000 or any(type(v) is not str for v in values) or len(set(values))!=len(values):fail('Extraction roots must be bounded unique identifiers.') + for value in values: + sha(value) if name in ('releases','model_materials','recipe_materials','runtime_materials') else identity(value) + normalized[name]=sorted(values) + jobs=roots.get('producer_jobs',[]) + if type(jobs) is not list or len(jobs)>10000:fail('Invalid producer selection.') + seen=set();normalized_jobs=[] + for value in jobs: + closed(value,{'kind','id'},'producer selection');identity(value['id']) + if value['kind'] not in PRODUCERS or (value['kind'],value['id']) in seen:fail('Unsupported or duplicate producer root.') + seen.add((value['kind'],value['id']));normalized_jobs.append(copy.deepcopy(value)) + normalized['producer_jobs']=sorted(normalized_jobs,key=lambda v:(v['kind'],v['id'])) + files=roots.get('working_files',[]) + if type(files) is not list or len(files)>1000:fail('Invalid working-file selection.') + from project_files import _path + seen=set();normalized_files=[] + for value in files: + closed(value,{'kind','path'},'working-file selection');_path(value['kind'],value['path']) + if (value['kind'],value['path']) in seen:fail('Duplicate working-file selection.') + seen.add((value['kind'],value['path']));normalized_files.append(copy.deepcopy(value)) + normalized['working_files']=sorted(normalized_files,key=lambda v:(v['kind'],v['path'])) + decisions=selection['decisions'] + if type(decisions) is not list or len(decisions)>10000:fail('Invalid ownership decisions.') + allowed=set(ROOT_FAMILIES.values())|NAMESPACED|{'project_artifact','project_file','trial_history','candidate_history'} + fixed=[];seen=set() + for value in decisions: + closed(value,{'family','id','include','reason'},'ownership decision') + if value['family'] not in allowed or type(value['id']) is not str or not 1<=len(value['id'])<=128 or type(value['include']) is not bool or type(value['reason']) is not str or not 1<=len(value['reason'])<=1000 or (value['family'],value['id']) in seen:fail('Invalid explicit ownership decision.') + seen.add((value['family'],value['id']));fixed.append(copy.deepcopy(value)) + return dict(project_snapshot=copy.deepcopy(snapshot),roots=normalized,decisions=sorted(fixed,key=lambda v:(v['family'],v['id']))) + + +def _seeds(selection,catalog_id): + selected={('project_snapshot',catalog_id+'.'+selection['project_snapshot']['manifest_sha256'])} + for root,family in ROOT_FAMILIES.items(): + for value in selection['roots'][root]:selected.add((family,catalog_id+'.'+value if family in NAMESPACED else value)) + for value in selection['roots']['producer_jobs']:selected.add((PRODUCERS[value['kind']],catalog_id+'.'+value['id'])) + from project_files import file_id + for value in selection['roots']['working_files']:selected.add(('project_file',file_id(catalog_id,value['kind'],value['path']))) + for value in selection['decisions']: + if value['include']:selected.add((value['family'],value['id'])) + return selected + + +def _capture(source,selection): + from project_extraction import capture_legacy + captured=capture_legacy(source,selection) + closed(captured,{'records','inventory','dependencies','blockers','working_files','warnings'},'legacy capture') + for field in captured: + if type(captured[field]) is not list:fail('Legacy capture fields must be finite lists.') + return captured + + +def _validate_candidate(records,project_id,artifacts): + from project_projection import ProjectProjection + from types import SimpleNamespace + ProjectProjection.validate_records(SimpleNamespace(project_id=project_id,artifacts=artifacts),records,project_id=project_id) + + +class ProjectTransfer: + def __init__(self,source): + if not isinstance(source,SourceView):fail('A pinned read-only source capability is required.','forbidden',403) + self.source=source;self._reports={};self.lock=threading.RLock() + + def extract(self,selection,expected_report_sha256,destination_parent_fd,directory,name,request_key,*,opened_store=None): + with self.lock:return _extract(self,selection,expected_report_sha256,destination_parent_fd,directory,name,request_key,opened_store=opened_store) + + def preview(self,selection): + with self.lock:return self._preview(selection) + + def _preview(self,selection): + source=self.source;source.require();selection=normalize_selection(selection) + if source.fingerprint is None: + _,_,source.fingerprint=_snapshot(source);source.snapshot['checkpoint']=source.fingerprint + captured=_capture(source,selection) + inventory={} + for item in captured['inventory']: + closed(item,{'family','id','revision'},'legacy inventory item') + key=item['family'],item['id'] + if key in inventory:fail('Legacy inventory repeats an identity.') + inventory[key]=item + required=_seeds(selection,source.db.dataset_id);direct=set(required);edges={} + for edge in captured['dependencies']: + closed(edge,{'source','target','reason'},'legacy dependency') + for field in ('source','target'):closed(edge[field],{'family','id'},'dependency locator') + edges.setdefault((edge['source']['family'],edge['source']['id']),[]).append((edge['target']['family'],edge['target']['id'])) + pending=list(required) + while pending: + for key in edges.get(pending.pop(),[]): + if key not in required:required.add(key);pending.append(key) + excluded={(v['family'],v['id']) for v in selection['decisions'] if not v['include']} + blockers=[copy.deepcopy(v) for v in captured['blockers'] if (v['family'],v['id']) in required] + for family,record_id in sorted(required-inventory.keys()):blockers.append(dict(family=family,id=record_id,code='missing_dependency',detail='Selected or required source fact is unavailable.')) + for family,record_id in sorted(required&excluded):blockers.append(dict(family=family,id=record_id,code='required_dependency_excluded',detail='Required closure cannot be excluded.')) + declared={(r['family'],r['id']):r for r in captured['records']} + records=[copy.deepcopy(declared[k]) for k in sorted(required) if k in declared] + artifact_report=[] + artifact_policies={} + for edge in captured['dependencies']: + target=(edge['target']['family'],edge['target']['id']) + parent=(edge['source']['family'],edge['source']['id']) + if parent in required and target[0]=='project_artifact': + policy='regenerable' if edge['reason'].startswith('regenerable:') else 'unique' + if artifact_policies.get(target[1])!='unique':artifact_policies[target[1]]=policy + for record in records: + if record['family']=='project_artifact': + state=source.artifacts.status(record['document'])['state'] + preservation=artifact_policies.get(record['id'],'unique') + artifact_report.append(dict(sha256=record['id'],bytes=record['document']['bytes'],preservation=preservation,state=state)) + if state!='verified' and preservation=='unique':blockers.append(dict(family='project_artifact',id=record['id'],code='missing_unique_payload',detail='Original required bytes cannot be regenerated during extraction.')) + file_observations=[] + from project_files import file_id + for value in captured['working_files']: + closed(value,{'kind','path','sha256','bytes','text'},'observed working file') + if ('project_file',file_id(source.db.dataset_id,value['kind'],value['path'])) in required:file_observations.append(copy.deepcopy(value)) + if not isinstance(source,StoppedSourceLease):blockers.append(dict(family='source',id=source.db.dataset_id,code='cessation_proof_unavailable',detail='The current trusted owner must explicitly stop and capture this source.')) + if not blockers: + try:_validate_candidate(records,source.db.dataset_id,source.artifacts) + except WorkbenchError as error:blockers.append(dict(family='closure',id=source.db.dataset_id,code=getattr(error,'code','invalid'),detail=str(error))) + report=dict(format='project_transfer_report_v1',source=dict(catalog_id=source.db.dataset_id,schema_version=source.db.schema_version, + catalog_signature=source.db._signature(),snapshot_sha256=source.fingerprint, + backup_receipt_sha256=digest(canonical(source.backup_receipt)) if source.backup_receipt is not None else None, + external_inventory_sha256=digest(canonical(sorted(source.external_inventory,key=lambda v:(v['root'],v['relative']))))), + selection=selection,included=[copy.deepcopy(inventory[k]) for k in sorted(required&inventory.keys())], + required=[dict(family=f,id=i) for f,i in sorted(required-direct)], + unassigned=[copy.deepcopy(inventory[k]) for k in sorted(inventory.keys()-required-excluded)], + excluded=[dict(family=f,id=i) for f,i in sorted(excluded)],blockers=blockers, + warnings=[copy.deepcopy(v) for v in captured['warnings'] if (v['family'],v['id']) in required], + artifacts=artifact_report,working_files=[{k:v[k] for k in ('kind','path','sha256','bytes')} for v in file_observations],can_extract=not blockers) + report_hash=digest(canonical(report));report['report_sha256']=report_hash + self._reports[report_hash]=dict(report=copy.deepcopy(report),records=records,working_files=file_observations) + source.require();return report + + +FAMILY='project_extraction' +FORMAT='project_extraction_v1' +SOURCE_FIELDS={'catalog_id','schema_version','catalog_signature','backup_receipt_sha256','external_inventory_sha256','snapshot_sha256'} + + +def decode_records(records): + from project_store import SUPPORTED_FORMATS + from project_files import file_id + indexed={(r['family'],r['id']):r for r in records} + counts={} + for r in records:counts[r['family']]=counts.get(r['family'],0)+1 + seen=set();result=[] + for envelope in records: + if envelope.get('family')!=FAMILY:continue + closed(envelope,{'family','id','document'},'extraction envelope');doc=envelope['document'] + closed(doc,{'format','source','selection','report_sha256','request_key','input_sha256','destination_project_id','working_files','counts','artifacts'},'extraction manifest') + if doc['format']!=FORMAT:fail('Unsupported extraction manifest format.','unsupported',409) + identity(envelope['id']);identity(doc['destination_project_id']) + if envelope['id']!=doc['destination_project_id'] or envelope['id'] in seen:fail('Duplicate or mismatched extraction identity.') + seen.add(envelope['id']);source=doc['source'];closed(source,SOURCE_FIELDS,'extraction source') + identity(source['catalog_id']) + if source['catalog_id']==doc['destination_project_id'] or type(source['schema_version']) is not int or source['schema_version']!=SCHEMA_VERSION:fail('Extraction source namespace or schema differs.') + for field in SOURCE_FIELDS-{'catalog_id','schema_version'}:sha(source[field]) + sha(doc['report_sha256']);sha(doc['input_sha256']) + overlay=indexed.get(('project_fork',doc['destination_project_id'])) + if overlay is None:fail('Extraction lacks its independent identity overlay.') + overlay=overlay['document'] + if overlay['source']!=dict(project_id=source['catalog_id'],checkpoint=source['snapshot_sha256']) or overlay['request_key']!=doc['request_key'] or overlay['artifacts']!=doc['artifacts']:fail('Extraction manifest differs from its retained identity transition.') + if digest(canonical(_input(source,doc['selection'],doc['report_sha256'],overlay['directory'],overlay['name'],doc['request_key'])))!=doc['input_sha256']:fail('Extraction input digest differs from its exact publication request.') + registered=indexed.get(('project_snapshot',source['catalog_id']+'.'+doc['selection']['project_snapshot']['manifest_sha256'])) + if registered is None or registered['document']['value']['id']!=doc['selection']['project_snapshot']['id']:fail('Extraction lacks its exact selected registered snapshot.') + if normalize_selection(doc['selection'])!=doc['selection']:fail('Extraction selection is not normalized.') + from project_store import REQUEST_KEY + if type(doc['request_key']) is not str or not REQUEST_KEY.fullmatch(doc['request_key']):fail('Invalid extraction request key.') + if type(doc['counts']) is not dict or any(f not in SUPPORTED_FORMATS or type(n) is not int or n<1 or counts.get(f,0)200:fail('Invalid extraction request identity.') + report=self.preview(selection) + if report['report_sha256']!=expected_report_sha256:fail('The extraction report changed; review the exact source closure again.','stale_source',409) + if not report['can_extract']: + error=WorkbenchError('Selected legacy project has unresolved extraction blockers.','extraction_blocked',409);error.report=report;raise error + cache=self._reports[expected_report_sha256];records=copy.deepcopy(cache['records']) + _assert_unchanged(source) + source_ref=report['source'];request_digest=digest(canonical(_input(source_ref,selection,expected_report_sha256,directory,name,request_key))) + from project_fork import (_inside,ARCHIVE_FAMILIES,_digest as fork_digest,_input as fork_input) + if _inside(destination_parent_fd,source.root_fd):fail('Extraction destination cannot overlap its source.','conflict',409) + root_path=Path(os.readlink('/proc/self/fd/'+str(destination_parent_fd))) + try:existing=os.stat(directory,dir_fd=destination_parent_fd,follow_symlinks=False) + except FileNotFoundError:existing=None + def replay(opened): + snapshot=opened.reconcile(request_key,request_digest) + if snapshot is None:fail('Extraction destination belongs to another request.','conflict',409) + manifest=next((r['document'] for r in snapshot['records'] if r['family']==FAMILY and r['id']==snapshot['project_id']),None) + if manifest is None or manifest['input_sha256']!=request_digest:fail('Extraction receipt is unavailable or differs.') + with ProjectArtifacts(opened) as current_artifacts: + _validate_candidate(snapshot['records'],snapshot['project_id'],current_artifacts) + verification=_cold_verify(opened,source);_assert_unchanged(source) + return _receipt(snapshot,manifest,verification,replayed=True) + if opened_store is not None: + # Trusted controller-only capability. Its caller pins workspace, + # authority, selected session and SQL ownership through this replay. + if not isinstance(opened_store,ProjectStore) or existing is None: + fail('Current selected destination is unavailable.','stale',409) + with opened_store.root_directory() as owned_fd: + try:target_fd=os.open(directory,DIR_FLAGS,dir_fd=destination_parent_fd) + except OSError:fail('Selected destination binding changed.','stale',409) + try: + if _fd_identity(target_fd)!=_fd_identity(owned_fd):fail('Selected store differs from the extraction destination.','conflict',409) + finally:os.close(target_fd) + return replay(opened_store) + if existing is not None: + with ProjectStore.open_at(destination_parent_fd,directory,root_path=root_path) as opened: + return replay(opened) + stage_name='.tuldok-extraction-'+uuid.uuid4().hex + store=None;artifacts=None;committed=None;published=False;stage_identity=None + try: + store=ProjectStore.create_at(destination_parent_fd,stage_name,name,'extraction-stage.'+digest(request_key.encode())[:32],request_digest,root_path=root_path) + with store.root_directory() as owned_fd:stage_identity=_fd_identity(owned_fd) + destination=store.snapshot()['project_id'];created_at=timestamp() + from project_data_fork import fork_records as fork_data + from project_material_fork import fork_records as fork_material + original=copy.deepcopy(records);mappings=[] + allocator=lambda *args:uuid.uuid4().hex + for builder in (fork_data,fork_material): + made=builder(original,source.db.dataset_id,source.fingerprint,destination,id_factory=allocator,created_at=created_at) + records.extend(made['records']);mappings.extend(made['mapping']) + working=[] + from project_files import file_id,materialize_captured + for observed in cache['working_files']: + file_identity=file_id(destination,observed['kind'],observed['path']) + version=dict(revision=1,sha256=observed['sha256'],bytes=observed['bytes'],text=observed['text'],adopted_at=created_at,actor_id=source.actor_id) + records.append(dict(family='project_file',id=file_identity,document=dict(format='project_file_v1',project_id=destination,kind=observed['kind'],path=observed['path'],current=copy.deepcopy(version),history=[version],origin=None))) + working.append(dict(kind=observed['kind'],path=observed['path'],sha256=observed['sha256'],bytes=observed['bytes'],source_catalog_id=source.db.dataset_id,source_snapshot_sha256=source.fingerprint,destination_id=file_identity,adopted_at=created_at,actor_id=source.actor_id)) + declarations={r['id']:r['document'] for r in records if r['family']=='project_artifact'} + copied=sorted(v['sha256'] for v in report['artifacts'] if v['state']=='verified') + missing_regenerable=sorted(v['sha256'] for v in report['artifacts'] if v['state']!='verified' and v['preservation']=='regenerable') + fork_source=dict(project_id=source.db.dataset_id,checkpoint=source.fingerprint) + overlay=dict(format='project_fork_v1',source=fork_source,destination_project_id=destination, + request_key=request_key,request_sha256=fork_digest(fork_input(fork_source,directory,name,request_key)),directory=directory,name=name, + mapping=sorted(mappings,key=lambda m:(m['family'],m['source_id'],m['source_revision'] or 0,m['destination_id'])), + archived=sorted([dict(family=r['family'],id=r['id']) for r in original if r['family'] in ARCHIVE_FAMILIES],key=lambda v:(v['family'],v['id'])), + artifacts=dict(copied=copied,missing_regenerable=missing_regenerable)) + records.append(dict(family='project_fork',id=destination,document=overlay)) + counts={FAMILY:1} + for r in records:counts[r['family']]=counts.get(r['family'],0)+1 + manifest=dict(format=FORMAT,source=copy.deepcopy(source_ref),selection=selection,report_sha256=expected_report_sha256,request_key=request_key,input_sha256=request_digest,destination_project_id=destination,working_files=working,counts=counts,artifacts=dict(copied=copied,missing_regenerable=missing_regenerable)) + records.append(dict(family=FAMILY,id=destination,document=manifest)) + artifacts=ProjectArtifacts(store) + for value in declarations.values(): + if value['sha256'] in missing_regenerable:continue + with source.artifacts.open_verified(value,maximum_bytes=value['bytes']) as fd:artifacts.publish_fd(fd,value,maximum_bytes=value['bytes']) + with store.root_directory() as fd:materialize_captured(records,destination,fd) + _validate_candidate(records,destination,artifacts) + committed=store.commit(records,store.snapshot()['checkpoint'],request_key,request_digest) + verification=_cold_verify(store,source) + store.save(committed['checkpoint']);_assert_unchanged(source) + _rename_noreplace(destination_parent_fd,stage_name,directory);published=True;os.fsync(destination_parent_fd) + _assert_unchanged(source) + return _receipt(committed,manifest,verification) + except BaseException as error: + if not published and store is not None and committed is not None: + try: + final=os.stat(directory,dir_fd=destination_parent_fd,follow_symlinks=False) + published=(final.st_dev,final.st_ino)==stage_identity + except FileNotFoundError:pass + except OSError: + failure=WorkbenchError('Extraction publication outcome cannot be inspected.','unknown',503) + failure.staging_directory=stage_name + raise failure from error + if published: + failure=WorkbenchError('Extraction was published; acknowledgement requires reconciliation.','unknown',503) + failure.receipt=dict(committed=True,selected=False,project_id=committed['project_id'],checkpoint=committed['checkpoint'],request_key=request_key,directory=directory) + raise failure from error + error.staging_directory=stage_name + raise + finally: + if artifacts is not None:artifacts.close() + if store is not None:store.close() diff --git a/project_transfer_controller.py b/project_transfer_controller.py new file mode 100644 index 00000000..7362e0ea --- /dev/null +++ b/project_transfer_controller.py @@ -0,0 +1,309 @@ +"""Explicit process-local legacy extraction and independent workspace selection. + +The live Dataset is supplied by its existing controller. Stopping never imports +its credentials into the separate control host. Only that Dataset can issue a +StoppedSourceLease; requests carry a controller binding and report hashes, never +serialized cessation evidence or filesystem descriptors. +""" +import copy +from contextlib import ExitStack +import hashlib +import os +from pathlib import Path +import re +import threading +import uuid + +from project_store import _open_directory_path, _verify_origin, REQUEST_KEY +from project_transfer import ProjectTransfer, preview_source, normalize_selection, canonical, leaf, sha +from project_fork import _inside +from workbench import WorkbenchError + +SECTIONS = frozenset(('included', 'required', 'unassigned', 'excluded', 'blockers', 'warnings', 'artifacts', 'working_files')) +OPERATIONS = frozenset('project_transfer.' + name for name in ('status', 'preview', 'report_page', 'stop', 'extract')) +MAX_RESPONSE = 1024 * 1024 + + +def fail(message, code='invalid', status=400): + raise WorkbenchError(message, code, status) + + +def _identity(descriptor): + value = os.fstat(descriptor) + return value.st_dev, value.st_ino + + +def _bounded(value): + if len(canonical(value)) > MAX_RESPONSE: + fail('Extraction controller response exceeds its bounded interface.', 'too_large', 413) + return value + + +def _receipt_summary(value): + if value.get('format') != 'project_extraction_receipt_v1': + fields = ('committed', 'selected', 'project_id', 'checkpoint', 'request_key', 'directory') + return {key: copy.deepcopy(value[key]) for key in fields if key in value} + return dict(format='project_transfer_receipt_v1', committed=value['committed'], + project_id=value['project_id'], checkpoint=value['checkpoint'], request_key=value['request_key'], + input_sha256=value['input_sha256'], report_sha256=value['report_sha256'], source=copy.deepcopy(value['source']), + artifacts=dict(copied_count=len(value['artifacts']['copied']), missing_regenerable_count=len(value['artifacts']['missing_regenerable'])), + verification=copy.deepcopy(value['verification']), replayed=value['replayed']) + + +class ProjectTransferController: + def __init__(self, dataset, control_workspace, roots=None): + from app import Dataset + from project_workspace import ProjectWorkspace + if not isinstance(dataset, Dataset) or not isinstance(control_workspace, ProjectWorkspace): + fail('Extraction requires an existing legacy controller and a separate control workspace.', 'forbidden', 403) + if dataset.db.dataset_id == control_workspace.db.dataset_id or dataset.db is control_workspace.db: + fail('The control host must have independent current local authority.', 'forbidden', 403) + self.dataset, self.workspace = dataset, control_workspace + self._lock = threading.RLock() + self._binding = uuid.uuid4().hex + self._phase = 'source_live' + self._closed = False + self._roots = {} + self._source = self._transfer = self._report = self._selection = None + self._backup = self._destination = self._last_receipt = self._failure = None + configured = roots if roots is not None else {alias: value[0] for alias, value in control_workspace._roots.items()} + try: + if type(configured) is not dict or not 1 <= len(configured) <= 100: + fail('Configure one to 100 extraction publication roots.') + for alias, path in configured.items(): + if type(alias) is not str or not re.fullmatch('[A-Za-z0-9][A-Za-z0-9_.-]{0,63}', alias): + fail('Invalid extraction publication root alias.') + absolute, descriptor = _open_directory_path(path) + self._roots[alias] = (Path(absolute), descriptor) + # Publication parents may contain the source as a sibling, but may + # never be inside it. Nonexistent leaf targets are checked at Stop. + _, source_fd = _open_directory_path(dataset.path) + try: + if any(_inside(descriptor, source_fd) for _, descriptor in self._roots.values()): + fail('Extraction publication roots cannot be inside the source catalog.', 'conflict', 409) + finally: + os.close(source_fd) + except BaseException: + self.close() + raise + + @property + def control_workspace(self): + return self.workspace + + @property + def phase(self): + """Trusted root dispatch uses this only to choose request authentication.""" + return self._phase + + @property + def access(self): + return self.dataset.access if self._phase == 'source_live' else self.workspace.access + + def _authorize(self, action): + if self._closed: + fail('Extraction controller is closed.', 'unavailable', 503) + self.access.require('admin', 'project_transfer.' + action, owner_only=True) + + def _bound(self, binding): + if type(binding) is not str or binding != self._binding: + fail('Extraction controller changed. Refresh its process binding.', 'stale', 409) + + def _root(self, alias): + if type(alias) is not str or alias not in self._roots: + fail('Choose a configured extraction publication root.') + path, descriptor = self._roots[alias] + _verify_origin(descriptor, path) + return descriptor + + def _target(self, root, directory, *, may_exist=False, selectable=False): + leaf(directory) + descriptor = self._root(root) + if selectable: + configured = self.workspace._root(root, directory) + if _identity(configured[1]) != _identity(descriptor): + fail('The destination root must also be configured in the control workspace.', 'conflict', 409) + try: + os.stat(directory, dir_fd=descriptor, follow_symlinks=False) + except FileNotFoundError: + pass + else: + if not may_exist: + fail('Extraction publication destination already exists.', 'conflict', 409) + return descriptor + + def _summary(self): + if self._report is None: + return None + return dict(report_sha256=self._report['report_sha256'], + selection_sha256=hashlib.sha256(canonical(self._selection)).hexdigest(), + source=copy.deepcopy(self._report['source']), can_extract=self._report['can_extract'], + counts={section: len(self._report[section]) for section in sorted(SECTIONS)}) + + def _state(self): + actions = ['status'] + if self._phase in ('source_live', 'source_stopped'): + actions += ['preview', 'report_page'] + if self._phase == 'source_live' and self._report is not None: + actions.append('stop') + if self._phase == 'source_stopped' and self._report is not None: + actions.append('extract') + return _bounded(dict(format='project_transfer_state_v1', binding=self._binding, + phase=self._phase, source_catalog_id=self.dataset.db.dataset_id, + control_catalog_id=self.workspace.db.dataset_id, roots=sorted(self._roots), + available_actions=actions, report=self._summary(), backup=copy.deepcopy(self._backup), + destination=copy.deepcopy(self._destination), failure=copy.deepcopy(self._failure), + last_receipt=copy.deepcopy(self._last_receipt))) + + def status(self): + with self._lock: + self._authorize('status') + return self._state() + + def preview(self, binding, selection): + with self._lock: + self._authorize('preview');self._bound(binding) + if self._phase not in ('source_live', 'source_stopped'): + fail('The original controller remains fenced after an unsuccessful stop.', 'source_fenced', 409) + normalized = normalize_selection(selection) + if len(canonical(normalized)) > MAX_RESPONSE: + fail('Extraction selection exceeds the request bound.', 'too_large', 413) + if self._phase == 'source_live': + if self._source is not None: + self._source.close() + self._source = self._transfer = self._report = self._selection = None + self._source = preview_source(self.dataset) + # Retain only the reviewed report, rather than accumulating a + # stopped transfer's full report cache across selection changes. + self._selection = self._report = None + self._transfer = ProjectTransfer(self._source) + report = self._transfer.preview(normalized) + self._selection, self._report = normalized, report + self._failure = None + return self._state() + + def report_page(self, binding, report_sha256, section, offset=0, limit=40): + with self._lock: + self._authorize('report_page');self._bound(binding);sha(report_sha256) + if self._report is None or report_sha256 != self._report['report_sha256']: + fail('The extraction report changed. Review the current report.', 'stale', 409) + if type(section) is not str or section not in SECTIONS or type(offset) is not int or not 0 <= offset <= 1000000 or type(limit) is not int or not 1 <= limit <= 40: + fail('Choose a known report section and bounded page.') + values = self._report[section] + return _bounded(dict(format='project_transfer_report_page_v1', binding=self._binding, + report_sha256=report_sha256, section=section, items=copy.deepcopy(values[offset:offset + limit]), + offset=offset, limit=limit, total=len(values))) + + def stop(self, binding, report_sha256, backup_root, backup_directory, destination_root, directory): + with self._lock: + self._authorize('stop');self._bound(binding);sha(report_sha256) + if self._phase != 'source_live' or self._report is None or self._report['report_sha256'] != report_sha256: + fail('Review this live source before its explicit stop.', 'stale', 409) + backup_fd = self._target(backup_root, backup_directory) + destination_fd = self._target(destination_root, directory, selectable=True) + if _identity(backup_fd) == _identity(destination_fd) and backup_directory == directory: + fail('Stopped backup and extracted project require different destinations.', 'conflict', 409) + if self._source is not None: + self._source.close();self._source = self._transfer = None + # After any stop attempt, source authentication may be closed and + # ordinary legacy routes remain fenced. No DTO can mint a lease. + self._phase = 'source_fenced';self._binding = uuid.uuid4().hex + self._report = None + try: + source = self.dataset.stop_for_project_extraction(backup_fd, backup_directory) + except BaseException as error: + # Native authorization can fail before it installs the source + # request fence. Do not claim a stopped/fenced source or switch + # authentication in that no-effect case. + if not self.dataset._extraction_stopping: + self._phase = 'source_live' + self._failure = dict(action='stop', code=getattr(error, 'code', 'unknown'), detail=str(error)[:1000]) + raise + self._source, self._transfer = source, ProjectTransfer(source) + self._phase = 'source_stopped' + self._backup = dict(root=backup_root, directory=backup_directory, + receipt_sha256=hashlib.sha256(canonical(source.backup_receipt)).hexdigest()) + self._destination = dict(root=destination_root, directory=directory) + self._failure = None + return self._state() + + def extract(self, binding, report_sha256, destination_root, directory, name, request_key): + with self._lock: + self._authorize('extract');self._bound(binding);sha(report_sha256) + if self._phase != 'source_stopped' or self._transfer is None: + fail('A trusted stopped source is required before extraction.', 'source_fenced', 409) + if self._report is None or report_sha256 != self._report['report_sha256']: + fail('Review the exact stopped-source report before extraction.', 'stale', 409) + if type(name) is not str or not name.strip() or len(name) > 200 or type(request_key) is not str or not REQUEST_KEY.fullmatch(request_key): + fail('Choose an extraction name and exact request key.') + destination_fd = self._target(destination_root, directory, may_exist=True, selectable=True) + if self._backup is not None and _identity(destination_fd) == _identity(self._root(self._backup['root'])) and directory == self._backup['directory']: + fail('Extraction cannot replace its stopped backup.', 'conflict', 409) + # Pin selection before authority, matching Workspace's lock order. + # An already selected destination owns its Store writer lock; only + # its actual pinned Store can reconcile that exact destination. + try: + with self.workspace._mutex, self.workspace.access.lock, ExitStack() as guards: + self._authorize('extract') + opened = None + if self.workspace._selection == (destination_root, directory) and self.workspace._store is not None: + projection = self.workspace._projection + if projection is None or self.workspace._recovery is not None: + fail('Recover the selected workspace before reconciling extraction.', 'projection_unavailable', 503) + guards.enter_context(projection.session.lock) + guards.enter_context(projection.db.lock) + projection.session.check() + opened = self.workspace._store + receipt = self._transfer.extract(self._selection, report_sha256, destination_fd, directory, name, request_key, opened_store=opened) + except BaseException as error: + self._failure = dict(action='extract', code=getattr(error, 'code', 'unknown'), detail=str(error)[:1000]) + if getattr(error, 'receipt', None) is not None: + self._last_receipt = _receipt_summary(error.receipt) + if getattr(error, 'staging_directory', None) is not None: + self._failure['staging_directory'] = error.staging_directory + raise + self._last_receipt = _receipt_summary(receipt) + self._destination = dict(root=destination_root, directory=directory) + self._failure = None + selected, state, selection_error = False, None, None + try: + state = self.workspace.dispatch('project_files.status', {}) + arguments = dict(binding=state['binding'], expected_checkpoint=state['project']['checkpoint'] if state['project'] else None, + root=destination_root, directory=directory, request_key='extract-open.' + hashlib.sha256(request_key.encode()).hexdigest()[:32]) + state = self.workspace.dispatch('project_files.open', arguments) + if state['project'] is None or state['project']['project_id'] != receipt['project_id']: + fail('Workspace selected a different project.', 'project_selection_unavailable', 503) + selected = True + except BaseException as error: + selection_error = dict(code=getattr(error, 'code', 'unknown'), detail=str(error)[:1000]) + return _bounded(dict(format='project_transfer_extraction_result_v1', binding=self._binding, + receipt=_receipt_summary(receipt), selection=dict(selected=selected, state=state, error=selection_error))) + + def dispatch(self, name, arguments): + """The root transport admits only these five named, closed methods.""" + fields = { + 'project_transfer.status': set(), + 'project_transfer.preview': {'binding', 'selection'}, + 'project_transfer.report_page': {'binding', 'report_sha256', 'section', 'offset', 'limit'}, + 'project_transfer.stop': {'binding', 'report_sha256', 'backup_root', 'backup_directory', 'destination_root', 'directory'}, + 'project_transfer.extract': {'binding', 'report_sha256', 'destination_root', 'directory', 'name', 'request_key'}} + if type(name) is not str or name not in OPERATIONS or type(arguments) is not dict or set(arguments) - fields[name]: + fail('Unsupported extraction controller action.', 'unsupported', 409) + required = fields[name] - ({'offset', 'limit'} if name == 'project_transfer.report_page' else set()) + if not required <= set(arguments): + fail('Missing extraction controller arguments.') + if name == 'project_transfer.status':return self.status() + if name == 'project_transfer.preview':return self.preview(arguments['binding'], arguments['selection']) + if name == 'project_transfer.report_page':return self.report_page(arguments['binding'], arguments['report_sha256'], arguments['section'], arguments.get('offset', 0), arguments.get('limit', 40)) + if name == 'project_transfer.stop':return self.stop(arguments['binding'], arguments['report_sha256'], arguments['backup_root'], arguments['backup_directory'], arguments['destination_root'], arguments['directory']) + return self.extract(arguments['binding'], arguments['report_sha256'], arguments['destination_root'], arguments['directory'], arguments['name'], arguments['request_key']) + + def close(self): + with self._lock: + if self._closed:return + self._closed = True + try: + if self._source is not None:self._source.close() + finally: + for _, descriptor in self._roots.values():os.close(descriptor) + self._roots.clear() diff --git a/project_workspace.py b/project_workspace.py new file mode 100644 index 00000000..83f9597b --- /dev/null +++ b/project_workspace.py @@ -0,0 +1,679 @@ +"""One inert external-project workspace and its installation-local authority. + +Only explicit project-files operations enter this owner. Legacy domain routes +are unavailable in this mode until their canonical publication is implemented. +The installation catalog holds local credentials; it is never portable content. +""" +import copy +import hashlib +import json +import os +from pathlib import Path +import re +import threading +import uuid + +from access import Access +import agent_api +from metadata import Metadata +from project_store import ProjectStore, ProjectStoreError +from project_projection import ProjectProjection +from project_custody import ProjectCustody +from workbench import WorkbenchError + + +PUBLIC_RECORD_FIELDS = ('id', 'kind', 'name', 'text', 'groups', 'parents', 'provenance', + 'task', 'annotation', 'review', 'revision', 'source_revision', + 'created_at', 'updated_at') +SUMMARY_FIELDS = ('id', 'kind', 'name', 'task', 'review', 'revision', 'source_revision', + 'created_at', 'updated_at') +PROJECT_ACTIONS = ('new', 'open', 'save', 'close', 'query', 'get', 'import_text', 'annotate') +# Set only after the complete finite dispatcher is integrated. A capability is +# a promise that the route reaches canonical publication, not an API wishlist. +M2_ENABLED = True +M3_ENABLED = True +M4_ENABLED = True +_RECOVERY_OPERATIONS = frozenset(('project_files.execution_reconcile_current', + 'project_files.native_reconcile', + 'project_files.runtime_capture', 'project_files.runtime_abandon', + 'project_files.execution_study_abandon_preparation', 'project_files.execution_study_reconcile_preparation')) +_DRAINING_OPERATIONS = _RECOVERY_OPERATIONS | frozenset(('project_files.execution_producer_cancel', + 'project_files.execution_study_stop')) +DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC + + +def public_record(value): + """Project owned values into the closed public contract; do not infer policy.""" + return {key: copy.deepcopy(value[key]) for key in PUBLIC_RECORD_FIELDS} + + +def _digest(value): + return hashlib.sha256(agent_api.encoded(value)).hexdigest() + + +def http_arguments(method, target, body=None): + """Use the fixed operation contracts for identical HTTP and SDK routing.""" + names = ['project_files.status', *('project_files.' + action for action in PROJECT_ACTIONS)] + names.extend(sorted(agent_api.PROJECT_FILES_M2 | agent_api.PROJECT_FILES_M3 | agent_api.PROJECT_FILES_M4)) + operations = {name: agent_api.operation(name) for name in names} + # A literal import route wins over a record-id placeholder. Contract + # validation still rejects an unexpected path/body/query representation. + ordered = sorted(operations.items(), key=lambda item: (item[1]['path'].count('{'), item[0])) + for name, operation in ordered: + if agent_api._route_matches(operation, method, target): + selected = (name, 1, agent_api.contract(name, 1)['sha256']) + return name, agent_api.validate_selected(selected, method, target, body) + raise WorkbenchError('Unknown project-file operation.', 'unsupported', 404) + + +class ProjectWorkspace: + project_files_mode = True + + def __init__(self, control_path, roots, *, dsn=None, runtime_sources=None): + from runtime_snapshots import configuration + configured = {} + for alias, config in (runtime_sources or {}).items(): + if (type(alias) is not str or not re.fullmatch('[A-Za-z0-9][A-Za-z0-9_.-]{0,63}', alias) + or len(configured) >= 100): + raise WorkbenchError('Configure at most 100 named current runtime sources.') + configured[alias] = copy.deepcopy(configuration(config)) + self.path = Path(control_path).resolve() + self.db = Metadata(self.path, dsn=dsn) + self.lock = self.db.lock + self.access = Access(self.db, self.path) + self.custody = ProjectCustody(self.path, self.db.dataset_id) + self._retained_recovery = None + self._dsn = dsn + self._runtime_sources = configured + self._mutex = threading.RLock() + self._roots = {} + self._store = self._text = self._projection = None + self._selection = None + self._last_project = None + self._recovery = None + self._binding = uuid.uuid4().hex + self._closed = False + self._receipts = {} + try: + if not roots or len(roots) > 100: + raise WorkbenchError('Configure between one and 100 local project-file roots.') + for alias, path in roots.items(): + if type(alias) is not str or not re.fullmatch('[A-Za-z0-9][A-Za-z0-9_.-]{0,63}', alias): + raise WorkbenchError('Invalid local project-file root alias.') + location = Path(path).expanduser().resolve(strict=True) + descriptor = os.open(location, DIR_FLAGS) + self._roots[alias] = (location, descriptor) + except BaseException: + self.close() + raise + + def _ensure_open(self): + if self._closed: + raise WorkbenchError('Project workspace is closed.', 'unavailable', 503) + + def _recovery_required(self, reason, request_key=None): + if self._recovery is None and self._selection is not None and self._last_project is not None: + self._recovery = dict(action='open', root=self._selection[0], directory=self._selection[1], + reason=reason, request_key=request_key) + + def _state(self): + self._ensure_open() + project = None + if self._recovery is None and self._store is not None: + try: + snapshot = self._store.snapshot() + except ProjectStoreError as error: + if error.code not in ('unknown', 'outcome_unknown', 'stale', 'stale_project', + 'invalid', 'unsupported', 'unavailable'): + raise + self._recovery_required('unknown_publication' if error.code in ('unknown', 'outcome_unknown') + else 'projection_unavailable') + else: + if self._text is not None and not self._text.available: + self._recovery_required('projection_unavailable') + else: + project = {key: snapshot[key] for key in ('project_id', 'name', 'checkpoint')} + self._last_project = copy.deepcopy(project) + if self._recovery is not None: + return dict(format='project_files_state_v1', binding=self._binding, state='recovery_required', + roots=sorted(self._roots), project=copy.deepcopy(self._last_project), + available_actions=['open'], recovery=copy.deepcopy(self._recovery)) + return dict(format='project_files_state_v1', binding=self._binding, + state='open' if project is not None else 'no_project_open', + roots=sorted(self._roots), project=project, + available_actions=list(PROJECT_ACTIONS if project is not None else ('new', 'open'))) + + def status(self): + with self._mutex, self.access.lock: + self.access.require('inspect', 'project_files.status') + return self._state() + + def capabilities(self): + value = agent_api.capabilities(self.db.dataset_id, profile='project') + for operation in value['operations']: + if operation['id'].startswith('project_transfer.'): + operation['availability'] = ('available' if getattr(self, 'transfer_controller', None) + is not None else 'unavailable') + continue + if operation['id'] in agent_api.PROJECT_FILES_M2 and not M2_ENABLED: + operation['availability'] = 'unavailable' + if operation['id'] in agent_api.PROJECT_FILES_M3 and not M3_ENABLED: + operation['availability'] = 'unavailable' + if operation['id'] in agent_api.PROJECT_FILES_M4 and not M4_ENABLED: + operation['availability'] = 'unavailable' + if not operation['id'].startswith('project_files.') and operation['id'] not in ('context.get', 'identity.get'): + operation['availability'] = 'unavailable' + return value + + def _bound(self, arguments, *, may_be_empty=False, check_checkpoint=True, allow_recovery=False): + self._ensure_open() + if arguments['binding'] != self._binding: + raise WorkbenchError('The project workspace changed. Refresh its binding before acting.', + 'stale_project', 409) + if self._recovery is not None: + if not allow_recovery: + raise WorkbenchError('Explicitly reopen the current project to reconcile its canonical outcome.', + 'projection_unavailable', 503) + if arguments.get('expected_checkpoint') != self._last_project['checkpoint']: + raise WorkbenchError('Recovery requires the last acknowledged checkpoint.', 'stale_project', 409) + return + if self._store is None: + if not may_be_empty: + raise WorkbenchError('Open an external project first.', 'unavailable', 409) + if arguments.get('expected_checkpoint') is not None: + raise WorkbenchError('The empty workspace has no checkpoint.', 'stale_project', 409) + return + snapshot = self._store.snapshot() + expected = arguments.get('expected_checkpoint', arguments.get('checkpoint')) + if check_checkpoint and expected != snapshot['checkpoint']: + raise WorkbenchError('The project checkpoint changed. Refresh before acting.', 'stale_project', 409) + + def _root(self, alias, relative): + if alias not in self._roots: + raise WorkbenchError('Choose a configured local project-file root.', 'unavailable', 409) + # Store create_at/open_at retains this handle through all actual use. + if (type(relative) is not str or not relative or len(relative) > 512 or + relative.startswith('/') or '\\' in relative or '\x00' in relative or + any(part in ('', '.', '..') for part in relative.split('/')) or + len(relative.split('/')) > 16): + raise WorkbenchError('Use a project directory relative to its configured root, without traversal.') + root_path, root_fd = self._roots[alias] + from project_store import assert_separate_project_control + assert_separate_project_control(root_fd, relative, root_path=root_path, control_path=self.path) + return self._roots[alias] + + def _projection_for(self, store): + identity = uuid.uuid4().hex + path = self.path / '.project-projections' / identity + snapshot = store.snapshot() + projection = Metadata(path, dsn=self._dsn, dataset_id=snapshot['project_id'], projection_id=identity) + try: + owner = ProjectProjection(store, projection, self.access, + {alias: value[0] for alias, value in self._roots.items()}, + custody=self.custody.for_projection(store, identity), + runtime_sources=self._runtime_sources) + owner.pending_original = self.custody.pending(store) + if owner.pending_original: + owner.session.available = False + return owner, owner.text + except BaseException: + projection.close() + # Disposable failed projections remain identified for diagnosis; + # no authoritative bytes or an uncertain outcome is deleted. + raise + + def _retained_projection_for(self, store, projection_id): + if type(projection_id) is not str or not re.fullmatch('[a-f0-9]{32}', projection_id): + raise WorkbenchError('Invalid original local projection identity.', 'unavailable', 409) + db = Metadata(self.path / '.project-projections' / projection_id, dsn=self._dsn, + create=False, dataset_id=store.snapshot()['project_id'], projection_id=projection_id) + try: + return ProjectProjection(store, db, self.access, + {alias: value[0] for alias, value in self._roots.items()}, + custody=self.custody.for_projection(store, projection_id), restore=False, + runtime_sources=self._runtime_sources) + except BaseException: + db.close() + raise + + def _detach(self): + """Release only a project whose current local effects have drained.""" + if self._projection is not None: + self._projection.execution.assert_quiescent() + if self._store is not None: + # Prove pending project publications and their full closure first. + self._store.save(self._store.snapshot()['checkpoint']) + if self._projection is not None: + self._projection.close() + if self._store is not None: + self._store.close() + self._store = self._text = self._projection = None + self._selection = None + self._last_project = self._recovery = None + + def _recover_current(self, arguments): + self._bound(arguments, allow_recovery=True) + if (arguments['root'], arguments['directory']) != self._selection: + raise WorkbenchError('Recover the current project before switching destinations.', 'projection_unavailable', 503) + if self._projection is not None and self._projection.pending_original: + raise WorkbenchError('Explicitly reconcile original-host custody before reopening this project.', + 'projection_unavailable', 503) + if self._projection is not None and self._projection.execution.has_local_custody: + # Retain actual local custody until the explicit execution recovery + # boundary proves cessation; imported history can never do so. + self._projection.execution.assert_quiescent() + root_path, root_fd = self._root(*self._selection) + # Keep the old projection and generation until a fresh inert projection + # is ready. Releasing the uncertain writer lock is necessary to reread it. + if self._store is not None: + self._store.close() + self._store = None + store = projection = None + try: + store = ProjectStore.open_at(root_fd, arguments['directory'], root_path=root_path) + snapshot = store.snapshot() + if snapshot['project_id'] != self._last_project['project_id']: + raise WorkbenchError('Recovery destination has a different project identity.', 'stale_project', 409) + # A reread reconciles identity; only a successful durability barrier + # resolves a prior checkpoint/directory fsync failure. + store.save(snapshot['checkpoint']) + projection, text = self._projection_for(store) + snapshot = store.snapshot() + if self._projection is not None: + self._projection.close() + self._store, self._projection, self._text = store, projection, text + self._last_project = {key: snapshot[key] for key in ('project_id', 'name', 'checkpoint')} + self._recovery = None + self._binding = uuid.uuid4().hex + return self._state() + except BaseException: + if projection is not None and projection is not self._projection: + projection.close() + if store is not None and store is not self._store: + store.close() + raise + + def _select(self, action, arguments): + if self._recovery is not None: + if action != 'open': + raise WorkbenchError('Reopen the current project before creating another.', 'projection_unavailable', 503) + return self._recover_current(arguments) + self._bound(arguments, may_be_empty=True) + root_path, root_fd = self._root(arguments['root'], arguments['directory']) + selection = (arguments['root'], arguments['directory']) + if action == 'open' and selection == self._selection: + return self._state() + if self._projection is not None: + self._projection.execution.assert_quiescent() + # Validate and construct the incoming inert projection before releasing + # the existing binding. An invalid Open cannot strand current custody. + store = None + projection = None + created = None + try: + if action == 'new': + request = {key: arguments[key] for key in ('root', 'directory', 'name', 'request_key')} + digest = _digest(request) + reconciled = False + try: + store = ProjectStore.create_at(root_fd, arguments['directory'], arguments['name'], + arguments['request_key'], digest, root_path=root_path) + except ProjectStoreError as error: + if error.code not in ('conflict', 'unknown', 'outcome_unknown'): + raise + # A lost creation reply or restart may leave a complete, + # unselected destination. Only its exact receipt authorizes + # treating it as this New; unrelated folders remain conflicts. + try: + store = ProjectStore.open_at(root_fd, arguments['directory'], root_path=root_path) + except ProjectStoreError: + raise error + if store.reconcile(arguments['request_key'], digest) is None: + raise error + reconciled = True + snapshot = store.snapshot() + if reconciled: + store.save(snapshot['checkpoint']) + created = snapshot + else: + store = ProjectStore.open_at(root_fd, arguments['directory'], root_path=root_path) + projection, text = self._projection_for(store) + self._detach() + self._store, self._projection, self._text = store, projection, text + self._selection = selection + snapshot = store.snapshot() + self._last_project = {key: snapshot[key] for key in ('project_id', 'name', 'checkpoint')} + self._recovery = None + self._binding = uuid.uuid4().hex + return self._state() + except BaseException as error: + if projection is not None and projection is not self._projection: + projection.close() + if store is not None and store is not self._store: + store.close() + if created is not None and self._store is not store: + failure = WorkbenchError('Project was created but could not be selected; retry this exact New to reconcile its destination.', + 'project_selection_unavailable', 503) + failure.committed, failure.selected = True, False + failure.project_id, failure.checkpoint = created['project_id'], created['checkpoint'] + failure.request_key = arguments['request_key'] + failure.root, failure.directory = arguments['root'], arguments['directory'] + failure.receipt = dict(committed=True, selected=False, project_id=created['project_id'], + checkpoint=created['checkpoint'], request_key=arguments['request_key'], + root=arguments['root'], directory=arguments['directory']) + raise failure from error + raise + + def _record_result(self, record, *, before_revision=None, history_limit=1): + snapshot = self._store.snapshot() + history = [item for item in self._text.history(record['id']) + if item['revision'] <= record['revision'] and + (before_revision is None or item['revision'] < before_revision)] + history.sort(key=lambda item: item['revision'], reverse=True) + page = history[:history_limit] + self._last_project = {key: snapshot[key] for key in ('project_id', 'name', 'checkpoint')} + return dict(binding=self._binding, project_id=snapshot['project_id'], checkpoint=snapshot['checkpoint'], + record=public_record(record), history=[public_record(item) for item in page], + next_history_before_revision=page[-1]['revision'] if len(history) > len(page) else None) + + def _save_as(self, arguments): + """Publish a new independent project, then select a fresh inert cache.""" + from functools import partial + from project_fork import publish_fork + from project_fork_composition import build_candidate + from workbench import timestamp + self._bound(arguments) + root_path, root_fd = self._root(arguments['root'], arguments['directory']) + source = self._projection + source.execution.assert_quiescent() + source.files.require_clean() + self._store.save(arguments['expected_checkpoint']) + published = None + incoming = projection = None + try: + published = publish_fork(self._store, source.artifacts, root_fd, arguments['directory'], + root_path=root_path, name=arguments['name'], + expected_checkpoint=arguments['expected_checkpoint'], request_key=arguments['request_key'], + quiescent=True, build_candidate=partial(build_candidate, created_at=timestamp(), + id_factory=lambda _family, _source_id, _revision: uuid.uuid4().hex), + validate_candidate=lambda records, identity: source.validate_records(records, project_id=identity)) + # A filesystem editor can act outside the workspace lock. Leave the + # complete fork unselected if its source working files changed. + source.files.require_clean() + incoming = ProjectStore.open_at(root_fd, arguments['directory'], root_path=root_path) + projection, text = self._projection_for(incoming) + snapshot, overlay = published['snapshot'], published['fork'] + self._detach() + self._store, self._projection, self._text = incoming, projection, text + self._selection = arguments['root'], arguments['directory'] + self._last_project = {key: snapshot[key] for key in ('project_id', 'name', 'checkpoint')} + self._recovery = None + self._binding = uuid.uuid4().hex + summary = dict(source_project_id=overlay['source']['project_id'], + source_checkpoint=overlay['source']['checkpoint'], project_id=snapshot['project_id'], + checkpoint=snapshot['checkpoint'], root=arguments['root'], directory=arguments['directory'], + request_key=arguments['request_key'], reconciled=published['reconciled'], + mapping_count=len(overlay['mapping']), copied_count=len(overlay['artifacts']['copied']), + missing_regenerable_count=len(overlay['artifacts']['missing_regenerable'])) + return dict(state=self._state(), fork=summary) + except BaseException as error: + if projection is not None and projection is not self._projection: + projection.close() + if incoming is not None and incoming is not self._store: + incoming.close() + receipt = getattr(error, 'receipt', None) + if published is not None: + snapshot = published['snapshot'] + receipt = dict(committed=True, selected=False, project_id=snapshot['project_id'], + checkpoint=snapshot['checkpoint'], request_key=arguments['request_key'], + root=arguments['root'], directory=arguments['directory']) + elif receipt is not None: + receipt = dict(receipt, root=arguments['root']) + if receipt is not None: + failure = WorkbenchError('Independent project was published but could not be selected; retry this exact Save As.', + 'project_selection_unavailable', 503) + failure.receipt = receipt + raise failure from error + raise + + def dispatch(self, name, arguments, *, selected_version=None): + """Pin workspace -> current host authority -> projection through return. + + An old request cannot observe a new Dataset alias. Revocation cannot + complete before an already-authorized canonical publication finishes. + """ + agent_api.validate_arguments(name, arguments) + if not name.startswith('project_files.'): + raise WorkbenchError('This domain is unavailable in external text-project mode.', 'unavailable', 409) + if name == 'project_files.execution_health': + return self._execution_health(arguments, selected_version=selected_version) + if name in _DRAINING_OPERATIONS: + return self._dispatch_draining(name, arguments) + action = name.removeprefix('project_files.') + with self._mutex, self.access.lock: + self._ensure_open() + operation = agent_api.operation(name) + for scope in operation['scopes']: + if scope == 'execute' and action in ('execution_submit_external', 'execution_study_admit'): + self.access.require_external_submission() + else: + self.access.require(scope, name, owner_only=action in ('new', 'open', 'save', 'save_as', 'close')) + if selected_version == 1 and action in ('get', 'annotate'): + self._bound(arguments, check_checkpoint=action == 'get') + record = self._projection.data._require_record(arguments['record_id']) + if record['document']['current']['provenance'].get('method') != 'import': + raise WorkbenchError('This retained text provenance requires project text contract version 2.', + 'unsupported', 409) + if name in agent_api.PROJECT_FILES_M4 and not M4_ENABLED: + raise WorkbenchError('This project capability is not yet available.', 'unavailable', 409) + if name in (agent_api.PROJECT_FILES_M2 | agent_api.PROJECT_FILES_M3 | agent_api.PROJECT_FILES_M4) - {'project_files.save_as'}: + if ((name in agent_api.PROJECT_FILES_M2 and not M2_ENABLED) + or (name in agent_api.PROJECT_FILES_M3 and not M3_ENABLED) + or (name in agent_api.PROJECT_FILES_M4 and not M4_ENABLED)): + raise WorkbenchError('This project capability is not yet available.', 'unavailable', 409) + self._bound(arguments, check_checkpoint=not operation['side_effects']) + from project_dispatch import dispatch + try: + value = dispatch(self._projection, name, arguments) + if name == 'project_files.execution_health' and selected_version == 1: + value = {key: item for key, item in value.items() if key != 'native_phases'} + except BaseException as error: + if getattr(error, 'code', None) in ('unknown', 'outcome_unknown', 'projection_unavailable'): + self._recovery_required('projection_unavailable', arguments.get('request_key')) + raise + snapshot = self._store.snapshot() + self._last_project = {key: snapshot[key] for key in ('project_id', 'name', 'checkpoint')} + result = dict(binding=self._binding, project_id=snapshot['project_id'], + checkpoint=snapshot['checkpoint'], value=value) + elif action == 'status': + result = self._state() + else: + # Lifecycle receipts are process-binding scoped. Domain edit + # receipts are canonical and survive any originating DB loss. + key = (name, arguments.get('binding'), arguments.get('request_key')) + signature = _digest(arguments) + prior = self._receipts.get(key) if arguments.get('request_key') else None + if prior is not None: + if prior[0] != signature: + raise WorkbenchError('Request key already has different input.', 'conflict', 409) + prior_state = prior[1]['state'] if action == 'save_as' else prior[1] + if prior_state != self._state(): + raise WorkbenchError('This lifecycle receipt belongs to an obsolete workspace state.', 'stale_project', 409) + result = copy.deepcopy(prior[1]) + else: + try: + if action in ('new', 'open'): + result = self._select(action, arguments) + elif action == 'save': + self._bound(arguments) + self._store.save(arguments['expected_checkpoint']) + result = self._state() + elif action == 'save_as': + result = self._save_as(arguments) + elif action == 'close': + self._bound(arguments) + self._detach() + self._binding = uuid.uuid4().hex + result = self._state() + elif action == 'query': + self._bound(arguments) + value = self._text.records() + items = sorted(value['items'], key=lambda item: item['id']) + if arguments.get('after_id') is not None: + items = [item for item in items if item['id'] > arguments['after_id']] + page = items[:arguments.get('limit', 40)] + result = dict(binding=self._binding, project_id=value['project_id'], checkpoint=value['checkpoint'], + items=[{key: copy.deepcopy(item[key]) for key in SUMMARY_FIELDS} for item in page], + next_after_id=page[-1]['id'] if len(items) > len(page) else None) + elif action == 'get': + self._bound(arguments) + result = self._record_result(self._text.get(arguments['record_id']), + before_revision=arguments.get('before_revision'), history_limit=arguments.get('history_limit', 1)) + elif action in ('import_text', 'annotate'): + # Canonical domain receipts reconcile before CAS. + self._bound(arguments, check_checkpoint=False) + if action == 'import_text': + record = self._text.import_text(arguments['asset'], arguments['expected_checkpoint'], arguments['request_key']) + else: + record = self._text.annotate(arguments['record_id'], arguments['annotation'], + arguments['expected_checkpoint'], arguments['request_key']) + result = self._record_result(record) + else: + raise WorkbenchError('Unsupported project-file operation.', 'unsupported', 400) + except BaseException as error: + code = getattr(error, 'code', None) + if action != 'save_as' and code in ('unknown', 'outcome_unknown', 'projection_unavailable'): + reason = 'unknown_publication' if code in ('unknown', 'outcome_unknown') else 'projection_unavailable' + self._recovery_required(reason, arguments.get('request_key')) + raise + if arguments.get('request_key') and action in ('new', 'open', 'save', 'save_as', 'close'): + self._receipts[key] = (signature, copy.deepcopy(result)) + if len(agent_api.encoded(result)) > agent_api.RESPONSE_LIMIT: + raise WorkbenchError('This project result exceeds the synchronous response bound; canonical work is retained.', + 'too_large', 413) + agent_api.validate_output(name, {'status': 200, 'result': result},version=selected_version) + return result + + def _dispatch_draining(self, name, arguments): + """Keep workspace selection pinned while descendants drain without SQL locks.""" + with self._mutex: + with self.access.lock: + self._ensure_open() + if not M3_ENABLED: + raise WorkbenchError('This project capability is not yet available.', 'unavailable', 409) + operation = agent_api.operation(name) + for scope in operation['scopes']: + self.access.require(scope, name, owner_only=name in _RECOVERY_OPERATIONS) + recovery_operation = name in _RECOVERY_OPERATIONS + if recovery_operation and self._projection is not None and not self._projection.session.available: + self._recovery_required('projection_unavailable', arguments.get('request_key')) + self._bound(arguments, check_checkpoint=recovery_operation and name != 'project_files.runtime_capture', + allow_recovery=recovery_operation) + projection = self._projection + recovering = self._recovery is not None or not projection.session.available + from project_dispatch import dispatch + try: + incoming = projection + if recovery_operation and projection.pending_original: + if len(projection.pending_original) != 1: + raise WorkbenchError('More than one original local custody binding needs explicit review.', + 'local_custody_unavailable', 409) + binding = projection.pending_original[0] + if binding['project_id'] != projection.session.project_id: + raise WorkbenchError('Restore the original project identity before reconciling this physical folder custody.', + 'local_custody_unavailable', 409) + if self._retained_recovery is None: + self._retained_recovery = self._retained_projection_for(self._store, binding['projection_id']) + incoming = self._retained_recovery + if incoming.db.projection_id != binding['projection_id']: + raise WorkbenchError('The retained physical recovery owner changed.', 'local_custody_unavailable', 409) + recovering = True + if incoming is not projection and name == 'project_files.execution_reconcile_current': + value = incoming.execution.reconcile_original(expected_checkpoint=incoming.session.snapshot['checkpoint']) + else: + internal = arguments + if recovery_operation and recovering and name != 'project_files.execution_reconcile_current': + # Public CAS above uses the last acknowledged workspace + # view. Only this qualified local owner sees its exact + # selected/attempted checkpoint for finite recovery. + internal = dict(arguments, expected_checkpoint=incoming.session.snapshot['checkpoint']) + if name == 'project_files.runtime_capture' and recovering: + options = {'reserve_bytes': internal['reserve_bytes']} if 'reserve_bytes' in internal else {} + value = incoming.runtime.recover_capture({'source': internal['source']}, + internal['expected_inventory_sha256'], internal['expected_checkpoint'], + internal['request_key'], **options) + else: + value = dispatch(incoming, name, internal) + if recovery_operation: + if not incoming.session.available or incoming.custody is not None and incoming.custody.has_pending: + raise WorkbenchError('Original writer custody remains unresolved.', 'outcome_unknown', 409) + if incoming is not projection: + incoming.execution.assert_quiescent() + projection.close() + projection = self._projection = incoming + self._text = incoming.text + self._retained_recovery = None + # Physical cache repair retains this same selected + # project and workspace generation; it is not Open. + except BaseException as error: + if getattr(error, 'code', None) in ('unknown', 'outcome_unknown', 'projection_unavailable'): + self._recovery_required('projection_unavailable', arguments.get('request_key')) + raise + with self.access.lock, projection.session.lock, projection.db.lock: + snapshot = self._store.snapshot() + self._last_project = {key: snapshot[key] for key in ('project_id', 'name', 'checkpoint')} + if name in _RECOVERY_OPERATIONS: + self._recovery = None + result = dict(binding=self._binding, project_id=snapshot['project_id'], + checkpoint=snapshot['checkpoint'], value=value) + if len(agent_api.encoded(result)) > agent_api.RESPONSE_LIMIT: + raise WorkbenchError('Project drainage result exceeds its response bound.', 'too_large', 413) + agent_api.validate_output(name, {'status': 200, 'result': result}) + return result + + def _execution_health(self, arguments, *, selected_version=None): + """Read only indexed current-local custody, including a fenced owner.""" + name='project_files.execution_health' + with self._mutex,self.access.lock: + self._ensure_open() + if not M3_ENABLED:raise WorkbenchError('This project capability is not yet available.','unavailable',409) + self.access.require('inspect',name) + if self._projection is not None and not self._projection.session.available: + self._recovery_required('projection_unavailable') + self._bound(dict(arguments,expected_checkpoint=arguments['checkpoint']),allow_recovery=True) + incoming=self._projection + if incoming.pending_original: + if len(incoming.pending_original)!=1 or incoming.pending_original[0]['project_id']!=incoming.session.project_id: + raise WorkbenchError('Original local custody requires its exact project identity.','local_custody_unavailable',409) + identity=incoming.pending_original[0]['projection_id'] + if self._retained_recovery is None: + self._retained_recovery=self._retained_projection_for(self._store,identity) + incoming=self._retained_recovery + if incoming.db.projection_id!=identity: + raise WorkbenchError('Original physical custody changed.','local_custody_unavailable',409) + value=incoming.execution.health(arguments.get('offset',0),arguments.get('limit',40)) + if selected_version==1:value={key:item for key,item in value.items() if key!='native_phases'} + result=dict(binding=self._binding,project_id=incoming.session.project_id, + checkpoint=arguments['checkpoint'],value=value) + if len(agent_api.encoded(result))>agent_api.RESPONSE_LIMIT: + raise WorkbenchError('Current custody health exceeds its response bound.','too_large',413) + agent_api.validate_output(name,dict(status=200,result=result),version=selected_version) + return result + + def close(self): + with self._mutex: + if self._closed: + return + # Shutdown always releases handles, including a stale/unknown store; + # actual local workers drain before any database handle is closed. + if self._projection is not None: + self._projection.execution.shutdown() + self._projection.close() + if self._store is not None: + self._store.close() + self._projection = self._text = self._store = None + for _path, fd in self._roots.values(): + os.close(fd) + self._roots.clear() + self.db.close() + self._closed = True diff --git a/projects.py b/projects.py index a0711df2..78869541 100644 --- a/projects.py +++ b/projects.py @@ -105,6 +105,21 @@ def manifest(value): return value +def decode_retained_snapshot(value): + """Validate immutable registration facts without binding any current root.""" + import copy + closed(value,('id','name','root','root_identity','manifest_path','manifest_sha256','manifest')) + if type(value['id']) is not str or not ID.fullmatch(value['id']):raise WorkbenchError('Invalid retained snapshot identity.') + text(value['name'],'Project name',120);text(value['root'],'Historical root alias',160) + relative_path(value['manifest_path']) + if type(value['manifest_sha256']) is not str or not SHA.fullmatch(value['manifest_sha256']):raise WorkbenchError('Invalid retained manifest hash.') + root=value['root_identity'] + if type(root) is not list or len(root)!=2 or any(type(number) is not int or number<0 for number in root):raise WorkbenchError('Invalid historical root identity.') + document=manifest(value['manifest']) + if document['id']!=value['id'] or document['name']!=value['name']:raise WorkbenchError('Retained snapshot manifest identity differs.') + return copy.deepcopy(value) + + class Projects: """One catalog's immutable project snapshots and explicitly configured root handles.""" diff --git a/recipe_process.py b/recipe_process.py index 6ccfffa9..a476f84e 100644 --- a/recipe_process.py +++ b/recipe_process.py @@ -21,6 +21,9 @@ import threading READY_SECONDS = 10 +# READY starts a separate, finite owner durability phase. Neither a slow +# canonical acknowledgement nor this budget authorizes a recipe without GO. +GO_WAIT_SECONDS = 60 GRACE_SECONDS = 3 CLEANUP_SECONDS = 10 STREAM_LIMIT = 1024 * 1024 @@ -279,6 +282,20 @@ def validate_cpu_manifest(value): return decode(encoded(value)) +def validate_cpu_snapshot_manifest(descriptor, manifest): + """Bind historical descriptor and exact manifest without reading a root.""" + descriptor=validate_cpu_snapshot_descriptor(descriptor) + manifest=validate_cpu_manifest(manifest) + raw=encoded(manifest) + if len(raw)!=descriptor['manifest_bytes'] or digest(raw)!=descriptor['manifest_sha256']: + raise ProcessError('CPU descriptor manifest identity differs.','stale') + for key in ('profile','python_version','packages','launch','total_bytes'): + if manifest[key]!=descriptor[key]:raise ProcessError('CPU descriptor differs from manifest.','stale') + if len(manifest['files'])!=descriptor['file_count']: + raise ProcessError('CPU descriptor file count differs.','stale') + return manifest + + def _cpu_open_root(root): """Walk every absolute component without following symlinks.""" fd = os.open('/', os.O_RDONLY | os.O_DIRECTORY) @@ -356,10 +373,7 @@ def verify_cpu_snapshot(descriptor, *, checkpoint=None): raise ProcessError('Captured CPU manifest hash changed.', 'stale') manifest = validate_cpu_manifest(decode(raw)) if raw != encoded(manifest): raise ProcessError('CPU manifest is not canonical.', 'invalid') - for key in ('profile', 'python_version', 'packages', 'launch', 'total_bytes'): - if manifest[key] != descriptor[key]: raise ProcessError('CPU descriptor differs from manifest.', 'stale') - if len(manifest['files']) != descriptor['file_count']: - raise ProcessError('CPU descriptor file count differs.', 'stale') + validate_cpu_snapshot_manifest(descriptor,manifest) expected = {item['path'] for item in manifest['files']} expected_dirs = {str(parent) for path in expected for parent in Path(path).parents if str(parent) != '.'} observed = set(); directory_count = 0 @@ -402,6 +416,19 @@ def verify_cpu_snapshot(descriptor, *, checkpoint=None): def validate_spec(value): + return _validate_spec(value, current=True) + + +def decode_retained_spec(value): + """Validate inert exact specification values, without current UID authority. + + The original trusted installation UID remains a historical value. A new + launch always uses validate_spec and its current distinct-owner requirement. + """ + return _validate_spec(value, current=False) + + +def _validate_spec(value, *, current): keys = {'schema_version','attempt_id','request_sha256','argv','env','cwd','stdin_base64', 'wall_seconds','executable_sha256','max_output_bytes','output_dir','captured_files','runtime_files','trusted_install_owner_uid'} if type(value) is not dict or type(value.get('schema_version')) is not int or value['schema_version'] not in (1,2): @@ -430,7 +457,8 @@ def validate_spec(value): raise ValueError() except ValueError as error: raise ProcessError('Stdin exceeds its 64 KiB bound or is invalid base64.') from error - if type(value['trusted_install_owner_uid']) is not int or value['trusted_install_owner_uid'] == os.getuid() or value['trusted_install_owner_uid'] < 0: + if (type(value['trusted_install_owner_uid']) is not int or value['trusted_install_owner_uid'] < 0 + or current and value['trusted_install_owner_uid'] == os.getuid()): raise ProcessError('Runtime owner must be an explicitly distinct trusted installation identity.') runtime = value['runtime_files'] if type(runtime) is not list or len(runtime)>10000: @@ -669,6 +697,7 @@ class Handle: def __init__(self, process, channel, spec, directory): self.process, self.channel, self.spec, self.directory = process, channel, spec, directory self.ready = None + self.go_deadline = None self.verifying = False self.sent_go = self.terminal = self.lost = self.sent_cancel = False self.started = time.monotonic() @@ -708,6 +737,7 @@ def poll(self): if self.ready is not None or not valid_identity(event['helper']) or event['helper'] != identity(self.process.pid): self.cancel('protocol_error'); raise ProcessError('Invalid custodian READY identity.') self.ready = event['helper'] + self.go_deadline = time.monotonic() + GO_WAIT_SECONDS elif kind == 'verifying': if not self.sent_go or self.verifying or 'torch_runtime' not in self.spec: self.cancel('protocol_error'); raise ProcessError('Invalid runtime verification observation.') @@ -728,9 +758,16 @@ def poll(self): return events def go(self, expected_ready_identity): - if self.ready is None or expected_ready_identity != self.ready or self.sent_go or self.terminal or self.channel is None: + if self.ready is None or expected_ready_identity != self.ready or self.sent_go or self.sent_cancel or self.terminal or self.lost or self.channel is None: raise ProcessError('GO requires the exact READY identity once.') - _packet(self.channel, {'type':'go','attempt_id':self.spec['attempt_id'],'request_sha256':self.spec['request_sha256'],'helper':self.ready}) + if self.go_deadline is None or time.monotonic() >= self.go_deadline: + self.close_control(wait=False) + raise ProcessError('Custodian GO acknowledgement deadline exceeded.', 'unavailable') + try: + _packet(self.channel, {'type':'go','attempt_id':self.spec['attempt_id'],'request_sha256':self.spec['request_sha256'],'helper':self.ready}) + except OSError as error: + self.close_control(wait=False) + raise ProcessError('Custodian control was lost before GO acknowledgement.', 'unavailable') from error self.sent_go = True def cancel(self, reason='cancelled'): @@ -758,12 +795,34 @@ def close_control(self, *, wait=True): return {'drained':False,'outcome':'unknown','reason':'missing_drain_receipt'} -def launch(spec, custody_dir): +def launch(spec, custody_dir, *, executable_fd=None, _before_spawn=None): spec = validate_spec(spec) # Full managed inventory hashing belongs to the cancellable custodian after # admission/GO. Synchronous dispatch only checks the bounded captured files. verify_captures(spec, include_torch=False) - executable = read_regular(spec['argv'][0], 128*1024*1024) + if executable_fd is None: + executable = read_regular(spec['argv'][0], 128*1024*1024) + else: + # A current owner may preserve a configured interpreter alias in argv0 + # while supplying its independently verified physical regular file. + # Never open this capability from a retained specification. + descriptor = os.dup(executable_fd) + try: + before = os.fstat(descriptor) + if not stat.S_ISREG(before.st_mode) or before.st_size > 128*1024*1024: + raise ProcessError('Current executable capability is not a bounded regular file.') + chunks=[];offset=0 + while offset<=128*1024*1024: + raw=os.pread(descriptor,min(1024*1024,128*1024*1024+1-offset),offset) + if not raw:break + chunks.append(raw);offset+=len(raw) + executable=b''.join(chunks);after=os.fstat(descriptor) + if (len(executable)>128*1024*1024 or + (before.st_dev,before.st_ino,before.st_size,before.st_mtime_ns,before.st_ctime_ns)!= + (after.st_dev,after.st_ino,after.st_size,after.st_mtime_ns,after.st_ctime_ns)): + raise ProcessError('Current executable capability changed.','stale') + finally: + os.close(descriptor) if digest(executable) != spec['executable_sha256']: raise ProcessError('Executable identity changed.', 'stale') directory = Path(custody_dir) @@ -785,8 +844,12 @@ def launch(spec, custody_dir): raise ProcessError('Managed Torch bootstrap changed before sealing.', 'stale') descriptors.append(sealed('tuldok-torch-owner',raw)) command = [_trusted_python(spec['trusted_install_owner_uid']),'-I','-B',f'/proc/self/fd/{descriptors[0]}','--helper',str(child.fileno()),*(str(fd) for fd in descriptors[1:])] - process = subprocess.Popen(command, stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL, + options = dict(stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL, pass_fds=(child.fileno(),*descriptors),close_fds=True,env={'PATH':'/usr/bin:/bin','LANG':'C.UTF-8'}) + # The current owner may retire its volatile never-spawned proof only at + # this boundary. Entering Popen can have effects even when it raises. + if _before_spawn is not None: _before_spawn() + process = subprocess.Popen(command, **options) parent.setblocking(False) return Handle(process,parent,spec,directory) except BaseException: @@ -1061,8 +1124,12 @@ def preparation_checkpoint(): # responsive; recheck it cancellably at GO, before any recipe fork. verify_captures(spec, include_cpu=False, include_torch=False) timing.finish(preparation_span) - wait_span=timing.begin('go_wait','owner_go');timing_write() - _packet(channel,dict(common,type='ready',helper=who)) + if time.monotonic()>=deadline: + stop('ready_timeout') + else: + wait_span=timing.begin('go_wait','owner_go');timing_write() + deadline=time.monotonic()+GO_WAIT_SECONDS + _packet(channel,dict(common,type='ready',helper=who)) while True: now=time.monotonic() if stopping is None and now>=deadline:stop('wall_timeout' if go else 'ready_timeout') @@ -1097,6 +1164,8 @@ def preparation_checkpoint(): if type(message) is dict and set(message)=={'type','reason'} and message['type']=='cancel' and message['reason'] in ('cancelled','shutdown','protocol_error'): stop(message['reason']);continue expected={'type':'go','attempt_id':spec['attempt_id'],'request_sha256':spec['request_sha256'],'helper':who} + if not go_received and time.monotonic()>=deadline: + stop('ready_timeout');continue if message!=expected or go_received or stopping is not None:stop('protocol_error');continue # GO authorizes verification, then recipe creation. Receipt # truth must retain that command even when hashing stops @@ -1118,6 +1187,12 @@ def preparation_checkpoint(): leader=os.fork() if leader==0: try: + # Arm before exec: a leader must never adopt a new + # parent if its qualified custodian dies in startup. + # The sealed ordinary executable has no privileged + # file transition that could clear this signal. + if libc.prctl(1,signal.SIGKILL,0,0,0) or os.getppid()!=who['pid']: + os._exit(126) os.setsid();os.chdir(spec['cwd']) os.dup2(input_fd,0);os.dup2(pipes['stdout'][1],1);os.dup2(pipes['stderr'][1],2) for entry in os.listdir('/proc/self/fd'): diff --git a/release_validation.py b/release_validation.py index 7a0b5e2a..d93792f0 100644 --- a/release_validation.py +++ b/release_validation.py @@ -10,10 +10,11 @@ import sequence_release import spatial_release import canonical_release +import copy import image_training import text_training import audio_training -from workbench import WorkbenchError, encode +from workbench import WorkbenchError, encode, text_value RECIPE = 'mesh_validate_export' SEQUENCE_RECIPE = 'sequence_validate_export' @@ -49,7 +50,7 @@ def inspect_release(path, release_id, recipe=RECIPE): 'split_counts': {split: sum(row['split'] == split for row in records) for split in ('train', 'validation', 'test')}, 'scope': 'Frozen classification export integrity only; no model training, predictions or quality qualification.'} - return manifest, summary + return manifest, decode_summary(summary,recipe,manifest=manifest,release_id=release_id) reader = sequence_release if recipe == SEQUENCE_RECIPE else mesh_release try: with Path(path).open('rb') as source: @@ -63,7 +64,107 @@ def inspect_release(path, release_id, recipe=RECIPE): summary = reader.validate_bytes(raw, release_id) with zipfile.ZipFile(io.BytesIO(raw)) as archive: manifest = canonical_release.decode(archive.read('manifest.json')) - return manifest, summary + return manifest, decode_summary(summary,recipe,manifest=manifest,release_id=release_id) + + +def decode_class_vocabulary(value,recipe,*,manifest=None): + """The existing export mapping remains data, including opaque image folders.""" + require=canonical_release.require + selected=None + for kind,adapter in (('image',image_training),('text',text_training),('audio',audio_training)): + if recipe in (kind+'_classification',kind+'_validate_export',adapter.MODEL_RECIPE):selected=kind + require(selected is not None,'classification vocabulary has no supported recipe.') + maximum=(image_training.MAX_CLASSES if selected=='image' else + text_training.projection.MAX_CLASSES if selected=='text' else audio_training.projection.MAX_CLASSES) + require(type(value) is list and 2<=len(value)<=maximum,'invalid classification vocabulary.') + for index,item in enumerate(value): + fields={'label','index','folder'} if selected=='image' else {'label','index'} + require(type(item) is dict and set(item)==fields,'unexpected class mapping fields.') + text_value(item['label'],'Class label',80) + require(type(item['index']) is int and item['index']==index,'class mapping index differs.') + if selected=='image':require(item['folder']==f'class_{index:06d}','opaque image class folder differs.') + if manifest is not None: + import image_classification_export + projection=image_classification_export if selected=='image' else ( + text_training.projection if selected=='text' else audio_training.projection) + rows=manifest['records'];expected,_coverage,_blockers=projection.inspect_projection(rows,{r['id']:r['split'] for r in rows}) + require(encode(value)==encode(expected),'class mapping differs from exact frozen labels.') + return copy.deepcopy(value) + + +def decode_summary(value,recipe,*,manifest,release_id): + """Bind a finite integrity DTO to its validated frozen source metadata. + + Native byte parsers remain the scientific/numeric owners. This projection + neither reads assets nor invents geometry, frames or voxels. Historical + explanatory text is preserved rather than rewritten to current wording. + """ + adapter_identity(recipe) + from workbench import text_value + require=canonical_release.require + require(type(value) is dict and type(manifest) is dict and type(manifest.get('records')) is list, + 'integrity summary needs its exact frozen manifest.') + rows=manifest['records'] + if recipe in CLASSIFICATION_RECIPES: + fields={'schema_version','format','kind','record_count','class_vocabulary','split_counts','scope'} + require(set(value)==fields,'unexpected classification integrity summary fields.') + text_value(value['scope'],'Integrity scope',2000) + decode_class_vocabulary(value['class_vocabulary'],recipe,manifest=manifest) + expected=dict(schema_version=1,format=manifest['format'],kind=recipe.split('_')[0],record_count=len(rows), + class_vocabulary=manifest['class_vocabulary'],split_counts={s:sum(row['split']==s for row in rows) + for s in canonical_release.SPLITS},scope=value['scope']) + else: + kind={RECIPE:'mesh',SEQUENCE_RECIPE:'sequence',POINTCLOUD_RECIPE:'pointcloud',VOLUME_RECIPE:'volume'}[recipe] + collection='meshes' if kind=='mesh' else kind+'s' + count_fields={'mesh':('vertex_count','triangle_count'),'sequence':('frame_count',), + 'pointcloud':('point_count',),'volume':('voxel_count',)}[kind] + fields={'schema_version','validation','release_sha256',kind+'_count','split_counts','family_scope','limitations',collection,*count_fields} + require(set(value)==fields,'unexpected whole-asset integrity summary fields.') + require(type(value['limitations']) is list and len(value['limitations'])<=100,'invalid integrity limitations.') + for limitation in value['limitations']:text_value(limitation,'Integrity limitation',4000) + summaries=[] + for row in rows: + require(row.get('kind')==kind,'integrity summary modality differs from its frozen source.') + metadata=row[kind];declared=metadata['manifest'] + if kind=='mesh': + from meshes import MeshAssets + MeshAssets.validate_project_metadata(metadata) + elif kind=='sequence': + from sequence_assets import SequenceAssets + SequenceAssets.validate_project_metadata(metadata) + else: + from spatial_assets import SpatialAssets + SpatialAssets.validate_project_metadata(kind,metadata) + item={key:row[key] for key in ('id','name','revision','source_revision','split','review')} + item.update(asset_sha256=row['asset_sha256'],units=declared['units'],provenance=declared['provenance'], + rights=row['provenance']['rights']) + if kind=='sequence': + item.update(run_sha256=metadata['run_sha256'],frames_sha256=declared['frames_sha256'], + frame_count=declared['frame_count'],geometry=declared['geometry'],field_types=declared['field_types'], + frame_index=metadata['frame_index'],technical_validation=metadata['technical_validation'], + pressure_semantics=declared['pressure_semantics']) + else: + item['manifest_sha256']=metadata['manifest_sha256'] + if kind in ('mesh','pointcloud'): + item.update(geometry_sha256=declared['geometry_sha256'],coordinate_system=declared['coordinate_system'], + **{key:metadata[key] for key in ('properties','bounds',*count_fields)}) + if kind=='pointcloud': + item.update(lineage=declared['lineage'],provided_normals=metadata['provided_normals'],provided_colors=metadata['provided_colors']) + else: + item.update(volume_sha256=declared['volume_sha256'],affine=declared['affine'],value_semantics=declared['value_semantics'], + array_axes=['z','y','x'],coordinate_contract='Affine maps voxel-center [x,y,z,1] to physical coordinates in declared units.', + **{key:metadata[key] for key in ('shape','dtype','order','voxel_count','spacing_xyz')}) + summaries.append(item) + expected=dict(schema_version=1,validation=kind+'_release_integrity',release_sha256=release_id, + split_counts={s:sum(row['split']==s for row in rows) for s in canonical_release.SPLITS}, + family_scope='selected_records_only',limitations=value['limitations'],**{kind+'_count':len(rows),collection:summaries}) + expected.update({key:sum(row[key] for row in summaries) for key in count_fields}) + # Existing canonical JSON bounds and scalar checks apply to both operands; + # exact comparison rejects unknown nested DTO fields without interpreting + # the source arrays a second time. + actual=canonical_release.decode(encode(value).encode()) + require(encode(actual)==encode(expected),'integrity summary differs from its exact frozen source metadata.') + return copy.deepcopy(actual) def definition_files(source_path): diff --git a/retained_analysis.py b/retained_analysis.py index 43f68880..8629c60a 100644 --- a/retained_analysis.py +++ b/retained_analysis.py @@ -47,6 +47,12 @@ LIMIT %s ''' +# Both readers share the exact curve expression and verification. The historical +# variant supplies bounded origin JSON and cannot insert a runnable Run row. +HISTORY_CURVE_SQL = CURVE_SQL.replace( + "FROM workspace_runs r\nCROSS JOIN LATERAL jsonb_array_elements(r.document::jsonb->'metrics'->'history') h", + "FROM jsonb_array_elements(%s::jsonb->'metrics'->'history') h").replace('WHERE r.id=%s\n','') + # Only bounded scalar rows enter this projection. Large probability arrays and # all original hash-bound bytes stay in managed evidence artifacts. EXAMPLES_SQL = ''' @@ -120,6 +126,14 @@ def _retained_run(db, request): _refuse('Retained Run document exceeds this query budget.', 'limit', 413) try: run = json.loads(stored['document']) + except (ValueError, TypeError): + _refuse('Retained Run envelope is unavailable.', 'unavailable', 409) + return validate_run_selection(run, request) + + +def validate_run_selection(run, request): + """Exact saved result selection shared by live and inert readers.""" + try: artifact = [item for item in run['artifacts'] if item['name'] == 'model-evidence.json'] state, run_id = run['state'], run['id'] actual_hash = artifact[0]['sha256'] if len(artifact) == 1 else None @@ -184,13 +198,56 @@ def _projection(evidence, request): return definition, checkpoints, raw.decode('ascii') -def query(runs, body, *, export=False): - """Read one exact result; callable by a later explicitly registered HTTP hook. +def _query_result(db, run, evidence, request, *, catalog_id, historical=False): + validate_run_selection(run, request) + run_id = run['id'] + definition, checkpoints, example_json = _projection(evidence, request) + curve_sql = HISTORY_CURVE_SQL if historical else CURVE_SQL + source = json.dumps(run,allow_nan=False) if historical else run_id + curve = [dict(row) for row in db.execute(curve_sql, + (canonical(checkpoints).decode('ascii'), request['metric_id'], source, MAX_CHECKPOINTS + 1))] + expected = {item['checkpoint_sha256']: item for item in evidence['observations'] + if item['metric_id'] == request['metric_id'] and item['split'] == 'validation'} + if len(curve) != len(checkpoints): + _refuse('Catalog curve coverage differs from retained checkpoints.') + for point in curve: + observation = expected.get(point['checkpoint_sha256']) + if (observation is None or observation['state'] != 'observed' + or point['epoch'] != observation['clocks']['epoch'] + or point['value'] != observation['value'] or point['support'] != observation['sample_count']): + _refuse('Catalog curve differs from retained metric observations.') + page = db.execute(EXAMPLES_SQL, (example_json, request['errors_only'], + request['limit'], request['offset'])).fetchone() + selected_rows = [{key: row[key] for key in (*request['fields'], 'ordinal', 'frozen_reference')} + for row in page['rows']] + provenance = {'catalog_id': catalog_id, 'run_id': run_id, + 'evaluation_sha256': request['evaluation_sha256'], + 'checkpoint_sha256': request['checkpoint_sha256'], + 'metric_definition_sha256': request['metric_definition_sha256'], + 'population_sha256': definition['population_sha256'], + 'probe_sha256': evidence['manifest']['probe']['sha256'], + 'data_sha256': evidence['manifest']['data_sha256'], + 'producer': evidence['manifest']['producer']} + count = page['filtered_support'] + result = {'state': 'verified', 'query_id': QUERY_ID, 'query_version': QUERY_VERSION, + 'mode': 'frozen', 'provenance': provenance, + 'metric': {'id': definition['id'], 'units': definition['units'], + 'definition': definition, 'support': len(evidence['probe']['members'])}, + 'curve': curve, 'columns': _columns(request['fields']), + 'examples': {'rows': selected_rows, 'population_support': len(evidence['probe']['members']), + 'filtered_support': count, 'offset': request['offset'], 'limit': request['limit'], + 'next_offset': request['offset'] + len(selected_rows) + if request['offset'] + len(selected_rows) < count else None, + 'filters': {'errors_only': request['errors_only']}}, + 'sampling': {'method': 'none', 'population': 'frozen_validation_probe'}, + 'selection_sha256': digest(request)} + if len(canonical(result)) > MAX_RESPONSE_BYTES: + _refuse('Retained analysis response exceeds this query budget.', 'limit', 413) + return result + - No cache or download URL is returned. An export must call this boundary - again. Final authorization happens outside the read snapshot, so revocation - committed during verification/query cannot be hidden by Repeatable Read. - """ +def query(runs, body, *, export=False): + """Current authorization surrounds exact retained reads and SQL projection.""" request = validate_request(body) if type(export) is not bool: raise TypeError('export must be a host-owned boolean') @@ -199,60 +256,35 @@ def query(runs, body, *, export=False): db = runs.db with db.lock.bounded(time.monotonic() + LOCK_SECONDS): runs.access.require('inspect', action, run_id) - # SET TRANSACTION must own its snapshot, never alter a caller's write - # transaction or check current revocation inside an inherited snapshot. if db.connection.info.transaction_status != TransactionStatus.IDLE: _refuse('Retained analysis requires a standalone read.', 'unavailable', 503) with _read_snapshot(db): _retained_run(db, request) evidence = runs.model_evidence(run_id, {'checkpoint_sha256': request['checkpoint_sha256']}, contract_version=1) - definition, checkpoints, example_json = _projection(evidence, request) with _read_snapshot(db): - _retained_run(db, request) - curve = [dict(row) for row in db.execute(CURVE_SQL, - (canonical(checkpoints).decode('ascii'), request['metric_id'], run_id, MAX_CHECKPOINTS + 1))] - expected = {item['checkpoint_sha256']: item for item in evidence['observations'] - if item['metric_id'] == request['metric_id'] and item['split'] == 'validation'} - if len(curve) != len(checkpoints): - _refuse('Catalog curve coverage differs from retained checkpoints.') - for point in curve: - observation = expected.get(point['checkpoint_sha256']) - if (observation is None or observation['state'] != 'observed' - or point['epoch'] != observation['clocks']['epoch'] - or point['value'] != observation['value'] or point['support'] != observation['sample_count']): - _refuse('Catalog curve differs from retained metric observations.') - page = db.execute(EXAMPLES_SQL, (example_json, request['errors_only'], - request['limit'], request['offset'])).fetchone() - selected_rows = [{key: row[key] for key in (*request['fields'], 'ordinal', 'frozen_reference')} - for row in page['rows']] - provenance = {'catalog_id': db.dataset_id, 'run_id': run_id, - 'evaluation_sha256': request['evaluation_sha256'], - 'checkpoint_sha256': request['checkpoint_sha256'], - 'metric_definition_sha256': request['metric_definition_sha256'], - 'population_sha256': definition['population_sha256'], - 'probe_sha256': evidence['manifest']['probe']['sha256'], - 'data_sha256': evidence['manifest']['data_sha256'], - 'producer': evidence['manifest']['producer']} - count = page['filtered_support'] - result = {'state': 'verified', 'query_id': QUERY_ID, 'query_version': QUERY_VERSION, - 'mode': 'frozen', 'provenance': provenance, - 'metric': {'id': definition['id'], 'units': definition['units'], - 'definition': definition, 'support': len(evidence['probe']['members'])}, - 'curve': curve, 'columns': _columns(request['fields']), - 'examples': {'rows': selected_rows, 'population_support': len(evidence['probe']['members']), - 'filtered_support': count, 'offset': request['offset'], 'limit': request['limit'], - 'next_offset': request['offset'] + len(selected_rows) - if request['offset'] + len(selected_rows) < count else None, - 'filters': {'errors_only': request['errors_only']}}, - 'sampling': {'method': 'none', 'population': 'frozen_validation_probe'}, - 'selection_sha256': digest(request)} - if len(canonical(result)) > MAX_RESPONSE_BYTES: - _refuse('Retained analysis response exceeds this query budget.', 'limit', 413) + run = _retained_run(db, request) + result = _query_result(db, run, evidence, request, catalog_id=db.dataset_id) runs.access.require('inspect', action, run_id) return result +def query_history(db, run, evidence, body, *, authorize, export=False): + """Query supplied immutable origin facts without an active workspace_runs row.""" + request = validate_request(body) + if type(export) is not bool: + raise TypeError('export must be a host-owned boolean') + action = 'runs.export_retained_analysis' if export else 'runs.retained_analysis' + authorize('inspect', action, run['id']) + with db.lock.bounded(time.monotonic() + LOCK_SECONDS): + if db.connection.info.transaction_status != TransactionStatus.IDLE: + _refuse('Retained analysis requires a standalone read.', 'unavailable', 503) + with _read_snapshot(db): + result = _query_result(db, run, evidence, request, catalog_id=run['dataset_id'],historical=True) + authorize('inspect', action, run['id']) + return result + + def _columns(fields): types = {'example_id': 'string', 'truth_index': 'integer', 'predicted_index': 'integer', 'confidence': 'number', 'error': 'boolean'} diff --git a/run_publication.py b/run_publication.py index d28a05b4..23a0fdb2 100644 --- a/run_publication.py +++ b/run_publication.py @@ -120,15 +120,15 @@ def _result_ref(ref, run): 'Recovered result reference differs.', 'stale') -def _source(runs, run, ref): - _require(ref == run.get('publication_source'), 'Exact original publication source is required.', 'stale') - directory = runs._diagnostic_directory(runs._external_directory(run)) - try: - raw, _, _ = runs._diagnostic_read(directory, 'publication-source-v1.json', MAX_DOCUMENT_BYTES) - finally: - os.close(directory) +def validate_publication_source(raw, *, run): + """Exact historical compute proof; never a publication continuation.""" + if type(raw) is not bytes or not 0 < len(raw) <= MAX_DOCUMENT_BYTES: + raise WorkbenchError('Publication source exceeds its byte bound.','invalid',409) + ref=run.get('publication_source') + _require(type(ref) is dict, 'Original publication source is unavailable.') _require(hashlib.sha256(raw).hexdigest() == ref['sha256'], 'Retained publication source changed.') - proof = runs._progress_json(raw) + from recipe_process import decode + proof = decode(raw) closed(proof, ('format', 'schema_version', 'catalog_id', 'run_id', 'request_sha256', 'material_sha256', 'graph_sha256', 'custody_receipt', 'inventory', 'inventory_sha256', 'evidence_semantics')) _require(proof['format'] == 'external_publication_source_v1' and type(proof['schema_version']) is int @@ -158,6 +158,17 @@ def _source(runs, run, ref): return proof + +def _source(runs, run, ref): + _require(ref == run.get('publication_source'), 'Exact original publication source is required.', 'stale') + directory = runs._diagnostic_directory(runs._external_directory(run)) + try: + raw, _, _ = runs._diagnostic_read(directory, 'publication-source-v1.json', MAX_DOCUMENT_BYTES) + finally: + os.close(directory) + return validate_publication_source(raw, run=run) + + def _copy(runs, directory, name, item, deadline, output=None): _check(runs, deadline) runs._copy_verified(directory, name, item, output, deadline=deadline, @@ -472,6 +483,30 @@ def cancel(runs, body): return _status(run) +def validate_publication_result(raw, *, run, source, ref, continuation): + """Validate a published historical result against its original compute proof.""" + if type(raw) is not bytes or not 0 < len(raw) <= MAX_DOCUMENT_BYTES: + raise WorkbenchError('Publication result exceeds its byte bound.','invalid',409) + _result_ref(ref, run) + _require(hashlib.sha256(raw).hexdigest() == ref['sha256'], 'Recovered result content changed.') + from recipe_process import decode + value = decode(raw) + closed(value, ('format', 'schema_version', 'catalog_id', 'run_id', 'request_sha256', 'material_sha256', + 'source_ref', 'continuation_request_sha256', 'inventory_sha256', 'custody_receipt_sha256', + 'artifacts', 'original_run_state', 'original_fault')) + manifest = _manifest(run, source) + _require(value.get('format') == 'recovered_publication_result_v1' + and type(value.get('schema_version')) is int and value['schema_version'] == 1 + and value['request_sha256'] == run['request_sha256'] + and value['continuation_request_sha256'] == continuation['request_sha256'] + and value['original_fault'] == run['publication_fault'] + and value.get('artifacts') == manifest['artifacts'] and value.get('source_ref') == run['publication_source'] + and value.get('original_run_state') == run['state'] + and all(value.get(key) == ref[key] for key in ('catalog_id', 'run_id', 'material_sha256', + 'inventory_sha256', 'custody_receipt_sha256')), 'Recovered result identity differs.') + return value + + @_reader_boundary def result(runs, ref, *, deadline=None, checkpoint=None, _verify_payloads=True): deadline = time.monotonic() + WALL_SECONDS if deadline is None else deadline @@ -500,21 +535,8 @@ def guard(): raw, _, _ = runs._diagnostic_read(directory, 'publication-result-v1.json', MAX_DOCUMENT_BYTES) finally: os.close(directory) - _require(hashlib.sha256(raw).hexdigest() == ref['sha256'], 'Recovered result content changed.') - value = runs._progress_json(raw) - closed(value, ('format', 'schema_version', 'catalog_id', 'run_id', 'request_sha256', 'material_sha256', - 'source_ref', 'continuation_request_sha256', 'inventory_sha256', 'custody_receipt_sha256', - 'artifacts', 'original_run_state', 'original_fault')) + value = validate_publication_result(raw, run=run, source=source, ref=ref, continuation=continuation) manifest = _manifest(run, source) - _require(value.get('format') == 'recovered_publication_result_v1' - and type(value.get('schema_version')) is int and value['schema_version'] == 1 - and value['request_sha256'] == run['request_sha256'] - and value['continuation_request_sha256'] == continuation['request_sha256'] - and value['original_fault'] == run['publication_fault'] - and value.get('artifacts') == manifest['artifacts'] and value.get('source_ref') == run['publication_source'] - and value.get('original_run_state') == run['state'] - and all(value.get(key) == ref[key] for key in ('catalog_id', 'run_id', 'material_sha256', - 'inventory_sha256', 'custody_receipt_sha256')), 'Recovered result identity differs.') destination = runs._diagnostic_directory(runs.path / run['id']) try: _require(set(os.listdir(destination)) == {item['name'] for item in value['artifacts']} | diff --git a/runs.py b/runs.py index 78d296a0..8d8a9398 100644 --- a/runs.py +++ b/runs.py @@ -42,6 +42,18 @@ _DOWNLOAD_PERMIT = threading.BoundedSemaphore(1) +def native_payload_names(run): + """The native owner inventory, shared with retained phase evidence readers.""" + if adapter_for_graph(run['graph']) is release_validation:return release_validation.PAYLOADS + if run['graph'].get('recipe') == image_training.MODEL_RECIPE: + import model_evidence + return model_evidence.payload_names(run['parameters']['epochs']) + if model_aware_graph(run['graph']): + from one_fit_evidence import payload_names + return payload_names() + return PAYLOADS if adapter_for_graph(run['graph']) is image_training else PAYLOADS - {'checkpoint.pt'} | {'model.json'} + + def process_identity(pid): """Linux start ticks plus boot ID distinguish an owned PID from PID reuse.""" try: @@ -79,11 +91,278 @@ def _sync(path): os.close(descriptor) +# These are local continuation mechanisms, not scientific observations. Exact +# specifications and helper identities inside receipts remain historical proof. +LOCAL_RUN_FIELDS = frozenset(('worker','supervisor','python','worker_command', + 'process_phase','release_path','publication_continuation')) +RUN_FIELDS = frozenset(('schema_version','mode','execution_kind','result_kind','id','dataset_id', + 'request_key','request_sha256','actor_id','admission_id','approval_id','state','created_at','started_at', + 'finished_at','graph','graph_revision','graph_sha256','graph_document_sha256','material','material_sha256', + 'spec','outputs','parameters','attempt','parent_run_id','events','artifacts','metrics','error', + 'custody_phase','custody_identity','custody_receipt_sha256','quality_claim','progress','node_attempts', + '_phase_timings','extension_context','approval_material_sha256','execution_resources','publication_source', + 'publication_fault','release_id','release_sha256','release_bytes','source_records','prior_test_exposure', + 'adapter','runtime','exposure_events','execution_test_exposure','evidence_context','validation_summary')) + +def project_run_projection(run, *, imported=False): + """Closed portable Run observation; local continuation is never imported.""" + from model_definitions import checked_json,natural,text + from pipeline import decode_graph + run=checked_json(run,40*1024**2) + unknown=set(run)-RUN_FIELDS-(frozenset() if imported else LOCAL_RUN_FIELDS) + if unknown: + raise WorkbenchError('Run contains unclassified retained fields: '+','.join(sorted(unknown)),'unsupported',409) + required={'schema_version','mode','id','dataset_id','request_key','request_sha256','state','created_at','started_at', + 'finished_at','graph','graph_revision','graph_sha256','graph_document_sha256','parameters','attempt','parent_run_id', + 'events','artifacts','metrics','error','quality_claim','progress','node_attempts','admission_id'} + external=run.get('execution_kind')=='external_recipe' + required.update(('execution_kind','result_kind','material','material_sha256','spec','outputs','actor_id','approval_id', + 'custody_phase','custody_identity','custody_receipt_sha256') if external else + ('release_id','release_sha256','release_bytes','source_records','prior_test_exposure','adapter','runtime','exposure_events')) + if not required<=set(run): + raise WorkbenchError('Historical Run is missing retained observation fields.','invalid',409) + identifier(run['dataset_id'],'Run origin');identifier(run['admission_id'],'Historical admission') + if type(run['request_key']) is not str or not REQUEST_KEY.fullmatch(run['request_key']): + raise WorkbenchError('Invalid historical request identity.') + if type(run['request_sha256']) is not str or not re.fullmatch('[a-f0-9]{64}',run['request_sha256']): + raise WorkbenchError('Invalid historical request digest.') + graph=decode_graph(run['graph']) + if run['graph_revision']!=graph['revision']: + raise WorkbenchError('Historical Run graph revision differs.') + natural(run['attempt'],'Attempt',1) + if run['parent_run_id'] is not None:identifier(run['parent_run_id'],'Retry parent Run') + for key in ('created_at','started_at','finished_at'): + if run[key] is not None:text(run[key],'Run observation time',80) + for key in ('events','artifacts','node_attempts'): + if type(run[key]) is not list or len(run[key])>100000: + raise WorkbenchError('Historical Run observations exceed their bound.') + if type(run['quality_claim']) is not bool or type(run['parameters']) is not dict or type(run['progress']) is not dict: + raise WorkbenchError('Historical Run observations differ.') + if 'execution_test_exposure' in run: + from pipeline import decode_exposures + decode_exposures(run['execution_test_exposure']) + if run['metrics'] is not None and type(run['metrics']) is not dict: + raise WorkbenchError('Historical Run metrics differ.') + seen=set() + for item in run['artifacts']: + closed(item,('name','sha256','bytes','media_type'),('role','split')) + if (type(item['name']) is not str or not re.fullmatch('[A-Za-z0-9][A-Za-z0-9_.-]{0,119}',item['name']) or + item['name'] in seen or type(item['sha256']) is not str or not re.fullmatch('[a-f0-9]{64}',item['sha256'])): + raise WorkbenchError('Historical Run artifact identity differs.') + seen.add(item['name']);natural(item['bytes'],'Artifact bytes');text(item['media_type'],'Artifact media type',120) + return {key:copy.deepcopy(item) for key,item in run.items() if key in RUN_FIELDS} + + +def validate_approved_composition(value, sha, graph, execution_material_sha256, *, catalog_id): + if value is None: + raise WorkbenchError('Approved composed material is unavailable.','unavailable',409) + closed(value,('ref','material'));material=value['material'] + if (digest(material)!=sha or value['ref']!={'kind':'recipe_material','catalog_id':catalog_id,'id':sha,'sha256':sha} or + material.get('format')!='recipe_material_v1' or material.get('catalog_id')!=catalog_id or + material.get('recipe_id')!=graph['id'] or material.get('recipe_revision')!=graph['revision'] or + material.get('executable_sha256')!=execution_material_sha256): + raise WorkbenchError('Approved composed material differs from execution.','unavailable',409) + return value + + +def validate_retained_run(run, *, catalog_id, run_id, request_key, request_sha256, + association=None, approval=None, composition=None, extension_context=None): + """Saved scientific and custody proof shared with inert project history. + + The caller resolves origin facts. This function reads no SQL, host path, + credentials, process state or runtime lease and grants no authority. + """ + identifier(run_id, 'Run ID') + try: + if (type(run['schema_version']) is not int or run['schema_version'] != 1 or run['id'] != run_id or run['dataset_id'] != catalog_id + or run['mode'] != 'real' or run['state'] not in (*ACTIVE, *TERMINAL) + or run['request_key'] != request_key or run['request_sha256'] != request_sha256 + or digest(run['graph']) != run['graph_document_sha256'] + or execution_digest(run['graph']) != run['graph_sha256'] + or (run.get('execution_kind') != 'external_recipe' and run['adapter'] != adapter_identity_for_graph(run['graph']))): + raise ValueError('Invalid retained run identity.') + if run.get('execution_kind') == 'external_recipe': + import recipe_contract + import recipe_process + # Absolute execution paths are historical evidence, not restore + # destinations. Never rewrite a spec bound by a custody receipt. + declared = run['spec']['cwd'] + if (type(declared) is not str or not Path(declared).is_absolute() + or '..' in Path(declared).parts or str(Path(declared)) != declared + or Path(declared).name != '.external-' + run['id'] + or Path(declared).parent.name != 'workspace-runs'): + raise ValueError('Invalid retained external staging location.') + admitted_context=extension_context + recipe_contract.validate_spec(run['material'], run['spec'], declared,context=admitted_context) + if (run['graph'].get('recipe') != 'external_recipe' + or run['material']['catalog_id'] != catalog_id + or digest(run['material']) != run['material_sha256'] + or run['material']['graph_execution_sha256'] != run['graph_sha256'] + or run['outputs'] != run['material']['recipe']['outputs'] + or run['spec']['attempt_id'] != run['id'] + or run['spec']['request_sha256'] != run['request_sha256'] + or run['custody_phase'] not in ('prepared', 'barrier_ready', 'go_intent', 'drained')): + raise ValueError('Invalid external execution identity.') + identity = run.get('custody_identity') + if 'publication_fault' in run or 'publication_continuation' in run: + import run_publication + run_publication.validate_continuation(run) + if 'publication_source' in run: + source = run['publication_source'] + if (type(source) is not dict or set(source) != {'kind', 'schema_version', 'catalog_id', 'run_id', 'sha256'} + or source['kind'] != 'external_publication_source' or type(source['schema_version']) is not int + or source['schema_version'] != 1 or source['catalog_id'] != run['dataset_id'] + or source['run_id'] != run['id'] or type(source['sha256']) is not str + or not re.fullmatch('[a-f0-9]{64}', source['sha256'])): + raise ValueError('Invalid retained publication source reference.') + if identity is not None and (identity.get('attempt_id') != run['id'] + or identity.get('request_sha256') != run['request_sha256'] + or identity.get('spec_sha256') != hashlib.sha256(recipe_process.encoded(run['spec'])).hexdigest()): + raise ValueError('Invalid external custodian identity.') + # A retained Study association owns this requirement: removing + # the JSON marker must not downgrade its Run to legacy custody. + if association is not None or 'approval_material_sha256' in run: + approved=run.get('approval_material_sha256') + if type(approved) is not str or not re.fullmatch('[a-f0-9]{64}',approved): + raise ValueError('Invalid composed approval identity.') + grant=approval + if (grant is None or grant['material_sha256']!=approved or grant['consumer_actor_id']!=run['actor_id'] or + grant['catalog_id']!=catalog_id or grant['run_id']!=run['id'] or + grant['admission_id']!=run['admission_id'] or grant['request_sha256']!=run['request_sha256']): + raise ValueError('Composed approval differs from its retained consumption.') + if (association is None or association['material_sha256']!=approved or association['actor_id']!=run['actor_id'] or + association['request_sha256']!=run['request_sha256']): + raise ValueError('Composed approval differs from its retained study association.') + validate_approved_composition(composition,approved,run['graph'],run['material_sha256'],catalog_id=catalog_id) + elif 'execution_kind' in run: + raise ValueError('Unknown execution kind.') + if model_aware_graph(run['graph']): + owner = model_contract(run['graph']) + context=run['evidence_context'] + if (context['adapter_sha256']!=run['runtime']['adapter_definition_sha256'] + or context['model_definition']!=owner.model_definition(run['parameters']) + or context['profile']!=owner.profile(catalog_id,context['model_sha256'],run['parameters'], + sum(row['split']=='validation' for row in run['source_records']))): + raise ValueError('Retained classifier evidence context differs.') + if association is not None: + if (run.get('actor_id')!=association['actor_id'] or run.get('approval_material_sha256')!=association['material_sha256'] + or context['material_sha256']!=association['material_sha256'] or run['request_sha256']!=association['request_sha256']): + raise ValueError('Built-in Study association differs from its authoritative receipt.') + validate_approved_composition(composition,association['material_sha256'],run['graph'],run['graph_sha256'],catalog_id=catalog_id) + elif run.get('approval_material_sha256') is not None: + raise ValueError('Unexpected built-in Study marker.') + return run + except (ValueError, KeyError, TypeError): + raise WorkbenchError('Stored run envelope is invalid; refusing to guess execution state.', 'unavailable', 409) from None + + +def public_run_projection(run): + """Common public fields; callers supply current input disclosure checks.""" + if run.get('execution_kind') == 'external_recipe': + keys = ('schema_version', 'mode', 'id', 'dataset_id', 'request_key', 'state', 'created_at', + 'started_at', 'finished_at', 'graph', 'graph_revision', 'graph_sha256', 'parameters', + 'attempt', 'parent_run_id', 'events', 'error', 'progress', 'node_attempts', + 'quality_claim', 'execution_kind', 'result_kind', 'material', 'material_sha256', + 'approval_id', 'actor_id', 'custody_phase') + return {key: copy.deepcopy(run[key]) for key in keys} + hidden = {'source_records', 'release_path', 'worker', 'python', 'request_sha256', 'graph_document_sha256', + 'worker_command', 'supervisor', 'process_phase', 'admission_id', 'evidence_context', '_phase_timings'} + return {key: copy.deepcopy(value) for key, value in run.items() if key not in hidden} + + +def decode_diagnostic_json(raw): + """Exact duplicate-free finite JSON used by live and inert custody readers.""" + def pairs(items): + result = {} + for key, value in items: + if key in result: + raise ValueError('Duplicate diagnostic field.') + result[key] = value + return result + return json.loads(raw, object_pairs_hook=pairs, + parse_constant=lambda value: (_ for _ in ()).throw(ValueError('Nonfinite diagnostic.'))) + + +def validate_external_artifact_inventory(run, receipt_raw, manifest_raw): + """Check retained output contract, custody and manifest without filesystem IO.""" + import recipe_contract + import recipe_process + if run['state'] != 'completed': + raise ValueError('Only completed Runs publish artifacts.') + definition = recipe_contract.decode_definition(encode(run['material']['recipe']).encode()) + allowed = {item['path']: item for item in definition['outputs']} + seen, total = set(), 0 + for item in run['artifacts']: + if (set(item) != {'name', 'role', 'bytes', 'sha256', 'media_type'} + or item['name'] not in allowed or item['name'] in seen): + raise ValueError('Invalid artifact inventory.') + rule = allowed[item['name']] + if (item['role'] != rule['role'] or type(item['bytes']) is not int + or not 0 <= item['bytes'] <= rule['max_bytes'] + or item['media_type'] != 'application/octet-stream' + or not isinstance(item['sha256'], str) or not re.fullmatch('[a-f0-9]{64}', item['sha256'])): + raise ValueError('Artifact differs from its approved contract.') + seen.add(item['name']) + total += item['bytes'] + if (any(rule['required'] and name not in seen for name, rule in allowed.items()) + or total > definition['resources']['max_output_bytes']): + raise ValueError('Incomplete or excessive artifact inventory.') + if hashlib.sha256(receipt_raw).hexdigest() != run['custody_receipt_sha256']: + raise ValueError('Custody receipt changed.') + receipt = recipe_process.validate_receipt(receipt_raw, run['custody_identity']) + if not receipt['drained'] or receipt['outcome'] != 'completed': + raise ValueError('Custody did not complete.') + manifest = decode_diagnostic_json(manifest_raw) + expected = {'schema_version': 1, 'run_id': run['id'], 'material_sha256': run['material_sha256'], + 'request_sha256': run['request_sha256'], 'custody_receipt_sha256': run['custody_receipt_sha256'], + 'artifacts': run['artifacts']} + if manifest != expected: + raise ValueError('Artifact manifest changed.') + return run['artifacts'] + + +def artifact_capture_source(run, item, raw, proof_raw): + """Derive the exact retained input receipt shared by live and inert readers.""" + import recipe_process + if run['state'] != 'completed': + raise WorkbenchError('Only completed Runs provide artifact inputs.', 'unavailable', 409) + if len(raw) != item['bytes'] or hashlib.sha256(raw).hexdigest() != item['sha256']: + raise WorkbenchError('Captured artifact differs from the Run inventory.', 'conflict', 409) + if run.get('execution_kind') == 'external_recipe': + if hashlib.sha256(proof_raw).hexdigest() != run['custody_receipt_sha256']: + raise WorkbenchError('Custody receipt changed during capture.', 'conflict', 409) + source = {'schema_version': 1, 'catalog_id': run['dataset_id'], 'run_id': run['id'], + 'attempt_id': run['spec']['attempt_id'], 'request_sha256': run['request_sha256'], + 'material_sha256': run['material_sha256'], 'artifact_name': item['name'], 'artifact_role': item['role'], + 'artifact_bytes': item['bytes'], 'measured_sha256': item['sha256'], + 'custody_receipt_sha256': run['custody_receipt_sha256'], + 'custody_identity': copy.deepcopy(run['custody_identity']), + 'custody_receipt_base64': base64.b64encode(proof_raw).decode('ascii')} + else: + if run.get('graph', {}).get('recipe') != 'image_classification_model_v2' or item['name'] == 'release.zip': + raise WorkbenchError('Capture requires qualified classifier evidence or an external Run.', 'unsupported', 409) + context = run['evidence_context'] + source = {'schema_version': 2, 'kind': 'builtin_classifier_publication_v1', + 'catalog_id': run['dataset_id'], 'run_id': run['id'], 'request_sha256': run['request_sha256'], + 'material_sha256': context['material_sha256'], 'graph_sha256': run['graph_sha256'], + 'release_sha256': run['release_sha256'], 'adapter_sha256': context['adapter_sha256'], + 'artifact_name': item['name'], 'artifact_role': 'model_evidence', 'artifact_bytes': len(raw), + 'measured_sha256': hashlib.sha256(raw).hexdigest(), + 'artifact_manifest_sha256': hashlib.sha256(proof_raw).hexdigest(), + 'artifact_manifest_base64': base64.b64encode(proof_raw).decode('ascii'), + 'population': 'test' if item['name'] in TEST_FILES else 'validation' if item.get('split') == 'validation' else 'train'} + try: + return recipe_process.validate_artifact_source(source, item['sha256'], len(raw)) + except recipe_process.ProcessError as error: + raise WorkbenchError('Retained artifact publication source differs.', 'unavailable', 409) from error + + class Runs: - def __init__(self, dataset, pipelines): + def __init__(self, dataset, pipelines, *, recover=True): self.dataset, self.pipelines = dataset, pipelines self.access = dataset.access self.db, self.lock = dataset.db, dataset.lock + self.project_execution = getattr(dataset, 'project_execution', None) self.path = dataset.path / 'workspace-runs' self.path.mkdir(exist_ok=True) self._wake, self._stop = threading.Event(), threading.Event() @@ -100,9 +379,11 @@ def __init__(self, dataset, pipelines): self._phase_book = phase_timings.Book() from run_capacity import Capacity self._capacity = Capacity.configured() + if self.project_execution is not None: + self.project_execution.attach_runs(self) with self.lock: self.runtime_owner = self.db.claim_runtime('workspace_runs') - if self.runtime_owner: + if self.runtime_owner and recover: self._recover() from run_coordinator import Coordinator self._coordinator = Coordinator(self) @@ -159,92 +440,23 @@ def _load(self, run_id): raise WorkbenchError('Run not found.', 'unavailable', 404) try: run = json.loads(row['document']) - if (run['schema_version'] != 1 or run['id'] != run_id or run['dataset_id'] != self.db.dataset_id - or run['mode'] != 'real' or run['state'] not in (*ACTIVE, *TERMINAL) - or run['request_key'] != row['request_key'] or run['request_sha256'] != row['request_sha256'] - or digest(run['graph']) != run['graph_document_sha256'] - or execution_digest(run['graph']) != run['graph_sha256'] - or (not self._external(run) and run['adapter'] != adapter_identity_for_graph(run['graph']))): - raise ValueError('Invalid retained run identity.') - if self._external(run): - import recipe_contract - import recipe_process - # Absolute execution paths are historical evidence, not restore - # destinations. Never rewrite a spec bound by a custody receipt. - declared = run['spec']['cwd'] - if (type(declared) is not str or not Path(declared).is_absolute() - or '..' in Path(declared).parts or str(Path(declared)) != declared - or Path(declared).name != '.external-' + run['id'] - or Path(declared).parent.name != 'workspace-runs'): - raise ValueError('Invalid retained external staging location.') - admitted_context=self._extension_context(run) if self._extension(run) else None - recipe_contract.validate_spec(run['material'], run['spec'], declared,context=admitted_context) - if (run['graph'].get('recipe') != 'external_recipe' - or run['material']['catalog_id'] != self.db.dataset_id - or digest(run['material']) != run['material_sha256'] - or run['material']['graph_execution_sha256'] != run['graph_sha256'] - or run['outputs'] != run['material']['recipe']['outputs'] - or run['spec']['attempt_id'] != run['id'] - or run['spec']['request_sha256'] != run['request_sha256'] - or run['custody_phase'] not in ('prepared', 'barrier_ready', 'go_intent', 'drained')): - raise ValueError('Invalid external execution identity.') - identity = run.get('custody_identity') - if 'publication_fault' in run or 'publication_continuation' in run: - import run_publication - run_publication.validate_continuation(run) - if 'publication_source' in run: - source = run['publication_source'] - if (type(source) is not dict or set(source) != {'kind', 'schema_version', 'catalog_id', 'run_id', 'sha256'} - or source['kind'] != 'external_publication_source' or type(source['schema_version']) is not int - or source['schema_version'] != 1 or source['catalog_id'] != run['dataset_id'] - or source['run_id'] != run['id'] or type(source['sha256']) is not str - or not re.fullmatch('[a-f0-9]{64}', source['sha256'])): - raise ValueError('Invalid retained publication source reference.') - if identity is not None and (identity.get('attempt_id') != run['id'] - or identity.get('request_sha256') != run['request_sha256'] - or identity.get('spec_sha256') != hashlib.sha256(recipe_process.encoded(run['spec'])).hexdigest()): - raise ValueError('Invalid external custodian identity.') - association=self.db.execute('SELECT t.material_sha256,r.actor_id,r.request_sha256 FROM model_attempt_reservations r ' - 'JOIN model_trials t ON t.id=r.trial_id WHERE r.run_id=%s', - (run['id'],)).fetchone() - # A retained Study association owns this requirement: removing - # the JSON marker must not downgrade its Run to legacy custody. - if association is not None or 'approval_material_sha256' in run: - approved=run.get('approval_material_sha256') - if type(approved) is not str or not re.fullmatch('[a-f0-9]{64}',approved): - raise ValueError('Invalid composed approval identity.') - grant=self.db.execute('SELECT g.material_sha256,g.consumer_actor_id,g.catalog_id,' - 'c.run_id,c.admission_id,c.request_sha256 FROM access_execution_grants g ' - 'JOIN access_execution_consumptions c ON c.grant_id=g.id WHERE g.id=%s', - (run['approval_id'],)).fetchone() - if (grant is None or grant['material_sha256']!=approved or grant['consumer_actor_id']!=run['actor_id'] or - grant['catalog_id']!=self.db.dataset_id or grant['run_id']!=run['id'] or - grant['admission_id']!=run['admission_id'] or grant['request_sha256']!=run['request_sha256']): - raise ValueError('Composed approval differs from its retained consumption.') - if (association is None or association['material_sha256']!=approved or association['actor_id']!=run['actor_id'] or - association['request_sha256']!=run['request_sha256']): - raise ValueError('Composed approval differs from its retained study association.') - self._approved_composition(approved,run['graph'],run['material_sha256']) - elif 'execution_kind' in run: - raise ValueError('Unknown execution kind.') - if model_aware_graph(run['graph']): - owner = model_contract(run['graph']) - context=run['evidence_context'] - if (context['adapter_sha256']!=run['runtime']['adapter_definition_sha256'] - or context['model_definition']!=owner.model_definition(run['parameters']) - or context['profile']!=owner.profile(self.db.dataset_id,context['model_sha256'],run['parameters'], - sum(row['split']=='validation' for row in run['source_records']))): - raise ValueError('Retained classifier evidence context differs.') - association=self.db.execute('SELECT r.study_id,r.trial_id,r.actor_id,r.request_sha256,t.material_sha256 ' - 'FROM model_attempt_reservations r JOIN model_trials t ON t.id=r.trial_id WHERE r.run_id=%s',(run_id,)).fetchone() - if association is not None: - if (run.get('actor_id')!=association['actor_id'] or run.get('approval_material_sha256')!=association['material_sha256'] - or context['material_sha256']!=association['material_sha256'] or run['request_sha256']!=association['request_sha256']): - raise ValueError('Built-in Study association differs from its authoritative receipt.') - self._approved_composition(association['material_sha256'],run['graph'],run['graph_sha256']) - elif run.get('approval_material_sha256') is not None: - raise ValueError('Unexpected built-in Study marker.') - return run + association=self.db.execute('SELECT r.study_id,r.trial_id,r.actor_id,r.request_sha256,t.material_sha256 ' + 'FROM model_attempt_reservations r JOIN model_trials t ON t.id=r.trial_id WHERE r.run_id=%s', + (run_id,)).fetchone() + approval=composition=context=None + if self._external(run) and (association is not None or 'approval_material_sha256' in run): + approval=self.db.execute('SELECT g.material_sha256,g.consumer_actor_id,g.catalog_id,' + 'c.run_id,c.admission_id,c.request_sha256 FROM access_execution_grants g ' + 'JOIN access_execution_consumptions c ON c.grant_id=g.id WHERE g.id=%s', + (run['approval_id'],)).fetchone() + if association is not None or 'approval_material_sha256' in run: + sha=run.get('approval_material_sha256') + material_sha=run['material_sha256'] if self._external(run) else run['graph_sha256'] + composition=self._approved_composition(sha,run['graph'],material_sha) + if self._extension(run):context=self._extension_context(run) + return validate_retained_run(run,catalog_id=self.db.dataset_id,run_id=run_id, + request_key=row['request_key'],request_sha256=row['request_sha256'], + association=association,approval=approval,composition=composition,extension_context=context) except (ValueError, KeyError, TypeError): raise WorkbenchError('Stored run envelope is invalid; refusing to guess execution state.', 'unavailable', 409) from None @@ -352,6 +564,8 @@ def _save(self, run): else: evidence['release_sha256'] = run['release_sha256'] self.access.audit(action, run['id'], evidence) + if self.project_execution is not None: + self.project_execution.mark_run(run['id']) @staticmethod def _external(run): @@ -423,12 +637,7 @@ def _public(self, run): if self._external(run): from candidate_packages import authorize_transitive_inputs authorize_transitive_inputs(self,run) - keys = ('schema_version', 'mode', 'id', 'dataset_id', 'request_key', 'state', 'created_at', - 'started_at', 'finished_at', 'graph', 'graph_revision', 'graph_sha256', 'parameters', - 'attempt', 'parent_run_id', 'events', 'error', 'progress', 'node_attempts', - 'quality_claim', 'execution_kind', 'result_kind', 'material', 'material_sha256', - 'approval_id', 'actor_id', 'custody_phase') - result = {key: copy.deepcopy(run[key]) for key in keys} + result = public_run_projection(run) if 'execution_resources' in run: result['execution_resources'] = copy.deepcopy(run['execution_resources']) if 'approval_material_sha256' in run:result['approval_material_sha256']=run['approval_material_sha256'] @@ -437,9 +646,7 @@ def _public(self, run): result['artifacts'] = [dict(item, locked=False, url=f"/api/workspace/runs/{run['id']}/artifacts/{item['name']}") for item in run['artifacts']] return result - hidden = {'source_records', 'release_path', 'worker', 'python', 'request_sha256', 'graph_document_sha256', - 'worker_command', 'supervisor', 'process_phase', 'admission_id', 'evidence_context', '_phase_timings'} - result = {key: copy.deepcopy(value) for key, value in run.items() if key not in hidden} + result = public_run_projection(run) association=self.db.execute('SELECT study_id,trial_id FROM model_attempt_reservations WHERE run_id=%s',(run['id'],)).fetchone() if association is not None or run['graph'].get('recipe')==image_training.MODEL_RECIPE: result['study_membership']=dict(association) if association is not None else None @@ -611,10 +818,18 @@ def submit_external(self, body, *, _graph_guard=None): raise if receipt: return self._reconcile_receipt(receipt) - self._wake_external(run_id) + # A project adapter may own a surrounding canonical publication TX. + # Only its acknowledged outer COMMIT can make this queue dispatchable. + if self.db.in_transaction: + self.db.on_commit(lambda: self._wake_external(run_id)) + else: + self._wake_external(run_id) return self._public(run) def _wake_external(self, run_id): + if self.db.in_transaction: + self.db.on_commit(lambda: self._wake_external(run_id)) + return with self.lock: self._confirmed_external.add(run_id) self._start() @@ -623,13 +838,8 @@ def _approved_composition(self,sha,graph,execution_material_sha256): from model_definitions import retained_json row=self.db.execute('SELECT document FROM workspace_recipe_materials WHERE sha256=%s',(sha,)).fetchone() if row is None:raise WorkbenchError('Approved composed material is unavailable.','unavailable',409) - value=retained_json(row[0]);closed(value,('ref','material'));material=value['material'] - if (digest(material)!=sha or value['ref']!={'kind':'recipe_material','catalog_id':self.db.dataset_id,'id':sha,'sha256':sha} or - material.get('format')!='recipe_material_v1' or material.get('catalog_id')!=self.db.dataset_id or - material.get('recipe_id')!=graph['id'] or material.get('recipe_revision')!=graph['revision'] or - material.get('executable_sha256')!=execution_material_sha256): - raise WorkbenchError('Approved composed material differs from execution.','unavailable',409) - return value + return validate_approved_composition(retained_json(row[0]),sha,graph,execution_material_sha256, + catalog_id=self.db.dataset_id) @staticmethod def _extension(run): @@ -748,6 +958,8 @@ def _admit_external_prepared(self, body, captured, run_id, staging, *, (run_id, key, request_hash, encode(run))) self.access.audit('runs.admit_external', run_id, {'material_sha256': run['material_sha256'], 'approved_material_sha256':approved_sha,'approval_id': grant_id, 'request_sha256': request_hash}) + if self.project_execution is not None: + self.project_execution.mark_run(run_id) if wake_on_commit: self.db.on_commit(lambda: self._wake_external(run_id)) return run @@ -755,6 +967,110 @@ def _admit_external_prepared(self, body, captured, run_id, staging, *, def _external_directory(self, run): return self.path / ('.external-' + run['id']) + def _capture_project_payloads(self, run, artifacts): + """Publish fixed current-local Run files before its SQL acknowledgement. + + The existing rooted Projects reader owns all path walking. Nothing in an + imported Run creates this reader or selects its current local directory. + Artifact custody makes its own verified immutable copy of each FD. + """ + from projects import Projects + if self.project_execution is None or not self.db.in_transaction: + raise RuntimeError('Project Run capture requires its current execution transaction.') + selected = [] + edges = [] + if self._external(run): + for name, item in run['material']['captures'].items(): + selected.append((name, '.external-' + run['id'] + '/' + name, item, + 'captured_input' if name.startswith('input/') else 'execution_source', 'unknown')) + else: + edges = self.project_execution.native.capture_run_payloads(run, artifacts) + for item in run['artifacts']: + selected.append((item['name'], run['id'] + '/' + item['name'], item, + item.get('role', 'output'), item.get('split') or 'unknown')) + if run['state'] == 'completed': + proofs = (('custody-receipt.json', 16384), ('artifact-manifest.json', 65536)) if self._external(run) else ( + ('artifact-manifest.json', 65536),) + for name, limit in proofs: + if name in {item['name'] for item in run['artifacts']}:continue + raw = self._read_regular(self.path / run['id'] / name, limit) + selected.append((name, run['id'] + '/' + name, + dict(sha256=hashlib.sha256(raw).hexdigest(), bytes=len(raw)), 'custody_proof', 'unknown')) + elif run.get('custody_identity') is not None and run.get('custody_phase') == 'drained': + raw = self._read_regular(self._external_directory(run) / 'custody/receipt.json', 16384) + import recipe_process + receipt = recipe_process.validate_receipt(raw, run['custody_identity']) + if not receipt['drained']: + raise WorkbenchError('Current custodian drainage is unconfirmed.', 'unknown', 409) + selected.append(('custody-receipt.json', '.external-' + run['id'] + '/custody/receipt.json', + dict(sha256=hashlib.sha256(raw).hexdigest(), bytes=len(raw)), 'custody_proof', 'unknown')) + if run.get('publication_source') is not None: + raw = self._read_regular(self._external_directory(run) / 'publication-source-v1.json', MAX_PUBLICATION_SOURCE_BYTES) + if hashlib.sha256(raw).hexdigest() != run['publication_source']['sha256']: + raise WorkbenchError('Current publication proof changed.', 'stale', 409) + selected.append(('publication-source-v1.json', '.external-' + run['id'] + '/publication-source-v1.json', + dict(sha256=run['publication_source']['sha256'],bytes=len(raw)), 'publication_proof', 'unknown')) + if self._external(run):edges.extend(self._capture_project_host_sources(run, artifacts)) + reader = Projects(self.dataset, {'current_runs': self.path}) + try: + for name, relative, item, role, population in selected: + declaration = dict(format='project_artifact_v1', sha256=item['sha256'], bytes=item['bytes']) + with reader._file('current_runs', relative) as (descriptor, info): + artifacts.publish_fd(descriptor, declaration) + edges.append(dict(name=name, blob={key:declaration[key] for key in ('sha256','bytes')}, + population=population, role=role, preservation='unique', + required_for=['scientific_inspection','execution_proof'])) + finally: + reader.close() + return edges + + def _capture_project_host_sources(self, run, artifacts): + """Retain the qualified host once, then use only its immutable bytes. + + An older Run without these edges stays unavailable: current source files + cannot supply retrospective evidence for a historical host identity. + """ + import recipe_contract + from projects import Projects + maximum = 4 * 1024 * 1024 + records = self.project_execution.session.snapshot['records'] + prior = next((record['document'] for record in records + if record['family'] == 'run_history' + and record['document']['origin_catalog_id'] == run['dataset_id'] + and record['document']['run']['id'] == run['id']), None) + expected = run['material']['host_files'] + if prior is not None: + edges = [] + for name, sha in sorted(expected.items()): + edge = next((edge for edge in prior['payloads'] if edge['name'] == 'host/' + name), None) + if (edge is None or edge['blob']['sha256'] != sha or edge['role'] != 'host_source' + or edge['preservation'] != 'unique' + or edge['required_for'] != ['historical_host_source']): + raise WorkbenchError('Retained Run host source is unavailable.', 'unavailable', 409) + with artifacts.open_verified(dict(format='project_artifact_v1', **edge['blob']), + maximum_bytes=maximum): + pass + edges.append(copy.deepcopy(edge)) + return edges + if (run['state'] != 'queued' or run['custody_phase'] != 'prepared' + or run.get('custody_identity') is not None): + raise WorkbenchError('Only a newly admitted current Run can capture its host source.', 'unavailable', 409) + if recipe_contract.recipe_host_identity(run['material']['recipe']) != expected: + raise WorkbenchError('Current execution host differs from its admitted identity.', 'stale', 409) + reader = Projects(self.dataset, {'current_host': Path(recipe_contract.__file__).resolve().parent}) + edges = [] + try: + for name, sha in sorted(expected.items()): + with reader._file('current_host', name) as (descriptor, info): + declaration = dict(format='project_artifact_v1', sha256=sha, bytes=info.st_size) + artifacts.publish_fd(descriptor, declaration, maximum_bytes=maximum) + edges.append(dict(name='host/' + name, blob={key:declaration[key] for key in ('sha256','bytes')}, + population='unknown', role='host_source', preservation='unique', + required_for=['historical_host_source'])) + finally: + reader.close() + return edges + def _external_drain_receipt(self, run): import recipe_process identity = run.get('custody_identity') @@ -816,15 +1132,7 @@ def _read_regular(path, limit): @staticmethod def _progress_json(raw): - def pairs(items): - result = {} - for key, value in items: - if key in result: - raise ValueError('Duplicate diagnostic field.') - result[key] = value - return result - return json.loads(raw, object_pairs_hook=pairs, - parse_constant=lambda value: (_ for _ in ()).throw(ValueError('Nonfinite diagnostic.'))) + return decode_diagnostic_json(raw) @staticmethod def _progress_text(value, limit, empty=False): @@ -1117,7 +1425,10 @@ def _execute_external(self, run_id): self._save(run) if recipe_contract.recipe_host_identity(run['material']['recipe']) != run['material']['host_files']: raise WorkbenchError('Recipe host changed after approval. Reopen and review a new attempt.', 'stale', 409) + if self.project_execution is not None:self.project_execution.before_launch(run_id) handle = recipe_process.launch(run['spec'], self._external_directory(run) / 'custody') + if self.project_execution is not None: + self.project_execution.register_custody(run_id, handle) clock.finish(launch_span) preparation_seconds = recipe_process.preparation_seconds(run['spec']) ready_deadline = time.monotonic() + preparation_seconds + 2 @@ -1127,6 +1438,7 @@ def _execute_external(self, run_id): while not terminal: for event in handle.poll(): if event['type'] == 'ready': + custody_deadline = time.monotonic() + recipe_process.GO_WAIT_SECONDS + recipe_process.CLEANUP_SECONDS + 5 with self.lock, self.db.transaction(): run = self._load(run_id) run['custody_identity'] = copy.deepcopy(handle.expected_identity) @@ -1230,37 +1542,9 @@ def _external_inventory(self, run, *, checkpoint=None): raise ValueError('Only completed Runs publish artifacts.') if directory.is_symlink() or not directory.is_dir(): raise ValueError('Invalid artifact directory.') - definition = recipe_contract.decode_definition(encode(run['material']['recipe']).encode()) - allowed = {item['path']: item for item in definition['outputs']} - seen, total = set(), 0 - for item in run['artifacts']: - if (set(item) != {'name', 'role', 'bytes', 'sha256', 'media_type'} - or item['name'] not in allowed or item['name'] in seen): - raise ValueError('Invalid artifact inventory.') - rule = allowed[item['name']] - if (item['role'] != rule['role'] or type(item['bytes']) is not int - or not 0 <= item['bytes'] <= rule['max_bytes'] - or item['media_type'] != 'application/octet-stream' - or not isinstance(item['sha256'], str) or not re.fullmatch('[a-f0-9]{64}', item['sha256'])): - raise ValueError('Artifact differs from its approved contract.') - seen.add(item['name']) - total += item['bytes'] - if (any(rule['required'] and name not in seen for name, rule in allowed.items()) - or total > definition['resources']['max_output_bytes']): - raise ValueError('Incomplete or excessive artifact inventory.') receipt_raw, _, _ = self._diagnostic_read(descriptor, 'custody-receipt.json', 16384) - if hashlib.sha256(receipt_raw).hexdigest() != run['custody_receipt_sha256']: - raise ValueError('Custody receipt changed.') - receipt = recipe_process.validate_receipt(receipt_raw, run['custody_identity']) - if not receipt['drained'] or receipt['outcome'] != 'completed': - raise ValueError('Custody did not complete.') manifest_raw, _, _ = self._diagnostic_read(descriptor, 'artifact-manifest.json', 65536) - manifest = self._progress_json(manifest_raw) - expected = {'schema_version': 1, 'run_id': run['id'], 'material_sha256': run['material_sha256'], - 'request_sha256': run['request_sha256'], 'custody_receipt_sha256': run['custody_receipt_sha256'], - 'artifacts': run['artifacts']} - if manifest != expected: - raise ValueError('Artifact manifest changed.') + validate_external_artifact_inventory(run, receipt_raw, manifest_raw) if set(os.listdir(descriptor)) != {item['name'] for item in run['artifacts']} | {'artifact-manifest.json','custody-receipt.json'}: raise ValueError('Published directory inventory differs.') for item in run['artifacts']: @@ -1513,6 +1797,9 @@ def _admit(self, key, request_hash, graph, validation, release, parent=None, *, 'Real CPU attempt admitted. Training starts from scratch; no model download.') self.db.execute('INSERT INTO workspace_runs(id,request_key,request_sha256,document) VALUES(%s,%s,%s,%s)', (run_id, key, request_hash, encode(run))) + if self.project_execution is not None: + self.project_execution.native.bind_run(run) + self.project_execution.mark_run(run_id) return run @authorized('execute', 'runs.retry', owner_only=True) @@ -1557,6 +1844,9 @@ def cancel(self, run_id): return self._public(run) def _start(self): + if self.db.in_transaction: + self.db.on_commit(self._start) + return with self._thread_lock: if self._closed or self._stop.is_set(): return @@ -1671,6 +1961,7 @@ def _loop(self): def _run_attempt(self, admitted, cpus, claim): affinity = os.sched_getaffinity(0) + if self.project_execution is not None:self.project_execution.attempt_started(admitted['id']) try: # Linux affinity belongs to this executor thread. Children inherit it; # other attempts and the HTTP/coordinator threads remain independent. @@ -1709,6 +2000,7 @@ def _run_attempt(self, admitted, cpus, claim): self._coordinator.fault('attempt_reconciliation_storage') finally: os.sched_setaffinity(0, affinity) + if self.project_execution is not None:self.project_execution.attempt_finished(admitted['id']) self._wake.set() def _execute(self, run_id): @@ -1716,6 +2008,8 @@ def _execute(self, run_id): selected = self._load(run_id) if self._external(selected): return self._execute_external(run_id) + if self.project_execution is not None: + return self.project_execution.native.execute_run(run_id) process = owner = None with self.lock, self.db.transaction(): run = self._load(run_id) @@ -2046,15 +2340,7 @@ def _fit_count(self, run): return run['parameters']['epochs'] if adapter_for_graph(run['graph']) is image_training else 1 def _payloads(self, run): - if self._validation_run(run): - return release_validation.PAYLOADS - if run['graph'].get('recipe') == image_training.MODEL_RECIPE: - import model_evidence - return model_evidence.payload_names(run['parameters']['epochs']) - if model_aware_graph(run['graph']): - from one_fit_evidence import payload_names - return payload_names() - return PAYLOADS if adapter_for_graph(run['graph']) is image_training else PAYLOADS - {'checkpoint.pt'} | {'model.json'} + return native_payload_names(run) def _reference_keys(self, run): adapter = adapter_for_graph(run['graph']) @@ -2247,20 +2533,7 @@ def capture_artifact(self, run_id, name, expected_sha256, *, max_bytes=MAX_ARTIF raise WorkbenchError('Artifact differs from expected SHA256.', 'conflict', 409) raw, _media_type = self.artifact(run_id, name) manifest_raw = self._read_regular(self.path / run_id / 'artifact-manifest.json', 65536) - context = run['evidence_context'] - source = {'schema_version': 2, 'kind': 'builtin_classifier_publication_v1', - 'catalog_id': run['dataset_id'], 'run_id': run_id, 'request_sha256': run['request_sha256'], - 'material_sha256': context['material_sha256'], 'graph_sha256': run['graph_sha256'], - 'release_sha256': run['release_sha256'], 'adapter_sha256': context['adapter_sha256'], - 'artifact_name': name, 'artifact_role': 'model_evidence', 'artifact_bytes': len(raw), - 'measured_sha256': hashlib.sha256(raw).hexdigest(), - 'artifact_manifest_sha256': hashlib.sha256(manifest_raw).hexdigest(), - 'artifact_manifest_base64': base64.b64encode(manifest_raw).decode('ascii'), - 'population': 'test' if name in TEST_FILES else 'validation' if item.get('split') == 'validation' else 'train'} - try: - recipe_process.validate_artifact_source(source, expected_sha256, len(raw)) - except recipe_process.ProcessError as error: - raise WorkbenchError('Classifier publication source differs.', 'unavailable', 409) from error + source = artifact_capture_source(run, item, raw, manifest_raw) return raw, source if type(max_bytes) is not int or not 0 < max_bytes <= MAX_ARTIFACT_BYTES: raise WorkbenchError('Invalid artifact input byte bound.') @@ -2273,20 +2546,7 @@ def capture_artifact(self, run_id, name, expected_sha256, *, max_bytes=MAX_ARTIF receipt_raw = self._read_regular(self.path / run_id / 'custody-receipt.json', 16384) if hashlib.sha256(receipt_raw).hexdigest() != run['custody_receipt_sha256']: raise WorkbenchError('Custody receipt changed during capture.', 'conflict', 409) - try: - receipt = recipe_process.validate_receipt(receipt_raw, run['custody_identity']) - except recipe_process.ProcessError as error: - raise WorkbenchError('Retained custody receipt cannot be verified.', 'unavailable', 409) from error - if not receipt['drained'] or receipt['outcome'] != 'completed': - raise WorkbenchError('Run custody did not complete.', 'unavailable', 409) - source = {'schema_version': 1, 'catalog_id': run['dataset_id'], - 'run_id': run['id'], 'attempt_id': run['spec']['attempt_id'], - 'request_sha256': run['request_sha256'], 'material_sha256': run['material_sha256'], - 'artifact_name': item['name'], 'artifact_role': item['role'], - 'artifact_bytes': item['bytes'], 'measured_sha256': item['sha256'], - 'custody_receipt_sha256': run['custody_receipt_sha256'], - 'custody_identity': copy.deepcopy(run['custody_identity']), - 'custody_receipt_base64': base64.b64encode(receipt_raw).decode('ascii')} + source = artifact_capture_source(run, item, raw, receipt_raw) return raw, source def artifact_segments(self, body, *, _cancelled=None, _delivery_guard=None): diff --git a/scientific_fields.py b/scientific_fields.py index 6174bb89..12065585 100644 --- a/scientific_fields.py +++ b/scientific_fields.py @@ -280,7 +280,11 @@ def validate_source(method, source, raw): Run evidence establishes internal custody relationships. It does not prove historical database authority or reproduce the full approved material. """ - digest = hashlib.sha256(raw).hexdigest() + return validate_source_declaration(method,source,hashlib.sha256(raw).hexdigest(),len(raw)) + + +def validate_source_declaration(method,source,digest,size): + """Retained custody declarations do not require or grant live authority.""" if method == 'project_data_import': exact(source, ('project_id', 'manifest_sha256', 'reference_id', 'path', 'measured_sha256'), 'Project source') if type(source['project_id']) is not str or not re.fullmatch('[a-f0-9]{32}', source['project_id']): @@ -293,7 +297,7 @@ def validate_source(method, source, raw): elif method == 'run_artifact_import': import recipe_process try: - recipe_process.validate_artifact_source(source, digest, len(raw)) + recipe_process.validate_artifact_source(source, digest, size) except recipe_process.ProcessError as error: invalid(str(error), error.code, 409 if error.code == 'conflict' else 400) else: @@ -303,6 +307,15 @@ def validate_source(method, source, raw): return dict(source) +def validate_project_metadata(value): + exact(value,('schema_version','format','manifest','frame_count','field_count','source_bytes','first_observed_state_sha256','protected_groups','scope'),'Retained field metadata') + if type(value['schema_version']) is not int or value['schema_version']!=1 or value['format']!=FORMAT:invalid('Unsupported retained field metadata.','unsupported') + manifest_contract(value['manifest']) + if type(value['frame_count']) is not int or value['frame_count']!=len(value['manifest']['frames']) or type(value['field_count']) is not int or value['field_count']!=len(value['manifest']['fields']):invalid('Retained field counts differ.') + if type(value['source_bytes']) is not int or not 1<=value['source_bytes']<=BUNDLE_LIMIT or type(value['first_observed_state_sha256']) is not str or not HASH.fullmatch(value['first_observed_state_sha256']):invalid('Invalid retained field byte/state identity.') + if strings(value['protected_groups'],'Field groups')!=value['protected_groups'] or len(value['protected_groups'])!=3 or value['scope']!=SCOPE:invalid('Retained field group/scope differs.') + + class ScientificAssets: def __init__(self, workbench): self.access = workbench.access @@ -352,6 +365,9 @@ def _admit(self, raw, body, method, source): def _capture(self, record_id): if type(record_id) is not str or not re.fullmatch('[a-f0-9]{32}', record_id): invalid('Invalid scientific record identity.') + resolver = getattr(self.workbench.dataset, 'project_asset', None) + if resolver is not None and record_id in self.workbench.dataset.project_sources: + return resolver(record_id, 'field_sequence') try: path = self.path / record_id / 'source.zip' if path.is_symlink() or path.parent.is_symlink(): diff --git a/sequence_assets.py b/sequence_assets.py index 173f3360..f13539fe 100644 --- a/sequence_assets.py +++ b/sequence_assets.py @@ -7,6 +7,28 @@ class SequenceAssets(ImmutableAssets): + @staticmethod + def validate_project_acquisition(value, metadata): + from sequence_batch_import import FORMAT, MAX_ITEMS, marker, label + from workbench import WorkbenchError + fields={'format','request_id','batch_name','item_name','item_index','run_sha256','frames_sha256'} + if type(value) is not dict or set(value)!=fields or value['format']!=FORMAT: + raise WorkbenchError('Unsupported retained sequence acquisition fields.','unsupported',409) + marker(value['request_id']);label(value['batch_name'],'Batch label');label(value['item_name'],'Trajectory label') + if type(value['item_index']) is not int or not 1<=value['item_index']<=MAX_ITEMS or value['run_sha256']!=metadata['run_sha256'] or value['frames_sha256']!=metadata['manifest']['frames_sha256']: + raise WorkbenchError('Retained sequence acquisition differs from its source.') + + @staticmethod + def prepare_project_files(files): + """Prepare bounded source files behind the sequence owner's facade.""" + from workbench import WorkbenchError + import rheon_sequences as source + if type(files) is not dict or set(files) != {'run.json', 'frames.jsonl'}: + raise WorkbenchError('Supply exactly run.json and frames.jsonl sequence files.') + return source.prepare( + source.decode_file(files['run.json'], source.contract.MANIFEST_LIMIT, 'run.json'), + source.decode_file(files['frames.jsonl'], source.contract.FRAMES_LIMIT, 'frames.jsonl')) + def __init__(self, workbench): super().__init__(workbench, kind='sequence', task='sequence_transport', maximum=MAX_BUNDLE_BYTES, default_name='Rheon transport sequence') @@ -19,3 +41,56 @@ def admit(self, prepared, body, *, acquisition=None): if acquisition is not None: origin['sequence_acquisition'] = acquisition return super().admit(prepared, body, origin) + + def validate_project_source(self, bundle, metadata): + """Retained sequence decoding stays behind this existing owner.""" + import io + import zipfile + from workbench import WorkbenchError + import rheon_sequences + try: + with zipfile.ZipFile(io.BytesIO(bundle)) as archive: + if archive.namelist() != ['run.json', 'frames.jsonl'] or sum(i.file_size for i in archive.infolist()) > MAX_BUNDLE_BYTES: + raise ValueError() + prepared = rheon_sequences.prepare(archive.read('run.json'), archive.read('frames.jsonl')) + expected = dict(metadata); expected.pop('bundle_bytes', None) + if prepared['metadata'] != expected: + raise ValueError() + except (ValueError, KeyError, zipfile.BadZipFile): + raise WorkbenchError('Retained sequence metadata differs from its original bundle.') from None + + @staticmethod + def validate_project_metadata(value): + import math + import re + from immutable_assets import validate_project_metadata_fields + from workbench import WorkbenchError + import rheon_sequence_contract as contract + validate_project_metadata_fields(value,{'adapter','manifest','run_sha256','frame_index','technical_validation','protected_groups','scope'},MAX_BUNDLE_BYTES) + contract.validate_manifest(value['manifest']) + if type(value['adapter']) is not dict or type(value['run_sha256']) is not str or not re.fullmatch('[a-f0-9]{64}',value['run_sha256']):raise WorkbenchError('Invalid retained sequence identity.') + frames=value['frame_index'] + if type(frames) is not list or len(frames)!=9:raise WorkbenchError('Invalid retained sequence frame index.') + offset=0 + for index,frame in enumerate(frames): + if type(frame) is not dict or set(frame)!={'frame','time_s','dt_s','carrier_stamp','liquid_stamp','diagnostics','byte_offset','byte_length','sha256'}:raise WorkbenchError('Invalid retained sequence index fields.') + contract.integer(frame['frame'],index,index,'frame');contract.number(frame['time_s'],'time');contract.number(frame['dt_s'],'interval') + if type(frame['byte_offset']) is not int or frame['byte_offset']!=offset or type(frame['byte_length']) is not int or not 1<=frame['byte_length']<=contract.LINE_LIMIT or type(frame['sha256']) is not str or not contract.HEX256.fullmatch(frame['sha256']):raise WorkbenchError('Invalid retained sequence byte index.') + offset+=frame['byte_length'] + for stamp in (frame['carrier_stamp'],frame['liquid_stamp']): + if type(stamp) is not dict or set(stamp)!={'id','version'} or any(type(n) is not str or not contract.U64.fullmatch(n) or int(n)>=1<<64 for n in stamp.values()):raise WorkbenchError('Invalid retained sequence stage stamp.') + diagnostics=frame['diagnostics'] + if index==0: + if diagnostics is not None:raise WorkbenchError('Initial sequence frame cannot have interval diagnostics.') + else: + if type(diagnostics) is not dict or set(diagnostics)!=contract.DIAGNOSTICS:raise WorkbenchError('Invalid retained sequence diagnostics.') + contract.integer(diagnostics['pressure_iterations'],0,10000,'Pressure iterations') + for name in contract.DIAGNOSTICS-{'pressure_iterations'}:contract.number(diagnostics[name],name) + if offset!=value['manifest']['frames_bytes']:raise WorkbenchError('Sequence index byte total differs.') + technical=value['technical_validation'] + if type(technical) is not dict or set(technical)!={'schema','version','frames','accepted_steps','time_s','volume_m3','mass_kg','centroid_x_m','all_frame_volumes_m3','maxima','limitations'}:raise WorkbenchError('Invalid retained sequence validation summary.') + if technical['schema']!=contract.SCHEMA or type(technical['version']) is not int or technical['version']!=1 or type(technical['frames']) is not int or technical['frames']!=9 or type(technical['accepted_steps']) is not int or technical['accepted_steps']!=8:raise WorkbenchError('Retained sequence summary profile differs.') + for key in ('time_s','volume_m3','mass_kg','centroid_x_m'):contract.number(technical[key],key) + if type(technical['all_frame_volumes_m3']) is not list or len(technical['all_frame_volumes_m3'])!=9 or any(type(n) not in (int,float) or not math.isfinite(n) for n in technical['all_frame_volumes_m3']):raise WorkbenchError('Invalid retained sequence volume observations.') + if type(technical['maxima']) is not dict or set(technical['maxima'])!={'divergence_s_inv','binomial_fraction_error','velocity_error_m_s','first_pressure_error_pa'}:raise WorkbenchError('Invalid retained sequence diagnostics.') + for number in technical['maxima'].values():contract.number(number,'diagnostic') diff --git a/spatial_assets.py b/spatial_assets.py index c7d54510..99f16543 100644 --- a/spatial_assets.py +++ b/spatial_assets.py @@ -3,6 +3,7 @@ import base64 import io import json +import re import zipfile from immutable_assets import ImmutableAssets @@ -86,6 +87,32 @@ def validated(self, record, bundle): except (ValueError, zipfile.BadZipFile, KeyError, RuntimeError, UnicodeError): raise WorkbenchError('Spatial source or metadata changed outside Tuldok.', 'conflict', 409) from None + def validate_project_source(self, bundle, metadata): + self.validated({self.kind: metadata}, bundle) + + @staticmethod + def validate_project_metadata(kind,value): + import math + from immutable_assets import validate_project_metadata_fields,validate_project_bounds + parser,_,_=profile(kind) + fields={'manifest','manifest_sha256','protected_groups','scope'} + fields|= {'properties','point_count','bounds','provided_normals','provided_colors'} if kind=='pointcloud' else {'source_sha256','shape','dtype','order','voxel_count','spacing_xyz'} + validate_project_metadata_fields(value,fields,parser.BUNDLE_LIMIT) + parser.manifest_declaration(value['manifest']) + if type(value['manifest_sha256']) is not str or not re.fullmatch('[a-f0-9]{64}',value['manifest_sha256']):raise WorkbenchError('Invalid retained spatial manifest digest.') + if kind=='pointcloud': + validate_project_bounds(value['bounds']) + if type(value['point_count']) is not int or not 1<=value['point_count']<=parser.POINT_LIMIT:raise WorkbenchError('Invalid retained point count.') + properties=value['properties'] + if type(properties) is not list or any(type(p) is not dict or set(p)!={'name','dtype'} for p in properties):raise WorkbenchError('Invalid retained point properties.') + names=[p['name'] for p in properties];xyz=['x','y','z'];normals=['nx','ny','nz'];colors=['red','green','blue'] + if names not in (xyz,xyz+normals,xyz+colors,xyz+normals+colors):raise WorkbenchError('Invalid retained point property order.') + if any(p['dtype'] not in (('uchar',) if p['name'] in colors else ('float','double')) for p in properties):raise WorkbenchError('Invalid retained point property dtype.') + if type(value['provided_normals']) is not bool or type(value['provided_colors']) is not bool or value['provided_normals']!=('nx' in names) or value['provided_colors']!=('red' in names):raise WorkbenchError('Retained point flags differ.') + else: + manifest=value['manifest'] + if value['shape']!=manifest['shape'] or value['source_sha256']!=manifest['volume_sha256'] or value['dtype']!='uint8' or value['order']!='C' or type(value['voxel_count']) is not int or value['voxel_count']!=math.prod(manifest['shape']) or value['spacing_xyz']!=parser._affine(manifest['affine'],manifest['shape']):raise WorkbenchError('Retained volume metadata differs.') + @authorized('inspect', 'spatial_assets.asset', target=lambda record: record.get('id') if isinstance(record, dict) else None) def asset(self, record): bundle, mime = super().asset(record) diff --git a/static/project-files.css b/static/project-files.css new file mode 100644 index 00000000..515d9ca5 --- /dev/null +++ b/static/project-files.css @@ -0,0 +1,12 @@ +:root{font-family:system-ui,sans-serif;color:#1c2929;background:#f3f5ef;font-size:16px;line-height:1.5}*{box-sizing:border-box}body{margin:0}main{max-width:1200px;margin:auto;padding:2rem 1.5rem 4rem}header{max-width:800px;margin-bottom:2rem}h1{font-size:2.5rem;line-height:1.15;letter-spacing:-.04em}h2{margin-top:0}.eyebrow{font-size:.8rem;text-transform:uppercase;letter-spacing:.12em;color:#375957}section{padding:1.5rem;background:#fff;border:1px solid #c6d0c7;border-radius:12px}label{display:block;font-weight:600;margin:.75rem 0 .3rem}input,textarea,select,button{font:inherit}input,textarea,select{display:block;width:100%;margin-top:.3rem;padding:.6rem;border:1px solid #7d9288;border-radius:5px;color:inherit;background:#fff}textarea{resize:vertical}button{padding:.6rem .9rem;border:1px solid #315d52;border-radius:5px;background:#edf3ee;color:#183c32;cursor:pointer}button:hover:enabled{background:#dceae0}button:disabled{opacity:.55;cursor:default}button[aria-current=true]{background:#204f43;color:white}.actions{display:flex;flex-wrap:wrap;gap:.6rem;margin:1rem 0}.project-location{display:grid;grid-template-columns:1fr 2fr 2fr;gap:1rem}.workspace-grid{display:grid;grid-template-columns:1fr 1.2fr;gap:1.2rem;margin-top:1.5rem}.help{font-size:.9rem;color:#43564f;margin-top:.3rem}.scope{padding:1rem;border-left:4px solid #527467}fieldset{border:0;margin:0;padding:0}legend{font-weight:600}#records-list{padding:0;list-style:none}#records-list li{margin:.5rem 0}#records-list button{width:100%;text-align:left}#project-files-error{border:2px solid #9c2f29;background:#fff0eb;padding:1rem;color:#76241f}#project-files-status{min-height:1.5rem}#project-files-summary,#project-files-receipt,#project-files-error{overflow-wrap:anywhere}#record-history{padding-left:1.5rem}#record-history pre{white-space:pre-wrap;overflow-wrap:anywhere}#record-heading{scroll-margin-top:1rem}:focus-visible{outline:3px solid #b35208;outline-offset:3px}.skip-link{position:absolute;left:1rem;top:-5rem;padding:.7rem;background:white;z-index:3}.skip-link:focus{top:1rem}.session-bar{padding:.65rem 1.5rem;border-bottom:1px solid #c6d0c7;background:#fff}@media(max-width:760px){.workspace-grid,.project-location{grid-template-columns:1fr}main{padding:1.2rem .8rem}h1{font-size:2rem}section{padding:1rem}} + +.project-history-panel{margin-top:1.5rem}.project-fact-list{list-style:none;padding:0;max-height:42rem;overflow:auto}.project-fact-list>li{padding:.65rem 0;border-bottom:1px solid #dce4de}.project-fact-list button{width:100%;text-align:left}.project-facts{overflow-wrap:anywhere}.project-facts dl{margin:.4rem 0 .8rem;border-left:2px solid #dce4de;padding-left:.7rem}.project-facts dt{font-weight:600;text-transform:capitalize}.project-facts dd{margin:0 0 .6rem;white-space:pre-wrap;max-height:18rem;overflow:auto}.project-facts details{margin:.5rem 0}.project-facts summary{cursor:pointer;font-weight:600}.project-history-panel form{margin:1rem 0}.project-history-panel legend{padding-top:.5rem} +/* History rows share the detail viewer's hash wrapping and intrinsic width. */ +#project-detail-history-list{overflow-wrap:anywhere} +.check-row{display:flex;gap:.6rem;align-items:flex-start}.check-row input{width:auto;flex:none;margin-top:.35rem} + +#project-working-summary,#project-save-as-summary,#project-fork-summary,#project-working-list,#project-working-history-list,#project-fork-list,#project-intents-list,#project-intent-detail{overflow-wrap:anywhere;min-width:0}#project-intent-detail{white-space:pre-wrap;max-height:32rem;overflow:auto}#project-working-text{white-space:pre;overflow:auto} + +#project-runtime-files,#project-runtime-summary,#project-runtime-pending,#project-runtime-layout,#project-runtime-status{overflow-wrap:anywhere;min-width:0}#project-runtime-layout,#project-runtime-status{white-space:pre-wrap;max-height:32rem;overflow:auto} + +#project-candidate-preparation-result,#project-candidate-preparation-status{overflow-wrap:anywhere;min-width:0}#project-candidate-preparation-result{white-space:pre-wrap;max-height:32rem;overflow:auto} diff --git a/static/project-files.html b/static/project-files.html new file mode 100644 index 00000000..59a847a0 --- /dev/null +++ b/static/project-files.html @@ -0,0 +1,330 @@ + + + + + + Text projects · Tuldok + + + + + + + + +
+

Tuldok / external projects

Text project workspace

+

Create or open a folder under a configured local root. Submitted text and annotations are saved before acknowledgement.

+
+

Reading project status…

+ + +
+

Project file

+

No project status available.

+
+ + + +
+

Use a relative directory within this root. New creates a new directory; Open reads an existing project. Close the current project before opening another.

+
+ + + + + + +
+

No unsubmitted edits.

+
+
+

Prepare a Candidate package or verifier

+

Choose a current Study, Candidate and captured CPU runtime. Preparation saves an inert Trial and exact sources; approval and Run admission remain separate actions.

+
Explicit Candidate preparation + + + + + + + +


+      
Resolve this installation's pending preparation +

These actions require the original current local custody marker. Imported intent or completion history does not authorize reconciliation. Neither action deletes staging files or starts a Run.

+ + + + +
+
+
+

Capture a current CPU runtime

+

An operator configures the source aliases on this installation. Inspect the selected source, review its exact inventory, then explicitly capture it. These controls copy bytes without executing Python, installing packages or granting permission to run a project.

+ + + +

    +
    Capture reviewed inventory + + + +

    + +
    Abandon this installation's stopped partial capture +

    This requires its original current local custody marker. The decision preserves every partial file, retained intent and scientific fact; imported history does not authorize it.

    + + + +
    +
    Inspect retained capture facts + +

    This outcome is historical. “Captured” does not establish that a current local runtime is present or ready. Reopening a project does not capture or provision it.

    
    +    
    +
    +

    Save As an independent project

    +

    Use a new directory. Historical results keep their original identities; editable definitions receive new identities. Changed working files and unresolved current work must be resolved first.

    +
    + Fork destination + + + +

    +
    +
    +

    Scripts and notes

    +

    Inspect files already adopted into this project, or explicitly adopt exact current UTF-8 bytes. Captured scripts are displayed as text and never run by these controls.

    +
    +
      +
      Adopt current file bytes + + + + + +

      This picker calculates a digest in your browser. Adoption reads the existing file at the relative project path; it does not upload or replace it.

      +
      +
      Adopt a missing note as a deletion +

      Inspect and select a known missing note first. This action records its absence and keeps all captured revisions.

      + + +
      +

      Select a captured file revision

      +

      + +
        +
        +
        +

        Fork origins and bindings

        +
        +

            +

            Select an unresolved binding

            Review its original parameters and binding fields below. Recreate a current definition with the named model or graph controls; this history never rebinds or grants execution.

            +
            
            +    
            +

            Authored edits are saved to the project before acknowledgement. Historical materials and artifacts remain inspectable when payloads are missing. Execution requires explicit current local permission through available controls. Independent Save As and explicit working-file adoption are available when their negotiated controls are enabled. Migration remains unavailable.

            +
            +
            +

            Author text

            +
            + New text record + + + +

            Comma-separated group IDs; keep at least one. These preserve source grouping.

            + + +
            +

            Text records

            +
              + +
              +
              +

              Select a text record

              +

              + +

              Source text is immutable. Submit a new record to author different text.

              +
              + Annotation and human review + + + +

              Enter one classification label, up to 80 characters.

              + + + +
              +

              Saved annotation history

              +
                + +
                +
                +
                +

                Project data and scientific history

                +

                Inspect saved records, models, Studies, Runs and their exact artifact availability. Opening a project never starts computation or restores permission to disclose held-out data.

                +
                + + + +
                + +
                +

                +
                +
                  +
                  +

                  Choose a saved record

                  +
                  + +
                  +
                    + + + + + + + +
                    +
                    +
                    +
                    +

                    Add data and saved intent

                    +
                    +
                    Import an image or audio source + + + + + + +
                    +
                    Save the entered data search +

                    Save the search text and data kind entered above. This records intent; it does not freeze a dataset.

                    + +
                    +
                    +
                    +
                    +

                    Create a model definition

                    +

                    Choose an available template from this installation. Saving a definition and publishing its source material never start training.

                    + +
                    New model + +
                    +
                    +
                    +

                    Plan a Study

                    +

                    Save scientific selection criteria and finite budget metadata. Continue and admit prepared Trials explicitly through current Study execution controls when available.

                    +
                    New Study + + +

                    Use the exact saved metric definition from retained evidence. This does not create or infer a metric.

                    +
                    + +
                    + +
                    +
                    +
                    +

                    Import declared scientific data

                    +

                    Choose a local source manifest, then one declared data reference. Tuldok reads the exact existing bytes without executing project code.

                    +
                    Register a local source +

                    The local root selected at the top of this page applies.

                    + +
                    +
                    Import a declared reference + + +
                    +
                    +
                    +

                    Current local execution

                    +

                    Review the exact current recipe before granting permission. Approvals apply to this installation and actor. Saved historical admissions and approvals never restart execution when a project is opened.

                    +
                    +

                    +
                    Review current recipe + +
                    +
                    Approve reviewed material locally + + + + +
                    +
                    Start explicitly approved execution

                    No current local approval selected.

                    +
                    Cancel a current local Run
                    +
                    +
                    +

                    Current native CPU execution

                    +

                    These explicit owner actions use this installation's operator-selected Python environment. Installed packages remain operator trust; a successful probe does not establish model quality or a complete software closure. Opening saved history never probes or starts a Run.

                    + +
                    Validate with a current native probe + + + +
                    +
                    Start the validated native Run + + +
                    +
                    Retry a current native attempt + + + +
                    +
                    Reconcile an original current phase + + +
                    + +

                    Use “Refresh current custody” above to inspect original local phase IDs. Select a phase below, then explicitly reconcile it.

                      +

                      +
                      +
                      +

                      Current Study execution

                      +

                      Select a Study in the scientific history view, then load its current epoch and prepared Trials. Closing an epoch saves cancellation intent; it does not prove that its child processes have ceased.

                      +

                      +
                      Open a current epoch
                      + +
                      Approve this Trial locally
                      +
                      Admit a Trial under the current budget
                      +
                      Close the current epoch
                      +
                      +
                      +

                      Current model producers

                      +

                      These actions contact the model endpoint you choose now. Captured requests and results remain historical facts; opening them never contacts a model or resumes a queue.

                      +

                      +
                      Start an explicit model request + +

                      Select an image or text record in saved data first.

                      + + + + + + + + + +
                      +

                      +
                      Continue a current image queue
                      +
                      Review a caption or class proposal
                      +
                      Review a grounded candidate
                      +
                      Cancel this current local producer
                      +
                      +
                      + + diff --git a/static/project-files.js b/static/project-files.js new file mode 100644 index 00000000..4357888c --- /dev/null +++ b/static/project-files.js @@ -0,0 +1,1823 @@ +'use strict'; +// One page owns this projection. The server owns project state and every edit. +(()=>{ + const $=id=>document.getElementById(id), base='/api/workspace/project-files'; + const actions=['new','open','save','close','query','get','import_text','annotate']; + const tasks=['text_classification','text_entities','text_corpus']; + // Generated from agent_api; freshness is checked by the client contract tests. + const pins = /* PROJECT_FILES_CONTRACTS_START */ +{ + "project_files.annotate": "70feae2ad14c49c4ae95b5bb69b4a50fbf93191cde840544cf89b6cbe8dc5c58", + "project_files.artifact_resolve": "0df477e294b4e0729a707d7dfb6038414fc354e1ae50155eaacfa82b3a55f51f", + "project_files.artifact_status": "8dd0343f5f882c96cf023f8274734ca57175ae986e82b3bb1188cb5864784834", + "project_files.artifacts": "20a4031d189058721d09b8bf694ea9b1583eea410afd381f58fe6c70d0a0c339", + "project_files.candidate": "c519ff04bc06d5e538d36b7f278862d8f282d0e7b97dd7f262444c384a50f854", + "project_files.candidate_select": "f043be6923760a208ca50d02a1ff4b60eadf09d48a034acdb18749806a537080", + "project_files.close": "95d0099ec2a378a1bbb65676799dd3baae2f36b838301f7aa165b116fba5edf6", + "project_files.data_annotate": "289270f5cb946765c5ced22fbbd18765bb55743145014c81cf54d7a6ba3025ea", + "project_files.data_availability": "92332c04973a4fa45d47e384717152ebc4a9ca08e7eb487669688ccd2ed42cbb", + "project_files.data_get": "0d19505995cbd8c12e3938cd89cf05955a2a63488a220711d29dd24b27c0c8c8", + "project_files.data_history": "b9e9e13389befe5f531d33d4d4d893649cb076f3eff98aa6ee85a4c46f03148a", + "project_files.data_import_audio": "51b511a068bed6ec024bd70bf08a48a23a94906362d54e0f3eb1223e325c237e", + "project_files.data_import_image": "e9f86ce62d29d267090c7955e0deb084f6ad6a936d1418baf5d807aa6d84ea3e", + "project_files.data_import_mesh": "840d6e1d6ab326d144b63e275d60d304277b5d4867c9c170c6dde5e117b46107", + "project_files.data_import_pointcloud": "c171b1342a5844fc1862947e20ae675395b8c3e24b44bff1179e0e8e6df70f53", + "project_files.data_import_scientific": "a39ce46384bb2f0c3cdb9a6bbf881316ce166a26208fc41b4a88bb307af32d63", + "project_files.data_import_sequence": "da8b91c9fd76d951fddc33acf6cca56287e0d7a29319b46032629b777af1f1ee", + "project_files.data_import_text": "c2db90c749154cb5489e13494d5a4404fdaf5a14895679fe2075fdfdc39e899d", + "project_files.data_import_volume": "c6e509d797970afd7da9e2069c29736e290ada8e7034326b31e1cb707aa572e2", + "project_files.data_preference_delete": "900538c7d8f74f04ed93387a864c8235b4162902345f6864b8d325527f63da5f", + "project_files.data_preference_history": "5ee7d60ce5e4aa09ea3496e9212b3404820d619cc2d47425faa94a3506d9ad07", + "project_files.data_preference_save": "eacdaa8b32cac556f8a591c4ca3f1191d83182623bd425a5619e1c6920c17782", + "project_files.data_preferences": "35b9bff0cba8bf453d446a8b62a6d003cc49f6abb8a12b55bfb76a112c3f2067", + "project_files.data_query": "0a7585ed5c41ed28eee2505e95773741a7af82c74b5265b8ed4ebf2beb72e394", + "project_files.data_response_history": "1bd46a162efae7213ff7c8a7b77dfe843e952a629fd7c24af3b5298e04140224", + "project_files.data_response_save": "fa5b92afe529520fe8206d629bd88085a68f4f9b399cd40dd05fd3948d3592f0", + "project_files.data_responses": "3e0c009244cad04fca4356cf3647bfd2b42cfc0d6a2ac31bb8de25cdb3c60ec8", + "project_files.data_rights": "ead16cca68b03533daa81bf06148e1918ce40dbc823ee060bd9908581f788653", + "project_files.data_source_delete": "457618e618cca92a71b668ffb9f68b0db87e51c417c2e5f41dcde76d71a23985", + "project_files.data_source_save": "0d50c45d445b2ce1014a696c3bc95965f0b548ded939f4a650f985f34e1ce849", + "project_files.definitions": "3516fcf8764382780c7da699218a05ff1f811634656cc81722ef547f21159698", + "project_files.dependencies": "615e173c6e7cb811dc5e51ef6c368975b4dd06de14d6cf69fbd8c4bb01e3f9e4", + "project_files.evidence": "440d7dba608d633a4a42479018360ef2fa6609a47148e7edfdf5246f84c020ef", + "project_files.execution_approve_external": "b35cec1a8bc5faad9e437ee04b6fb3bc9d575e8b7c4dd0444401fa8f551fa301", + "project_files.execution_cancel": "5fff1a68bfaeefcf426c573f4f128e76c96163171f31580922be3178080ffcd0", + "project_files.execution_generation_resume": "cfadef988f4510fcea1e64e1a8a4ca60794bec26a2d1c116b35e678971cc33ae", + "project_files.execution_grounded_review": "de971104eebe46acb67edfb77f1c0df98e8302528ef446ad51c772c13e67458b", + "project_files.execution_health": "c3b1d0017f5a49cf69c3a4c5c33e1d3db9846404d12627563d97ef40de7404a5", + "project_files.execution_preflight_external": "f754d712ac7da823375408fbcec63f7006c972a6e0885d69bae6772f2b850fea", + "project_files.execution_producer_cancel": "af114f7ed941f80ec565577863a6fffc4ec1c7d7069af6715350e8489526fd61", + "project_files.execution_producer_decide": "38bd7d1a1a052d62f6fc74cc16fcaf9e3c243f97dc3c39e38cdef03ca272a5d8", + "project_files.execution_producer_health": "297c6160a07cef60a8d4a50a690805f30a75b7e1bf6543e6da3a7befcb133b7e", + "project_files.execution_producer_start": "e7ee70a06346026980ba0fbf808b6d61ff3e7ac57a6619d72cd1b8b77e9662f7", + "project_files.execution_reconcile_current": "5ed3f998f053fd6333804e604f758c58e1b862c6f92f4de4272495916cef77de", + "project_files.execution_study_abandon_preparation": "650e7f85ea3ff476448422077ecc51fc06e270e975967886d5a6c726aee5af73", + "project_files.execution_study_admit": "381cacfa7582db321520cbc0d0e0be6bd187e1e3ff98cdbde371244b5994adba", + "project_files.execution_study_approve": "0a49e0d1b8e789a96969b73565f127d5cbb2c6cc39fea40fff33029f2ddbd7e2", + "project_files.execution_study_continue": "d421a59a95fb2a6b8d4e65d652000a13177054d34bafff3d0a3e30eeae00fb75", + "project_files.execution_study_prepare_package": "6687819b4cad1fa89ca1ec874422ea04f52cbc3685691dc3c612f122933c89b2", + "project_files.execution_study_prepare_verifier": "1c28024ab764c1f5747f17b90e73a95fb1666be4f94af040f376ec6241e12421", + "project_files.execution_study_reconcile_preparation": "c5a301fd6e3159de684f5700d557ccd13dd704e617d4779015e9b5ca93d76075", + "project_files.execution_study_stop": "84d103019c80b8e7bd3b59e0259c3947de4f4f48cc05c3076cf16112d8671911", + "project_files.execution_submit_external": "fc33d4eac346c1f08423ccb5d593db43b561124629d53f2a8896ff9838db99f3", + "project_files.expose_holdout": "d69478ed54267ee1f1966f423f412a32f90b95a55997c6dc7d8348e3cdd78fd7", + "project_files.file_adopt": "a190326d5c54296f8359be594996908fa28acbbd125168821c3fa1f520ef9b07", + "project_files.file_adopt_deletion": "7b6d661856a614d1b83d8011d1ffc7f9e2619853495faf9250888739f880685c", + "project_files.file_get": "a22c27e2fd890ffcb479e0aa8ea40a0cb32d06d1e5750b970b40f27915fe4d24", + "project_files.file_history": "c83477f7b0f8616c96ad79cebaf0eb3887fe289567f5d5ceb1d1502bebed7423", + "project_files.files_inspect": "0c2ff3ed93109ea19b5c92ffa24ac8f67e4bcfa3fe386921e984b5dfa3bfe7eb", + "project_files.fork_info": "958bc1aa269b5459c7e2d9c6bcb6725c06f7055502d73c49a95330cc4065aea4", + "project_files.get": "6b0f4e16a00aa119b86cbb5493be0bac945ff08a5e63773a8f2c2f9296658d5b", + "project_files.graph_clone": "8f9b460530f0f136900734345fc338a1993ffdaddf5f4d90faf352c39f397514", + "project_files.graph_create": "8fcb6d573b849ac03b5ee0ae192f274c11b9b5303451410ee46013f4cb0d83dc", + "project_files.graph_create_external": "93d88731f66062c9d0ac15d38897e731e25e39df7f9b141e5f3277f2d07b4205", + "project_files.graph_get": "77eea46e63758f0ca172cd23595cf5a42fe9820dbc82f3aefb7f5b6c22dc23d9", + "project_files.graph_save": "5a9745e4041cda6be46f63b83cb1f89d102d10c8810e41261a326cf0ef6821f4", + "project_files.graphs": "4892c990da89bc193d408537761e2e2db19694840ee652e64cba64a3bf57dbdb", + "project_files.import_text": "0f168320abb858892a2724b0b59862475bec653d1670ce754d61f2d82c912538", + "project_files.lineage": "750c173b42b113b6b82542fe4d82f8652cf07e1efe5cafde4c3e776d6afc3683", + "project_files.lineage_record": "16fd6cbd20bcd3743e4ac889dc6c398e714152f57d3c65cbd013f047c4267708", + "project_files.material_intent": "79d1ee6c3d8bc1313f2e51ff1d7d4ea489f5f3e82a6fd2e00ed93e1f7613bfbd", + "project_files.material_intent_capabilities": "86e90e2d0f094beb1c277a680da9a35e28d78c827390ac1c4e51fc3003c1ce32", + "project_files.material_intents": "6431fd2780d706c77d2c4dd82f15794017368fd3bade28ac62bbdff5c8b0c547", + "project_files.materials": "92a6a6a1750fed89aef9574ab6e82be2c6c6351bfa89fbfcb1f3866d4ea3cb13", + "project_files.model_create": "34cf790fb2a3355ccab7c3a6c7174ced8dae861812d9479582fd8d5a9212745c", + "project_files.model_get": "95cc198ab6f084b596324f7251803ad4670a9f8e786e1a9639f1b9ed041798c3", + "project_files.model_material": "46afde2b06e0e5b08565f12ec86d2d7ec43ef6b63d97c86c264fc9c2f451f20b", + "project_files.model_publish": "97c0ec706fd663d322764a2dd72bd920e7e7ac8defaee2d23624502ed239fc62", + "project_files.model_save": "a90f8d985578191dee672824c9178559452f8b0dd759f74ed91337a96ca670e8", + "project_files.models": "92e13621050d7f229a4785ed6845df2f191a9e037bcdf377fa7fb0ba796aebe3", + "project_files.native_preflight": "494c907dac56a3126d26f064ec20bb980f918bded8e7f727c1fdaaa118059b13", + "project_files.native_reconcile": "74299207459fbd08d23ad7e8c1db205a4297f014415fd463c9857ba60dda4c7e", + "project_files.native_retry": "489655b49dadc184d95d6455ad2662bd5e7fd2cde2170fd303a92b2de77471c1", + "project_files.native_submit": "82468f35f04306fd4f39a0cc94a487fc05ae739e28f24f0be49ed35644639643", + "project_files.new": "672373f8ddc0b89fea15165798aab97cf4ac2cdf678a04d96839512cc8976b6f", + "project_files.open": "fd4b9f97c03cd7050981cfafd1177bff8c86351bb603b8f7980aaa149d2b0368", + "project_files.package_state": "6f0369eafab3b8ed1db71ffb12e7d5a2bbcf400b46315d46ba1e2fb13e63baf7", + "project_files.policies": "629b61ae9bd9c65d04cd3e89fb50fb62dd455b9f75bcaf9db08b7890bb5516f5", + "project_files.policy": "5bdb86600f2ec387fde721b22fdd0afa3ed47660126847c2908c1bb9de0421cb", + "project_files.producer": "6187a9eb1e32cb3bcf13d283492b1ad909c32753aa5ed97fbc12a9708523d9e3", + "project_files.producers": "65b2f4dff355c3bf5a023bf4f9dc116e302eda85ad90e6afaa2f21f0aa0667b5", + "project_files.publication_result": "10b4a5f3d4f37343322327024ddbedf5b6b69672c785e208ec48534d7b40c867", + "project_files.query": "9a8b0d3a99adf5d7d53b62261dfac83e8b3ea44c10cb2f6c7a9c3d79422fef08", + "project_files.recipe_create": "53a6760f03675d4d8a8d3a0ffd5b56689975667bbc136b1edf8a206307c18651", + "project_files.recipe_get": "23837c51032b7c7ae6a365616f66cab9ae0d191108027291ad49898b2c421e2a", + "project_files.recipe_material": "c384acbf2333d7dd360991d72c8e1e218dc3ad3291af76e09f76efe25ea69cc0", + "project_files.recipe_prepare": "e91344f3032bf3e6abf50e5f5399252439498bce999c1f732322fd70a5eda1cf", + "project_files.recipe_save": "7fb9708aaba280ae437ee2554ecdaef3f724f3a7f4e55b49e54e1ef186691a7e", + "project_files.recipes": "3217c2033f60242c661c6689d6f9f634ea9d3a9b188a9465edcdc0bea572bc6c", + "project_files.release_freeze": "93a1b5b3c6bf11f63c92571113ce536743e7fd07b0b77ae8aee6e909230245df", + "project_files.release_preview": "0b6e2f0c2d7e3503fa29391fc20d27f058d0d3ad61e4547aee9845633ba6c5d8", + "project_files.retained_analysis": "05959a0e49a623318d6d70ded4f86df5df5d9bf29c411fcfcd1d9c0148b650cc", + "project_files.retained_analysis_export": "fa5d97e094484da053a1374131040deefdac429ec8360d671f57b12353d69359", + "project_files.run": "3cd94d46587596c36da4d1187b4648442cf6f216d2972daa496b7879e294ddec", + "project_files.run_document_segment": "bd6599cb89b106ba0be4141fc5d38ad5e07b2c57c5b24e1fcf91802ba9ff9006", + "project_files.runs": "cab46b11c5376ba641a717ceb1ce2bbe3693b7ac264e41192849f8522eb5d61d", + "project_files.runtime_abandon": "21086c2a92f23a8d42085678a7b99a69897bacb5e84341d482b0d2e9d6b69df8", + "project_files.runtime_capture": "2d787e49e7f911820d4341b33b4738219fa773a783bb2fb5a674d05dbfbb173a", + "project_files.runtime_inspect": "ece28c002151d36b9a415d4b7692536318765d1a7f742e3fd1f60c53b3a28204", + "project_files.runtime_material": "da48c927442878373434e93bb97fc65fc7056bd8e5a4b97e8a18627eb768cb07", + "project_files.runtime_sources": "6b94cbb5c712fbbea5645cbb64f8fd8b7f72c28042ddeacd96aaaad828a81460", + "project_files.runtime_status": "80931340bab620ca52c05277bc0380c97b7e9191a808312caedabc30f4ab7e96", + "project_files.save": "38572e376db36f81582dc6419fded35ff9d7c9185f5af2cdaafd74c94790f90a", + "project_files.save_as": "2b0558049ca0055a9682bcda6eb81ce9f48358b7cf804b5d499067a5594e2d33", + "project_files.search_create": "ae5c51158d849cda199cc007c42ea7a1cb62cd15a7ca8957bd32df813548e942", + "project_files.search_delete": "518037dbe9464fd327df53f7801a3c1ba30bb822467c091a2bc684542a58495c", + "project_files.search_rename": "78ca2aed2cd34c278ee1a9858dc76ebf8d39111de36bd48ada43f0407af5ff70", + "project_files.searches": "061be81df13158c18c2a124419da07db9564b44c239c8ca80cbf65a7ad26c1bd", + "project_files.selection_create": "87f60aeb83a497e7c6adab524f3ddc4776a6820497aa676f50ab3e1104b62ba2", + "project_files.selection_delete": "cb021e6e1d7b4df1db1e4ca614067c3b26820737eca92dbc7168b3ff45b0894f", + "project_files.selection_get": "c99e320815d54b60d0ddb3179a0ee62fa5eca938ac71011813296ce93bb085b0", + "project_files.selection_rename": "7e3fe9853cc593e4c0464162b3c9137208500650a125a46704f1e38d49b6f5a1", + "project_files.selections": "fd9cc6a94144a349232e99aa1ad4890cb7cb4b023de8bf4e8249374aa29ec39d", + "project_files.snapshot": "1eacdfeb7843bc432d62c6a94890dc07f618074faecb5e22589df9c9377e5030", + "project_files.source_references": "36a6d5b97efd63268d393b456e3bfec45cc696335acf652eff503b64a4ca0869", + "project_files.source_register": "04390ea427cb9958f5a0a1118de5cf8f91434d12e9c241621cf11465d804c64e", + "project_files.status": "1d0c4b64caaab8369d4addefd72e53cc8c7d0be4c07729fa04b358febe643b6a", + "project_files.studies": "5c45e0938cef51c271d1e82b28e878bc831f5a9272773440e39022074e6e87b6", + "project_files.study": "b268a20042efe9c8bc260f74309688dc7bbbd60486d30a33621a68e1a92fa3d7", + "project_files.study_create": "8d2aac7c073a6a7b3ebb41227b5b466cf03606625404f93a59b325038a4ad7a1", + "project_files.study_save": "5bf3fb814588fd7d9c35836e9d964a64481b3cc4169caf76cad6ce776ff0fe15", + "project_files.trial": "79afa4bed6d0907f3d4116cc04cb784e94e5a24ab89725ec24f5d7bce70e869f", + "project_files.trial_prepare": "e1f6973536fd4c356c4a92fc674d6766f7df0d8a113b8ee71bb05d0adc40b04f" +} +/* PROJECT_FILES_CONTRACTS_END */; + // Generated fixed operation routes from agent_api; remote schemas are never executed. + const versionPins = /* PROJECT_FILES_VERSIONS_START */ +{ + "project_files.annotate": { + "1": "70feae2ad14c49c4ae95b5bb69b4a50fbf93191cde840544cf89b6cbe8dc5c58", + "2": "2ae39dd621161500061bc8f57bb465e90fed04cfaf921e0dc368f82bd50cd107" + }, + "project_files.execution_health": { + "1": "c3b1d0017f5a49cf69c3a4c5c33e1d3db9846404d12627563d97ef40de7404a5", + "2": "6b7a834881da36960c0a0ee393c0c26e0d918b857d30791e8e5af79c2ae4a73e" + }, + "project_files.get": { + "1": "6b0f4e16a00aa119b86cbb5493be0bac945ff08a5e63773a8f2c2f9296658d5b", + "2": "fdc0858fc03a0866bb743b04251ea5f9a3c1e6be1b211366db7e0652166736ea" + } +} +/* PROJECT_FILES_VERSIONS_END */; + let selectedVersions=new Map(); + const selectedHeader=operation=>operation+'/'+selectedVersions.get(operation)+'/'+(versionPins[operation]?.[selectedVersions.get(operation)]??pins[operation]); + const m2Operations = /* PROJECT_FILES_ROUTES_START */ +{ + "project_files.artifact_resolve": { + "method": "POST", + "path": "/api/workspace/project-files/artifacts/resolve", + "mutation": true + }, + "project_files.artifact_status": { + "method": "POST", + "path": "/api/workspace/project-files/artifacts/status", + "mutation": false + }, + "project_files.artifacts": { + "method": "GET", + "path": "/api/workspace/project-files/artifacts", + "mutation": false + }, + "project_files.candidate": { + "method": "GET", + "path": "/api/workspace/project-files/candidates/{candidate_id}", + "mutation": false + }, + "project_files.candidate_select": { + "method": "POST", + "path": "/api/workspace/project-files/studies/{study_id}/candidates", + "mutation": true + }, + "project_files.data_annotate": { + "method": "POST", + "path": "/api/workspace/project-files/data/{record_id}", + "mutation": true + }, + "project_files.data_availability": { + "method": "GET", + "path": "/api/workspace/project-files/data/{record_id}/availability", + "mutation": false + }, + "project_files.data_get": { + "method": "GET", + "path": "/api/workspace/project-files/data/{record_id}", + "mutation": false + }, + "project_files.data_history": { + "method": "GET", + "path": "/api/workspace/project-files/data/{record_id}/history", + "mutation": false + }, + "project_files.data_import_audio": { + "method": "POST", + "path": "/api/workspace/project-files/data/import-audio", + "mutation": true + }, + "project_files.data_import_image": { + "method": "POST", + "path": "/api/workspace/project-files/data/import-image", + "mutation": true + }, + "project_files.data_import_mesh": { + "method": "POST", + "path": "/api/workspace/project-files/data/import-mesh", + "mutation": true + }, + "project_files.data_import_pointcloud": { + "method": "POST", + "path": "/api/workspace/project-files/data/import-pointcloud", + "mutation": true + }, + "project_files.data_import_scientific": { + "method": "POST", + "path": "/api/workspace/project-files/data/import-scientific", + "mutation": true + }, + "project_files.data_import_sequence": { + "method": "POST", + "path": "/api/workspace/project-files/data/import-sequence", + "mutation": true + }, + "project_files.data_import_text": { + "method": "POST", + "path": "/api/workspace/project-files/data/import-text", + "mutation": true + }, + "project_files.data_import_volume": { + "method": "POST", + "path": "/api/workspace/project-files/data/import-volume", + "mutation": true + }, + "project_files.data_preference_delete": { + "method": "POST", + "path": "/api/workspace/project-files/preferences/delete", + "mutation": true + }, + "project_files.data_preference_history": { + "method": "GET", + "path": "/api/workspace/project-files/data/{record_id}/preference-history", + "mutation": false + }, + "project_files.data_preference_save": { + "method": "POST", + "path": "/api/workspace/project-files/preferences", + "mutation": true + }, + "project_files.data_preferences": { + "method": "GET", + "path": "/api/workspace/project-files/data/{record_id}/preferences", + "mutation": false + }, + "project_files.data_query": { + "method": "GET", + "path": "/api/workspace/project-files/data", + "mutation": false + }, + "project_files.data_response_history": { + "method": "GET", + "path": "/api/workspace/project-files/data/{record_id}/response-history", + "mutation": false + }, + "project_files.data_response_save": { + "method": "POST", + "path": "/api/workspace/project-files/responses", + "mutation": true + }, + "project_files.data_responses": { + "method": "GET", + "path": "/api/workspace/project-files/data/{record_id}/responses", + "mutation": false + }, + "project_files.data_rights": { + "method": "POST", + "path": "/api/workspace/project-files/data/{record_id}/rights", + "mutation": true + }, + "project_files.data_source_delete": { + "method": "POST", + "path": "/api/workspace/project-files/data/{record_id}/source/delete", + "mutation": true + }, + "project_files.data_source_save": { + "method": "POST", + "path": "/api/workspace/project-files/data/{record_id}/source", + "mutation": true + }, + "project_files.definitions": { + "method": "GET", + "path": "/api/workspace/project-files/definitions", + "mutation": false + }, + "project_files.dependencies": { + "method": "GET", + "path": "/api/workspace/project-files/runs/{run_id}/dependencies", + "mutation": false + }, + "project_files.evidence": { + "method": "POST", + "path": "/api/workspace/project-files/runs/{run_id}/evidence", + "mutation": false + }, + "project_files.execution_approve_external": { + "method": "POST", + "path": "/api/workspace/project-files/execution/approvals", + "mutation": true + }, + "project_files.execution_cancel": { + "method": "POST", + "path": "/api/workspace/project-files/execution/runs/{run_id}/cancel", + "mutation": true + }, + "project_files.execution_generation_resume": { + "method": "POST", + "path": "/api/workspace/project-files/execution/generation/{producer_id}/resume", + "mutation": true + }, + "project_files.execution_grounded_review": { + "method": "POST", + "path": "/api/workspace/project-files/execution/grounded/{producer_id}/review", + "mutation": true + }, + "project_files.execution_health": { + "method": "GET", + "path": "/api/workspace/project-files/execution", + "mutation": false + }, + "project_files.execution_preflight_external": { + "method": "POST", + "path": "/api/workspace/project-files/execution/preflight", + "mutation": false + }, + "project_files.execution_producer_cancel": { + "method": "POST", + "path": "/api/workspace/project-files/execution/producers/{producer_id}/cancel", + "mutation": true + }, + "project_files.execution_producer_decide": { + "method": "POST", + "path": "/api/workspace/project-files/execution/producers/{producer_id}/decide", + "mutation": true + }, + "project_files.execution_producer_health": { + "method": "GET", + "path": "/api/workspace/project-files/execution/producers/health", + "mutation": false + }, + "project_files.execution_producer_start": { + "method": "POST", + "path": "/api/workspace/project-files/execution/producers", + "mutation": true + }, + "project_files.execution_reconcile_current": { + "method": "POST", + "path": "/api/workspace/project-files/execution/reconcile-current", + "mutation": true + }, + "project_files.execution_study_abandon_preparation": { + "method": "POST", + "path": "/api/workspace/project-files/execution/studies/{study_id}/preparations/abandon", + "mutation": true + }, + "project_files.execution_study_admit": { + "method": "POST", + "path": "/api/workspace/project-files/execution/studies/{study_id}/runs", + "mutation": true + }, + "project_files.execution_study_approve": { + "method": "POST", + "path": "/api/workspace/project-files/execution/studies/{study_id}/approvals", + "mutation": true + }, + "project_files.execution_study_continue": { + "method": "POST", + "path": "/api/workspace/project-files/execution/studies/{study_id}/continue", + "mutation": true + }, + "project_files.execution_study_prepare_package": { + "method": "POST", + "path": "/api/workspace/project-files/execution/studies/{study_id}/packages", + "mutation": true + }, + "project_files.execution_study_prepare_verifier": { + "method": "POST", + "path": "/api/workspace/project-files/execution/studies/{study_id}/verifiers", + "mutation": true + }, + "project_files.execution_study_reconcile_preparation": { + "method": "POST", + "path": "/api/workspace/project-files/execution/studies/{study_id}/preparations/reconcile", + "mutation": true + }, + "project_files.execution_study_stop": { + "method": "POST", + "path": "/api/workspace/project-files/execution/studies/{study_id}/stop", + "mutation": true + }, + "project_files.execution_submit_external": { + "method": "POST", + "path": "/api/workspace/project-files/execution/runs", + "mutation": true + }, + "project_files.expose_holdout": { + "method": "POST", + "path": "/api/workspace/project-files/runs/{run_id}/disclose", + "mutation": true + }, + "project_files.file_adopt": { + "method": "POST", + "path": "/api/workspace/project-files/files/adopt", + "mutation": true + }, + "project_files.file_adopt_deletion": { + "method": "POST", + "path": "/api/workspace/project-files/files/adopt-deletion", + "mutation": true + }, + "project_files.file_get": { + "method": "GET", + "path": "/api/workspace/project-files/files/{file_id}", + "mutation": false + }, + "project_files.file_history": { + "method": "GET", + "path": "/api/workspace/project-files/files/{file_id}/history", + "mutation": false + }, + "project_files.files_inspect": { + "method": "GET", + "path": "/api/workspace/project-files/files", + "mutation": false + }, + "project_files.fork_info": { + "method": "GET", + "path": "/api/workspace/project-files/fork", + "mutation": false + }, + "project_files.graph_clone": { + "method": "POST", + "path": "/api/workspace/project-files/graphs/{graph_id}/clone", + "mutation": true + }, + "project_files.graph_create": { + "method": "POST", + "path": "/api/workspace/project-files/graphs", + "mutation": true + }, + "project_files.graph_create_external": { + "method": "POST", + "path": "/api/workspace/project-files/graphs/external", + "mutation": true + }, + "project_files.graph_get": { + "method": "GET", + "path": "/api/workspace/project-files/graphs/{graph_id}", + "mutation": false + }, + "project_files.graph_save": { + "method": "POST", + "path": "/api/workspace/project-files/graphs/{graph_id}", + "mutation": true + }, + "project_files.graphs": { + "method": "GET", + "path": "/api/workspace/project-files/graphs", + "mutation": false + }, + "project_files.lineage": { + "method": "GET", + "path": "/api/workspace/project-files/lineage", + "mutation": false + }, + "project_files.lineage_record": { + "method": "POST", + "path": "/api/workspace/project-files/lineage", + "mutation": true + }, + "project_files.material_intent": { + "method": "GET", + "path": "/api/workspace/project-files/material-intents/{family}/{definition_id}", + "mutation": false + }, + "project_files.material_intent_capabilities": { + "method": "GET", + "path": "/api/workspace/project-files/material-intents/capabilities", + "mutation": false + }, + "project_files.material_intents": { + "method": "GET", + "path": "/api/workspace/project-files/material-intents", + "mutation": false + }, + "project_files.materials": { + "method": "GET", + "path": "/api/workspace/project-files/materials", + "mutation": false + }, + "project_files.model_create": { + "method": "POST", + "path": "/api/workspace/project-files/models", + "mutation": true + }, + "project_files.model_get": { + "method": "GET", + "path": "/api/workspace/project-files/models/{model_id}", + "mutation": false + }, + "project_files.model_material": { + "method": "POST", + "path": "/api/workspace/project-files/materials/model", + "mutation": false + }, + "project_files.model_publish": { + "method": "POST", + "path": "/api/workspace/project-files/models/{model_id}/publish", + "mutation": true + }, + "project_files.model_save": { + "method": "POST", + "path": "/api/workspace/project-files/models/{model_id}", + "mutation": true + }, + "project_files.models": { + "method": "GET", + "path": "/api/workspace/project-files/models", + "mutation": false + }, + "project_files.native_preflight": { + "method": "POST", + "path": "/api/workspace/project-files/execution/native/preflight", + "mutation": true + }, + "project_files.native_reconcile": { + "method": "POST", + "path": "/api/workspace/project-files/execution/native/phases/{phase_id}/reconcile", + "mutation": true + }, + "project_files.native_retry": { + "method": "POST", + "path": "/api/workspace/project-files/execution/native/runs/{run_id}/retry", + "mutation": true + }, + "project_files.native_submit": { + "method": "POST", + "path": "/api/workspace/project-files/execution/native/runs", + "mutation": true + }, + "project_files.package_state": { + "method": "GET", + "path": "/api/workspace/project-files/candidates/{candidate_id}/package-state", + "mutation": false + }, + "project_files.policies": { + "method": "GET", + "path": "/api/workspace/project-files/policies", + "mutation": false + }, + "project_files.policy": { + "method": "POST", + "path": "/api/workspace/project-files/policies/get", + "mutation": false + }, + "project_files.producer": { + "method": "GET", + "path": "/api/workspace/project-files/producers/{origin_catalog_id}/{producer_id}", + "mutation": false + }, + "project_files.producers": { + "method": "GET", + "path": "/api/workspace/project-files/producers", + "mutation": false + }, + "project_files.publication_result": { + "method": "GET", + "path": "/api/workspace/project-files/runs/{run_id}/publication-result", + "mutation": false + }, + "project_files.recipe_create": { + "method": "POST", + "path": "/api/workspace/project-files/recipes", + "mutation": true + }, + "project_files.recipe_get": { + "method": "GET", + "path": "/api/workspace/project-files/recipes/{recipe_id}", + "mutation": false + }, + "project_files.recipe_material": { + "method": "POST", + "path": "/api/workspace/project-files/materials/recipe", + "mutation": false + }, + "project_files.recipe_prepare": { + "method": "POST", + "path": "/api/workspace/project-files/recipes/{graph_id}/prepare", + "mutation": true + }, + "project_files.recipe_save": { + "method": "POST", + "path": "/api/workspace/project-files/recipes/{recipe_id}", + "mutation": true + }, + "project_files.recipes": { + "method": "GET", + "path": "/api/workspace/project-files/recipes", + "mutation": false + }, + "project_files.release_freeze": { + "method": "POST", + "path": "/api/workspace/project-files/releases/freeze", + "mutation": true + }, + "project_files.release_preview": { + "method": "POST", + "path": "/api/workspace/project-files/releases/preview", + "mutation": false + }, + "project_files.retained_analysis": { + "method": "POST", + "path": "/api/workspace/project-files/runs/{run_id}/analysis", + "mutation": false + }, + "project_files.retained_analysis_export": { + "method": "POST", + "path": "/api/workspace/project-files/runs/{run_id}/analysis/export", + "mutation": false + }, + "project_files.run": { + "method": "GET", + "path": "/api/workspace/project-files/runs/{run_id}", + "mutation": false + }, + "project_files.run_document_segment": { + "method": "GET", + "path": "/api/workspace/project-files/runs/{run_id}/document-segment", + "mutation": false + }, + "project_files.runs": { + "method": "GET", + "path": "/api/workspace/project-files/runs", + "mutation": false + }, + "project_files.runtime_abandon": { + "method": "POST", + "path": "/api/workspace/project-files/runtime/abandon", + "mutation": true + }, + "project_files.runtime_capture": { + "method": "POST", + "path": "/api/workspace/project-files/runtime/capture", + "mutation": true + }, + "project_files.runtime_inspect": { + "method": "GET", + "path": "/api/workspace/project-files/runtime/inspect", + "mutation": false + }, + "project_files.runtime_material": { + "method": "POST", + "path": "/api/workspace/project-files/materials/runtime", + "mutation": false + }, + "project_files.runtime_sources": { + "method": "GET", + "path": "/api/workspace/project-files/runtime/sources", + "mutation": false + }, + "project_files.runtime_status": { + "method": "GET", + "path": "/api/workspace/project-files/runtime/status", + "mutation": false + }, + "project_files.save_as": { + "method": "POST", + "path": "/api/workspace/project-files/save-as", + "mutation": true + }, + "project_files.search_create": { + "method": "POST", + "path": "/api/workspace/project-files/searches", + "mutation": true + }, + "project_files.search_delete": { + "method": "POST", + "path": "/api/workspace/project-files/searches/{search_id}/delete", + "mutation": true + }, + "project_files.search_rename": { + "method": "POST", + "path": "/api/workspace/project-files/searches/{search_id}", + "mutation": true + }, + "project_files.searches": { + "method": "GET", + "path": "/api/workspace/project-files/searches", + "mutation": false + }, + "project_files.selection_create": { + "method": "POST", + "path": "/api/workspace/project-files/selections", + "mutation": true + }, + "project_files.selection_delete": { + "method": "POST", + "path": "/api/workspace/project-files/selections/{selection_id}/delete", + "mutation": true + }, + "project_files.selection_get": { + "method": "GET", + "path": "/api/workspace/project-files/selections/{selection_id}", + "mutation": false + }, + "project_files.selection_rename": { + "method": "POST", + "path": "/api/workspace/project-files/selections/{selection_id}", + "mutation": true + }, + "project_files.selections": { + "method": "GET", + "path": "/api/workspace/project-files/selections", + "mutation": false + }, + "project_files.snapshot": { + "method": "POST", + "path": "/api/workspace/project-files/materials/snapshot", + "mutation": false + }, + "project_files.source_references": { + "method": "GET", + "path": "/api/workspace/project-files/sources/{project_id}/references", + "mutation": false + }, + "project_files.source_register": { + "method": "POST", + "path": "/api/workspace/project-files/sources/register", + "mutation": true + }, + "project_files.studies": { + "method": "GET", + "path": "/api/workspace/project-files/studies", + "mutation": false + }, + "project_files.study": { + "method": "GET", + "path": "/api/workspace/project-files/studies/{study_id}", + "mutation": false + }, + "project_files.study_create": { + "method": "POST", + "path": "/api/workspace/project-files/studies", + "mutation": true + }, + "project_files.study_save": { + "method": "POST", + "path": "/api/workspace/project-files/studies/{study_id}", + "mutation": true + }, + "project_files.trial": { + "method": "GET", + "path": "/api/workspace/project-files/trials/{trial_id}", + "mutation": false + }, + "project_files.trial_prepare": { + "method": "POST", + "path": "/api/workspace/project-files/studies/{study_id}/trials", + "mutation": true + } +} +/* PROJECT_FILES_ROUTES_END */; + let selectedContracts=new Set(); + let state=null, selected=null, history=[], summaries=[], nextRecord=null, nextHistory=null, epoch=0, busy=false, blocked=false; + let importDirty=false, annotationDirty=false, m2Dirty=false, pending=null; + let registeredSource=null,modelTemplates=[],templateBinding=null; + let runtimeInspection=null,runtimePending=null,runtimeSourceOffset=0,runtimeSourceTotal=0,runtimeFileOffset=0,runtimeFileTotal=0; + let forkPending=null,workingSelection=null,workingDeletion=null,workingOffset=0,workingTotal=0,workingHistoryOffset=0,workingHistoryTotal=0,intentsOffset=0,intentsTotal=0,forkOffset=0,forkTotal=0,intentCapsOffset=0,intentCapsTotal=0; + let nativeGraphs=[],nativePrepared=null,nativePending=null,nativeOffset=0,nativeTotal=0; + let executionGraphs=[],executionPrepared=null,executionApproval=null,executionBinding=null,executionOffset=0,executionTotal=0,executionStudy=null,executionTrials=[],studyApproval=null,executionGraphOffset=0,executionGraphTotal=0,studyTrialOffset=0,studyTrialTotal=0,producerSource=null,producerCurrent=null,producerHealthOffset=0,producerHealthTotal=0; + let explored=null,exploreOffset=0,exploreTotal=0,historyOffset=0,historyTotal=0; + const m2DirtyForms=new Set(); + const m2PagedActions=new Set(['data_query','data_history','data_responses','data_response_history', + 'data_preferences','data_preference_history','models','graphs','recipes','studies','runs', + 'lineage','policies','artifacts','materials','selections','searches','producers','source_references','execution_producer_health','files_inspect','file_history','material_intents','material_intent_capabilities','runtime_sources']); + const m2HistoryActions=new Set(['data_history','data_response_history','data_preference_history']); + const dirty=()=>importDirty||annotationDirty||m2Dirty; + const checkpoint=()=>state?.project?.checkpoint??null; + const exact=(value,keys)=>value!==null&&typeof value==='object'&&!Array.isArray(value)&&Object.keys(value).length===keys.length&&keys.every(key=>Object.hasOwn(value,key)); + const str=(value,max,empty=false)=>typeof value==='string'&&Array.from(value).length<=max&&(empty||/\S/u.test(value)); + const id=value=>typeof value==='string'&&/^[a-f0-9]{32}$/.test(value); + const sha=value=>typeof value==='string'&&/^[a-f0-9]{64}$/.test(value); + const texts=(value,max,min=0,length=120)=>Array.isArray(value)&&value.length>=min&&value.length<=max&&new Set(value).size===value.length&&value.every(item=>str(item,length)); + const positive=value=>Number.isSafeInteger(value)&&value>0; + function annotationOK(task,value,nullable=false){ + if(value===null)return nullable; + if(task==='text_classification')return exact(value,['label'])&&str(value.label,80); + if(task==='text_corpus')return exact(value,['note'])&&str(value.note,4000); + return exact(value,['spans'])&&Array.isArray(value.spans)&&value.spans.length<=500&&value.spans.every(span=>exact(span,['label','start','end'])&&str(span.label,80)&&Number.isSafeInteger(span.start)&&span.start>=0&&positive(span.end)&&span.end>span.start); + } + function provenanceOK(value){ + if(exact(value,['method','rights','normalization'])&&value.method==='import'&&str(value.rights,1000)&&value.normalization==='NFC; LF newlines')return true; + const origin=value?.origin; + return exact(value,['method','rights','origin'])&&value.method==='project_fork'&&str(value.rights,1000)&&exact(origin,['project_id','checkpoint','family','id','revision','source_revision'])&&id(origin.project_id)&&sha(origin.checkpoint)&&['workbench_text','workbench_record'].includes(origin.family)&&id(origin.id)&&positive(origin.revision)&&positive(origin.source_revision); + } + function recordOK(value){ + const fields=['id','kind','name','text','groups','parents','provenance','task','annotation','review','revision','source_revision','created_at','updated_at']; + return exact(value,fields)&&id(value.id)&&value.kind==='text'&&str(value.name,200)&&str(value.text,200000)&&texts(value.groups,30,1)&&texts(value.parents,30)&&provenanceOK(value.provenance)&&tasks.includes(value.task)&&annotationOK(value.task,value.annotation,true)&&['draft','human_reviewed'].includes(value.review)&&positive(value.revision)&&value.source_revision===1&&str(value.created_at,80)&&str(value.updated_at,80); + } + function projectOK(value){return exact(value,['project_id','name','checkpoint'])&&id(value.project_id)&&str(value.name,200)&&sha(value.checkpoint);} + function summaryOK(value){ + return exact(value,['id','kind','name','task','review','revision','source_revision','created_at','updated_at'])&&id(value.id)&&value.kind==='text'&&str(value.name,200)&&tasks.includes(value.task)&&['draft','human_reviewed'].includes(value.review)&&positive(value.revision)&&value.source_revision===1&&str(value.created_at,80)&&str(value.updated_at,80); + } + function receiptOK(value){ + const fields=['committed','selected','project_id','checkpoint','request_key']; + return exact(value,value?.selected===false?[...fields,'root','directory']:fields)&&value.committed===true&&typeof value.selected==='boolean'&&id(value.project_id)&&sha(value.checkpoint)&&typeof value.request_key==='string'&&/^[A-Za-z0-9][A-Za-z0-9_.:-]{7,127}$/.test(value.request_key)&&(value.selected||(str(value.root,160)&&str(value.directory,512))); + } + function stateOK(value){ + const fields=['format','binding','state','roots','project','available_actions']; + if(!exact(value,value?.state==='recovery_required'?[...fields,'recovery']:fields)||value.format!=='project_files_state_v1'||!id(value.binding)||!texts(value.roots,100,0,160)||!Array.isArray(value.available_actions)||value.available_actions.length>8||new Set(value.available_actions).size!==value.available_actions.length||!value.available_actions.every(action=>actions.includes(action)))return false; + if(value.state==='no_project_open')return value.project===null; + if(!projectOK(value.project))return false; + if(value.state==='open')return true; + const recovery=value.recovery; + return value.state==='recovery_required'&&exact(recovery,['action','root','directory','reason','request_key'])&&recovery.action==='open'&&str(recovery.root,160)&&str(recovery.directory,512)&&['unknown_publication','projection_unavailable'].includes(recovery.reason)&&(recovery.request_key===null||typeof recovery.request_key==='string'&&/^[A-Za-z0-9][A-Za-z0-9_.:-]{7,127}$/.test(recovery.request_key))&&value.available_actions.length===1&&value.available_actions[0]==='open'; + } + function boundOK(value,kind){ + const fields=kind==='query'?['binding','project_id','checkpoint','items','next_after_id']:['binding','project_id','checkpoint','record','history','next_history_before_revision']; + if(!exact(value,fields)||!id(value.binding)||!id(value.project_id)||!sha(value.checkpoint))return false; + if(kind==='query')return Array.isArray(value.items)&&value.items.length<=100&&value.items.every(summaryOK)&&(value.next_after_id===null||id(value.next_after_id)&&value.items.at(-1)?.id===value.next_after_id)&&value.items.every((item,index)=>index===0||item.id>value.items[index-1].id); + return recordOK(value.record)&&Array.isArray(value.history)&&value.history.length<=4&&value.history.every(recordOK)&&value.history.every((item,index)=>item.id===value.record.id&&item.revision<=value.record.revision&&(index===0||item.revision!busy&&!blocked&&selectedContracts.has('project_files.'+action)&&state?.available_actions.includes(action); + for(const action of ['new','open','save','close'])$('project-files-'+action).disabled=!allowed(action); + $('project-files-refresh').disabled=busy; + $('project-files-root').disabled=busy||state?.state==='recovery_required'; + $('project-files-directory').disabled=busy||state?.state==='recovery_required'; + $('project-files-name').disabled=busy||state?.state==='recovery_required'; + $('text-import-fields').disabled=!open||!allowed('import_text'); + $('annotation-fields').disabled=!selected||!allowed('annotate'); + $('import-text').disabled=!allowed('import_text'); + $('annotation-save').disabled=!selected||!allowed('annotate'); + $('project-files-discard').disabled=busy||!dirty(); + $('records-more').hidden=nextRecord===null;$('records-more').disabled=!allowed('query'); + $('record-history-more').hidden=nextHistory===null;$('record-history-more').disabled=!selected||!allowed('get'); + $('project-files-open').textContent=state?.state==='recovery_required'?'Reopen to reconcile':'Open project'; + for(const button of $('records-list').querySelectorAll('button'))button.disabled=busy||blocked; + $('project-files-dirty').textContent=dirty()?'This tab has unsent or unacknowledged edits. After an error, inspect saved state before retrying; explicitly discard these edits before closing. Unsent annotations must be resolved before selecting a different record.':'No unsubmitted edits.'; + m2Controls();m4Controls();runtimeControls();candidateControls(); + if(window.TuldokAuth&&!window.TuldokAuth.canReview())$('record-review').value='draft'; + } + function showState(){ + if(executionBinding!==state.binding){nativeGraphs=[];nativePrepared=null;nativePending=null;nativeOffset=nativeTotal=0;$('project-native-graph').replaceChildren();$('project-native-phases').replaceChildren();executionBinding=state.binding;executionPrepared=null;executionApproval=null;executionStudy=null;studyApproval=null;executionGraphs=[];executionTrials=[];producerSource=null;producerCurrent=null;$('project-execution-grant').textContent='No current local approval selected.';} + const root=$('project-files-root'), previous=root.value;root.replaceChildren(); + for(const alias of state.roots){const option=document.createElement('option');option.value=alias;option.textContent=alias;root.append(option);} + if(state.roots.includes(previous))root.value=previous; + const destination=$('project-save-as-root'),oldDestination=destination.value;destination.replaceChildren(); + for(const alias of state.roots){const option=document.createElement('option');option.value=alias;option.textContent=alias;destination.append(option);} + if(state.roots.includes(oldDestination))destination.value=oldDestination; + const candidateRoot=$('project-candidate-root'),oldCandidateRoot=candidateRoot.value;candidateRoot.replaceChildren();for(const alias of state.roots){const option=document.createElement('option');option.value=alias;option.textContent=alias;candidateRoot.append(option);}if(state.roots.includes(oldCandidateRoot))candidateRoot.value=oldCandidateRoot; + const recovery=state.state==='recovery_required'; + $('project-files-summary').textContent=state.project?`${state.project.name} · ${state.project.project_id} · ${recovery?'last acknowledged':'saved'} checkpoint ${state.project.checkpoint}${recovery?' · Reopen this project to reconcile its saved state.':''}`:'No project open.'; + if(recovery){root.value=state.recovery.root;$('project-files-directory').value=state.recovery.directory;} + controls(); + } + function annotationHelp(){ + const task=$('annotation-task').value; + $('annotation-label').textContent=task==='text_classification'?'Class label':task==='text_corpus'?'Corpus note':'Entity spans (JSON)'; + $('annotation-help').textContent=task==='text_classification'?'Enter one classification label, up to 80 characters.':task==='text_corpus'?'Enter a human-authored corpus note, up to 4,000 characters.':'Enter {"spans":[{"label":"person","start":0,"end":4}]}. Offsets count Unicode code points; an empty spans array records a negative.'; + $('annotation-value').maxLength=task==='text_classification'?80:task==='text_corpus'?4000:200000; + } + function showRecord(focus=false){ + $('record-heading').textContent=selected?selected.name:'Select a text record'; + $('record-summary').textContent=selected?`Revision ${selected.revision} · ${selected.review} · source revision ${selected.source_revision}`:''; + $('record-text').value=selected?.text??''; + $('annotation-task').value=selected?.task??'text_classification'; + const value=selected?.annotation; + $('annotation-value').value=value?(selected.task==='text_entities'?JSON.stringify(value,null,2):value.label??value.note):''; + $('annotation-groups').value=selected?.groups.join(', ')??''; + $('record-review').value=selected?.review??'draft'; + annotationHelp(); + showHistory();controls();if(focus)$('record-heading').focus(); + } + function showHistory(){ + const list=$('record-history');list.replaceChildren(); + for(const snapshot of history){const item=document.createElement('li'),title=document.createElement('p'),body=document.createElement('pre');title.textContent=`Revision ${snapshot.revision} · ${snapshot.review} · ${snapshot.task}`;body.textContent=JSON.stringify(snapshot.annotation,null,2);item.append(title,body);list.append(item);} + controls(); + } + function showRecords(items){ + const list=$('records-list');list.replaceChildren(); + if(!items.length){const item=document.createElement('li');item.textContent='No text records yet.';list.append(item);} + for(const record of items){const item=document.createElement('li'),button=document.createElement('button');button.type='button';button.dataset.recordId=record.id;button.textContent=`${record.name} · ${record.review} · revision ${record.revision}`;button.setAttribute('aria-current',String(record.id===selected?.id));button.onclick=()=>select(record.id);item.append(button);list.append(item);} + controls(); + } + function needClean(){if(!dirty())return true;error('This tab has unsent or unacknowledged edits. Inspect saved state after an error, then submit or choose “Discard unsubmitted edits” before this action.');return false;} + async function discover(ticket){ + let response,value; + try{response=await window.TuldokAuth.fetch('/api/agent/capabilities',{cache:'no-store'});value=await response.json();} + catch(_){const failure=Error('Project contracts unavailable. Refresh status before continuing.');failure.code='transport';throw failure;} + if(ticket!==epoch)return false; + if(!response.ok){const failure=Error(value?.error??'Project contracts unavailable. Sign in and refresh status.');failure.status=response.status;throw failure;} + if(!exact(value,['format','envelope_version','catalog_id','operations','limits'])||value.format!=='agent_capabilities_v1'||value.envelope_version!==1||!id(value.catalog_id)||!Array.isArray(value.operations)||value.operations.length>256||JSON.stringify(value).length>1048576||!exact(value.limits,['request_bytes','response_bytes','max_operations'])||value.limits.request_bytes!==1048576||value.limits.response_bytes!==16777216||value.limits.max_operations!==256)throw Error('Unsupported project capability envelope.'); + const identity=window.TuldokAuth.identity?.(); + if(identity?.catalog_id&&identity.catalog_id!==value.catalog_id)throw Error('Project capability catalog differs from the current session.'); + const names=new Set(),chosen=new Set(),versionsChosen=new Map(); + for(const operation of value.operations){ + if(!exact(operation,['id','contracts','availability'])||typeof operation.id!=='string'||!/^[a-z][a-z0-9_.]{0,79}$/.test(operation.id)||names.has(operation.id)||!['available','unavailable','unsupported'].includes(operation.availability)||!Array.isArray(operation.contracts)||!operation.contracts.length||operation.contracts.length>4)throw Error('Unsupported operation capability.'); + names.add(operation.id);const versions=new Set(); + for(const contract of operation.contracts){ + if(!exact(contract,['version','sha256','input_schema','output_schema','scopes','authority','effects','retry','bounds'])||!positive(contract.version)||versions.has(contract.version)||!sha(contract.sha256)||!contract.input_schema||typeof contract.input_schema!=='object'||Array.isArray(contract.input_schema)||!contract.output_schema||typeof contract.output_schema!=='object'||Array.isArray(contract.output_schema)||!texts(contract.scopes,16,0,40)||!['agent','owner'].includes(contract.authority)||!['read','mutation'].includes(contract.effects)||!['read','cas','receipt','never'].includes(contract.retry)||!exact(contract.bounds,['request_bytes','response_bytes','inline_decoded_bytes','json_depth'])||!Object.values(contract.bounds).every(positive))throw Error('Unsupported operation contract.'); + versions.add(contract.version); + // Remote schemas are opaque discovery data; only installed local pins + // select behavior. Every request lets the owner verify this exact pin. + const local=versionPins[operation.id]??(Object.hasOwn(pins,operation.id)?{'1':pins[operation.id]}:{}); + if(operation.availability==='available'&&local[contract.version]===contract.sha256&&contract.version>(versionsChosen.get(operation.id)??0)){chosen.add(operation.id);versionsChosen.set(operation.id,contract.version);} + } + } + selectedContracts=chosen;selectedVersions=versionsChosen; + if(!chosen.has('project_files.status')){const failure=Error('No compatible project status contract is available.');failure.code='contract_mismatch';throw failure;} + return true; + } + async function request(path,{body,kind='state',ticket=epoch,read=false,recordId}={}){ + const binding=state?.binding, expected=checkpoint(), project=state?.project?.project_id; + const action=path===''?'status':path.startsWith('/records?')?'query':path.startsWith('/records/')?(body===undefined?'get':'annotate'):path.slice(1).replaceAll('-','_'); + const operation='project_files.'+action; + if(!selectedContracts.has(operation)){const failure=Error('No compatible contract is available for '+action+'. Refresh project status.');failure.code='contract_mismatch';throw failure;} + let response,value; + try{ + response=await window.TuldokAuth.fetch(base+path,{method:body===undefined?'GET':'POST',headers:{'Tuldok-Operation-Contract':selectedHeader(operation),...(body===undefined?{}:{'Content-Type':'application/json'})},body:body===undefined?undefined:JSON.stringify(body),cache:'no-store'}); + value=await response.json(); + }catch(_){const failure=Error(body===undefined?'Project response unavailable. Refresh status to recover.':'Acknowledgement unavailable; the edit may already be saved. Keep your edits and refresh status before retrying.');failure.code='transport';throw failure;} + if(ticket!==epoch){message('A superseded project response was ignored. The current view is unchanged.');return null;} + if(!response.ok){const fields=['error',...(Object.hasOwn(value??{},'code')?['code']:[]),...(Object.hasOwn(value??{},'receipt')?['receipt']:[])];const valid=exact(value,fields)&&str(value.error,1000)&&(!Object.hasOwn(value,'code')||str(value.code,120))&&(!Object.hasOwn(value,'receipt')||receiptOK(value.receipt));const failure=Error(valid?`${value.code??'error'}: ${value.error}`:'Invalid project error response. Refresh status.');failure.code=valid?(value.code??'http_error'):'transport';failure.status=response.status;if(valid&&value.receipt)failure.receipt=value.receipt;throw failure;} + if(!(kind==='state'?stateOK(value):kind==='fork'?forkOK(value,body):boundOK(value,kind))){const failure=Error('Unsupported project response. The current state is unknown; refresh status before continuing.');failure.code='transport';throw failure;} + if(kind==='record'&&selectedVersions.get(operation)===1&&[value.record,...value.history].some(record=>record.provenance.method!=='import')){const failure=Error('Version 1 cannot represent retained fork provenance. Refresh contracts.');failure.code='transport';throw failure;} + if(kind!=='state'&&kind!=='fork'&&(value.binding!==binding||value.project_id!==project||(read&&value.checkpoint!==expected)||(recordId&&value.record?.id!==recordId))){const failure=Error('stale: Response belongs to a different project or checkpoint. Your unsubmitted edits remain here. Refresh status.');failure.code='stale';throw failure;} + return value; + } + async function run(work,{clean=false}={}){ + if(busy)return;if(clean&&!needClean())return; + const ticket=++epoch;busy=true;clearError();controls(); + let nextFocus=null; + try{nextFocus=await work(ticket);}catch(failure){ + if(ticket===epoch){ + if(failure.status===409||failure.status>=500||['stale','conflict','binding_mismatch','transport','projection_unavailable','stale_binding','stale_checkpoint','too_large'].includes(failure.code))blocked=true; + if(failure.receipt){const receipt=failure.receipt;$('project-files-receipt').hidden=false;$('project-files-receipt').textContent=`Saved to project ${receipt.project_id} at checkpoint ${receipt.checkpoint}; request ${receipt.request_key}. ${receipt.selected?'Refresh status and reopen to restore the local view.':'The project was created but is not selected. Refresh status, then Open its directory.'}`;if(!receipt.selected){$('project-files-root').value=receipt.root;$('project-files-directory').value=receipt.directory;}} + error(failure.message); + } + }finally{if(ticket===epoch){busy=false;controls();if(nextFocus)nextFocus.focus();}} + } + async function query(ticket,append=false){ + if(state?.state!=='open'){summaries=[];nextRecord=null;showRecords([]);return;} + const cursor=append?nextRecord:null,parameters={binding:state.binding,checkpoint:checkpoint()}; + if(cursor!==null)parameters.after_id=cursor; + const result=await request('/records?'+new URLSearchParams(parameters),{kind:'query',ticket,read:true}); + if(result){if(cursor!==null&&result.items.some(item=>item.id<=cursor))throw Error('Invalid record-page continuation. Refresh status.');summaries=append?[...summaries,...result.items]:result.items;nextRecord=result.next_after_id;showRecords(summaries);} + } + function olderHistory(){return run(async ticket=>{ + if(!selected||nextHistory===null)return; + const before=nextHistory; + const result=await request('/records/'+selected.id+'?'+new URLSearchParams({binding:state.binding,checkpoint:checkpoint(),before_revision:before}),{kind:'record',ticket,read:true,recordId:selected.id}); + if(!result)return; + if(result.record.revision!==selected.revision||result.history.some(item=>item.revision>=before))throw Error('Invalid history-page continuation. Refresh status.'); + history=[...history,...result.history];nextHistory=result.next_history_before_revision;showHistory();message('Older saved annotation history loaded.');return nextHistory===null?$('record-history-title'):$('record-history-more'); + });} + function refresh(){return run(async ticket=>{ + if(!await discover(ticket))return; + const value=await request('',{ticket});if(!value)return; + const changed=state?.binding!==value.binding||checkpoint()!==value.project?.checkpoint; + state=value;blocked=false; + if(changed){m4Reset();clearExploration();selected=null;history=[];nextHistory=null;if(!annotationDirty)showRecord();else{$('record-heading').textContent='Unsubmitted annotation from a prior checkpoint';$('record-summary').textContent='Project changed. Your unsubmitted annotation remains below; discard it before selecting a saved record.';$('record-text').value='';}} + showState();await query(ticket);message(dirty()?'Project status refreshed. Unsubmitted edits remain in this tab.':'Project status refreshed.'); + });} + function select(recordId){if(annotationDirty){error('The annotation editor has unsent or unacknowledged edits. Submit or explicitly discard that annotation before selecting a saved record.');return;}return run(async ticket=>{ + const result=await request('/records/'+recordId+'?'+new URLSearchParams({binding:state.binding,checkpoint:checkpoint()}),{kind:'record',ticket,read:true,recordId}); + if(!result)return;selected=result.record;history=result.history;nextHistory=result.next_history_before_revision;annotationDirty=false;showRecord(true);await query(ticket);message('Saved text and annotation history loaded.'); + });} + function key(){return crypto.randomUUID().replaceAll('-','');} + function mutationBody(path,fields){ + const body={binding:state.binding,expected_checkpoint:checkpoint(),...fields}; + const signature=JSON.stringify({path,body}); + if(!pending||pending.signature!==signature)pending={signature,key:key()}; + return {...body,request_key:pending.key}; + } + function lifecycle(action){const recovering=action==='open'&&state?.state==='recovery_required';return run(async ticket=>{ + const fields={}; + if(['new','open'].includes(action)){ + if(!$('project-files-root').value||!$('project-files-directory').value.trim())throw Error('Choose a local root and relative project directory.'); + fields.root=$('project-files-root').value;fields.directory=$('project-files-directory').value; + if(action==='new'){if(!$('project-files-name').value.trim())throw Error('Enter a name for the new project.');fields.name=$('project-files-name').value;} + } + const value=await request('/'+action,{ticket,body:mutationBody('/'+action,fields)});if(!value)return; + state=value;pending=null;if(action!=='save'){m4Reset();clearExploration();selected=null;history=[];nextHistory=null;}showState();if(action!=='save'&&!annotationDirty)showRecord();else if(recovering&&annotationDirty){$('record-heading').textContent='Preserved unsubmitted annotation';$('record-text').value='';}await query(ticket);message(action==='save'?'Project checkpoint is saved.':action==='close'?'Project closed.':`Project ${action==='new'?'created':'opened'}.`); + return action==='save'?$('project-files-save'):(state.state==='open'?$('text-name'):$('project-files-directory')); + },{clean:!recovering});} + function groups(value){const result=value.split(',').map(item=>item.trim()).filter(Boolean);if(!texts(result,30,1))throw Error('Supply 1–30 distinct protected group IDs, each up to 120 characters.');return result;} + async function acceptRecord(value,ticket){ + state={...state,project:{...state.project,checkpoint:value.checkpoint}};selected=value.record;history=value.history;nextHistory=value.next_history_before_revision;pending=null;annotationDirty=false;showState();showRecord(true);await query(ticket); + } + $('text-import-form').onsubmit=event=>{ + event.preventDefault();if(annotationDirty){error('Submit or discard the current annotation before creating another text record.');return;} + run(async ticket=>{ + const asset={name:$('text-name').value,text:$('text-content').value,groups:groups($('text-groups').value),rights:$('text-rights').value}; + if(!str(asset.name,200)||!str(asset.text,200000)||!str(asset.rights,1000))throw Error('Enter a name, text and rights note within the displayed limits.'); + const value=await request('/import-text',{kind:'record',ticket,body:mutationBody('/import-text',{asset})});if(!value)return; + importDirty=false;$('text-name').value='';$('text-content').value='';await acceptRecord(value,ticket);message('Text submitted and saved to the project.'); + }); + }; + $('annotation-form').onsubmit=event=>{ + event.preventDefault();run(async ticket=>{ + if(!selected)throw Error('Select a saved text record first.'); + const task=$('annotation-task').value,raw=$('annotation-value').value; + let annotation;if(task==='text_entities'){try{annotation=JSON.parse(raw);}catch(_){throw Error('Entity spans must be valid JSON.');}}else annotation=task==='text_corpus'?{note:raw}:{label:raw}; + if(!annotationOK(task,annotation))throw Error('The annotation does not match this task. Follow the instructions above the editor.'); + const body={revision:selected.revision,source_revision:selected.source_revision,task,annotation,groups:groups($('annotation-groups').value),review:$('record-review').value}; + const value=await request('/records/'+selected.id,{kind:'record',ticket,recordId:selected.id,body:mutationBody('/records/'+selected.id,{annotation:body})});if(!value)return; + await acceptRecord(value,ticket);message('Annotation submitted and saved to the project.'); + }); + }; + function projectValue(value,depth=0){ + const box=document.createElement('div'); + if(value===null||typeof value!=='object'){box.textContent=value===null?'Not recorded':String(value);return box;} + if(depth>5){box.textContent='Further saved details available through the project API.';return box;} + const entries=Array.isArray(value)?value.map((item,index)=>[String(index+1),item]):Object.entries(value); + const list=document.createElement('dl'); + for(const [name,item] of entries){ + const term=document.createElement('dt'),definition=document.createElement('dd');term.textContent=name.replaceAll('_',' '); + if(item!==null&&typeof item==='object'){ + const details=document.createElement('details'),summary=document.createElement('summary');summary.textContent=Array.isArray(item)?`${item.length} saved items`:'Saved details';details.append(summary,projectValue(item,depth+1));definition.append(details); + }else definition.textContent=item===null?'Not recorded':String(item); + list.append(term,definition); + } + box.append(list);return box; + } + const m2Allowed=action=>!busy&&!blocked&&state?.state==='open'&&selectedContracts.has('project_files.'+action); + function m2Controls(){ + const action=$('project-explore-kind').value; + $('project-explore-load').disabled=!m2Allowed(action); + $('project-explore-more').disabled=!m2Allowed(action); + for(const [field,operation] of [['project-source-fields','data_import_'+$('project-source-kind').value],['project-search-fields','search_create'],['project-selection-fields','selection_create'],['project-rights-fields','data_rights'],['project-data-note-fields','data_annotate']])$(field).disabled=!m2Allowed(operation)||(['project-selection-fields','project-rights-fields','project-data-note-fields'].includes(field)&&(!explored||explored.checkpoint!==checkpoint())); + $('project-definitions-load').disabled=!m2Allowed('definitions'); + $('project-model-create-fields').disabled=!modelTemplates.length||templateBinding!==state?.binding||!m2Allowed('model_create'); + $('project-model-publish').disabled=!explored||explored.category!=='models'||explored.checkpoint!==checkpoint()||!m2Allowed('model_publish'); + $('project-study-create-fields').disabled=!m2Allowed('study_create'); + $('project-register-fields').disabled=!m2Allowed('source_register'); + $('project-source-references-more').disabled=!m2Allowed('source_references'); + $('project-scientific-fields').disabled=!registeredSource||registeredSource.binding!==state?.binding||!m2Allowed('data_import_scientific'); + executionControls(); + $('project-artifact-status').disabled=!explored||explored.category!=='artifacts'||!m2Allowed('artifact_status'); + $('project-artifact-resolve-fields').disabled=!explored||explored.category!=='artifacts'||!m2Allowed('artifact_resolve'); + $('project-metadata-fields').disabled=!explored||explored.checkpoint!==checkpoint()||!m2Allowed(explored.saveAction); + for(const name of ['project-detail-history','project-detail-availability','project-run-evidence','project-run-document','project-run-disclose','project-detail-history-more'])$(name).disabled=busy||blocked||state?.state!=='open'; + for(const button of $('project-explore-list').querySelectorAll('button'))button.disabled=busy||blocked; + } + function clearExploration(){ + explored=null;exploreOffset=0;exploreTotal=0;historyOffset=0;historyTotal=0; + $('project-explore-list').replaceChildren();$('project-detail-facts').replaceChildren();$('project-detail-history-list').replaceChildren(); + $('project-detail-title').textContent='Choose a saved record';$('project-explore-summary').textContent=''; + for(const name of ['project-metadata-form','project-data-note-form','project-rights-form','project-selection-form','project-detail-history','project-detail-history-more','project-detail-availability','project-run-evidence','project-run-document','project-run-disclose','project-disclosure-label','project-model-publish','project-artifact-status','project-artifact-resolve-form','project-explore-more'])if(!m2DirtyForms.has(name))$(name).hidden=true; + if(m2Dirty)$('project-detail-title').textContent='Preserved unsubmitted metadata; discard it before selecting a saved record.'; + } + async function m4Response(action,value,args,project){ + const invalid=()=>{const failure=Error('Saved file or fork response differs from its selected identity, revision or exact bytes.');failure.code='transport';throw failure;}; + const natural=value=>Number.isSafeInteger(value)&&value>=0; + const safeText=(value,max,empty=false)=>str(value,max,empty)&&!value.includes('\0')&&!Array.from(value).some(ch=>ch.length===1&&ch.charCodeAt(0)>=0xd800&&ch.charCodeAt(0)<=0xdfff); + const filePath=(kind,path)=>['script','note'].includes(kind)&&safeText(path,512)&&path.startsWith((kind==='script'?'scripts':'notes')+'/')&&!path.includes('\\')&&path.split('/').every(part=>part!==''&&part!=='.'&&part!=='..')&&!/[\x00-\x1f]/.test(path); + const shaBytes=async raw=>Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',raw)),n=>n.toString(16).padStart(2,'0')).join(''); + const fileID=async value=>shaBytes(new TextEncoder().encode(project+'\0'+value.kind+'\0'+value.path)); + const metadata=value=>exact(value,['revision','sha256','bytes','adopted_at','actor_id'])&&positive(value.revision)&&sha(value.sha256)&&natural(value.bytes)&&value.bytes<=1048576&&safeText(value.adopted_at,80)&&id(value.actor_id); + const deletedMetadata=value=>exact(value,['revision','deleted','sha256','bytes','adopted_at','actor_id'])&&positive(value.revision)&&value.revision>=2&&value.deleted===true&&value.sha256===null&&value.bytes===0&&safeText(value.adopted_at,80)&&id(value.actor_id); + const families=['model_definition','pipeline_graph','recipe_definition']; + const fields=['task','specification','source_selector','preprocessing_policy','output_policy','recipe_source','inputs','runtime','release','nodes','data','evaluation_profile','model','trainer']; + const reselection=value=>Array.isArray(value)&&value.length<=14&&new Set(value).size===value.length&&value.every(field=>fields.includes(field)); + const origin=value=>exact(value,['project_id','checkpoint','family','id','revision','created_at'])&&id(value.project_id)&&sha(value.checkpoint)&&families.includes(value.family)&&safeText(value.id,512)&&positive(value.revision)&&safeText(value.created_at,80); + if(['file_get','file_adopt','file_adopt_deletion'].includes(action)){ + const keys=action==='file_get'?['id','project_id','kind','path','version']:['id','project_id','kind','path','revision','sha256','bytes','adopted_at','changed',...(action==='file_adopt_deletion'?['deleted']:[])]; + if(!exact(value,keys)||!sha(value.id)||value.project_id!==project||!filePath(value.kind,value.path)||value.id!==await fileID(value))invalid(); + if(action==='file_get'){ + const v=value.version,deleted=v?.deleted===true,meta=Object.fromEntries(Object.entries(v??{}).filter(([k])=>k!=='text')); + if(deleted){if(value.kind!=='note'||!exact(v,['revision','deleted','sha256','bytes','adopted_at','actor_id','text'])||!deletedMetadata(meta)||v.text!==null)invalid();} + else{if(!exact(v,['revision','sha256','bytes','adopted_at','actor_id','text'])||!metadata(meta)||typeof v.text!=='string'||Array.from(v.text).some(ch=>ch.length===1&&ch.charCodeAt(0)>=0xd800&&ch.charCodeAt(0)<=0xdfff))invalid(); + const bytes=new TextEncoder().encode(v.text);if(bytes.length>1048576||bytes.length!==v.bytes||await shaBytes(bytes)!==v.sha256)invalid();} + if(value.id!==args.file_id||(args.revision!==undefined&&args.revision!==v.revision))invalid(); + }else if(action==='file_adopt_deletion'){ + if(value.kind!=='note'||value.path!==args.path||value.deleted!==true||value.sha256!==null||value.bytes!==0||value.changed!==true||value.revision!==args.expected_revision+1||!safeText(value.adopted_at,80))invalid(); + }else if(value.kind!==args.kind||value.path!==args.path||value.sha256!==args.expected_sha256||!positive(value.revision)||typeof value.changed!=='boolean'||value.revision!==args.expected_revision+Number(value.changed)||!natural(value.bytes)||value.bytes>1048576||!safeText(value.adopted_at,80))invalid(); + } + if(action==='file_history'&&(!exact(value,['id','items','total','offset','limit'])||value.id!==args.file_id||!value.items.every(item=>metadata(item)||deletedMetadata(item))))invalid(); + if(action==='files_inspect')for(const item of value.items){ + if(!exact(item,['id','kind','path','revision','expected_sha256','expected_bytes','observed_sha256','observed_bytes','state','reason'])||!filePath(item.kind,item.path)||item.id!==await fileID(item)||!positive(item.revision)||!(item.expected_sha256===null||sha(item.expected_sha256))||!natural(item.expected_bytes)||item.expected_bytes>1048576||!(item.observed_sha256===null||sha(item.observed_sha256))||!(item.observed_bytes===null||natural(item.observed_bytes)&&item.observed_bytes<=1048576)||!['unchanged','changed','missing','invalid','deleted'].includes(item.state)||!(item.reason===null||['missing','unsafe_path','too_large','invalid_utf8','unreadable','unstable'].includes(item.reason)))invalid(); + if(item.expected_sha256===null&&(item.kind!=='note'||item.expected_bytes!==0||['unchanged','missing'].includes(item.state)))invalid(); + if(item.state==='deleted'&&(item.expected_sha256!==null||item.observed_sha256!==null||item.observed_bytes!==null||item.reason!==null))invalid(); + if(item.state==='unchanged'&&(item.observed_sha256!==item.expected_sha256||item.observed_bytes!==item.expected_bytes||item.reason!==null))invalid(); + } + if(action==='material_intents')for(const item of value.items)if(!exact(item,['family','id','catalog_id','definition_id','name','revision','required_reselection','fork_origin'])||!families.includes(item.family)||item.catalog_id!==project||!id(item.definition_id)||item.id!==project+'.'+item.definition_id||!safeText(item.name,200)||item.revision!==1||!reselection(item.required_reselection)||!origin(item.fork_origin)||item.fork_origin.family!==item.family)invalid(); + if(action==='material_intent'){ + const current=value.current; + if(!exact(value,['family','id','current','fork_origin'])||value.family!==args.family||value.id!==project+'.'+args.definition_id||!exact(current,['format','catalog_id','id','revision','name','definition_family','definition','required_reselection'])||current.format!=='project_material_fork_intent_v1'||current.catalog_id!==project||current.id!==args.definition_id||current.revision!==1||!safeText(current.name,200)||current.definition_family!==args.family||current.definition===null||typeof current.definition!=='object'||Array.isArray(current.definition)||!reselection(current.required_reselection)||!origin(value.fork_origin)||value.fork_origin.family!==args.family)invalid(); + } + if(action==='material_intent_capabilities')for(const item of value.items)if(!exact(item,['family','id','requires_reselection'])||!families.includes(item.family)||typeof item.id!=='string'||!item.id.startsWith(project+'.')||!id(item.id.slice(33))||!reselection(item.requires_reselection))invalid(); + if(action==='fork_info'){ + const page=value.mapping,offset=args.offset??0,limit=args.limit??40; + if(!exact(value,['source','project_id','mapping','archived_count','copied_count','missing_regenerable_count'])||value.project_id!==project||!(value.source===null||exact(value.source,['project_id','checkpoint'])&&id(value.source.project_id)&&sha(value.source.checkpoint))||!['archived_count','copied_count','missing_regenerable_count'].every(k=>natural(value[k]))||!exact(page,['items','total','offset','limit'])||!natural(page.total)||page.offset!==offset||page.limit!==limit||!Array.isArray(page.items)||page.items.length!==Math.min(limit,Math.max(0,page.total-offset)))invalid(); + for(const item of page.items)if(!exact(item,['family','source_id','source_revision','destination_id'])||!safeText(item.family,80)||!safeText(item.source_id,512)||!(item.source_revision===null||positive(item.source_revision))||!safeText(item.destination_id,512))invalid(); + } + } + async function m2Request(action,arguments_,ticket){ + const operation='project_files.'+action,definition=m2Operations[operation]; + if(!definition||!selectedContracts.has(operation))throw Error('This operation is unavailable for the selected project.'); + const binding=state.binding,cp=checkpoint(),project=state.project.project_id; + let values={binding,[definition.mutation?'expected_checkpoint':'checkpoint']:cp,...arguments_}; + let path=definition.path; + for(const [name,value] of Object.entries(values))path=path.replace('{'+name+'}',encodeURIComponent(value)); + const remaining=Object.fromEntries(Object.entries(values).filter(([name])=>!definition.path.includes('{'+name+'}'))); + const options={method:definition.method,cache:'no-store',headers:{'Tuldok-Operation-Contract':selectedHeader(operation)}}; + if(definition.method==='GET')path+='?'+new URLSearchParams(remaining); + else{ + if(definition.mutation&&!['execution_reconcile_current','execution_study_reconcile_preparation','native_reconcile'].includes(action)){ + let prepared; + if(action==='runtime_capture'){ + const signature=JSON.stringify(arguments_); + if(runtimePending&&(runtimePending.signature!==signature||runtimePending.body.binding!==binding))throw Error('Inspect the pending runtime capture outcome, then keep its exact request or explicitly discard the capture form before changing source, inventory or reserve.'); + if(!runtimePending)runtimePending={signature,body:mutationBody(path,arguments_)}; + prepared=runtimePending.body;$('project-runtime-status-key').value=prepared.request_key;$('project-runtime-abandon-key').value=prepared.request_key; + $('project-runtime-pending').textContent=`Pending capture ${prepared.request_key}: source ${prepared.source}, inventory ${prepared.expected_inventory_sha256}, reserve ${prepared.reserve_bytes??6442450944} bytes. Retry uses these exact fields. Preserve the key until its retained outcome is known.`; + Object.assign(remaining,prepared); + }else if(['native_preflight','native_submit','native_retry'].includes(action)){ + const signature=JSON.stringify({action,arguments_}); + if(nativePending&&(nativePending.signature!==signature||nativePending.body.binding!==binding))throw Error('Keep the exact pending native request until its outcome is known, or explicitly discard unsubmitted fields before choosing a different action.'); + if(!nativePending)nativePending={action,signature,body:mutationBody(path,arguments_)}; + prepared=nativePending.body;Object.assign(remaining,Object.fromEntries(Object.entries(prepared).filter(([field])=>!definition.path.includes('{'+field+'}')))); + $('project-native-pending').textContent=`Pending ${action}: request ${prepared.request_key}. Keep this exact key and fields after a lost acknowledgement. No automatic retry is performed.`; + }else prepared=mutationBody(path,arguments_); + values={...values,...prepared};remaining.request_key=prepared.request_key; + } + if(['execution_study_prepare_package','execution_study_prepare_verifier'].includes(action)){$('project-candidate-recovery-study').value=arguments_.study_id;$('project-candidate-recovery-key').value=values.request_key;$('project-candidate-preparation-status').textContent=`Pending preparation ${values.request_key}. Preserve this key until its outcome is known.`;} + options.headers['Content-Type']='application/json';options.body=JSON.stringify(remaining); + if(new TextEncoder().encode(options.body).length>1048576)throw Error('This source exceeds the synchronous import limit. Choose a smaller source.'); + } + let response,value; + try{response=await window.TuldokAuth.fetch(path,options);value=await response.json();} + catch(_){const failure=Error(definition.mutation?'Acknowledgement unavailable; this change may already be saved. Keep your edits and refresh project status.':'Saved history is unavailable. Refresh project status.');failure.code='transport';throw failure;} + if(ticket!==epoch)return null; + if(!response.ok){ + const failure=Error(str(value?.error,1000)?value.error:'Unsupported project error response.');failure.code=value?.code??'transport';failure.status=response.status; + if(value?.receipt){if(!receiptOK(value.receipt)||value.receipt.request_key!==values.request_key||value.receipt.project_id!==project||!value.receipt.selected){const invalid=Error('Project receipt belongs to a different request.');invalid.code='transport';throw invalid;}failure.receipt=value.receipt;} + throw failure; + } + if(!exact(value,['binding','project_id','checkpoint','value'])||value.binding!==binding||value.project_id!==project||!sha(value.checkpoint)||(!definition.mutation&&value.checkpoint!==cp)||!value.value||typeof value.value!=='object'||Array.isArray(value.value)){ + const failure=Error('Saved response belongs to a different project or checkpoint. Refresh project status.');failure.code='transport';throw failure; + } + if(m2PagedActions.has(action)){ + const page=value.value,limit=arguments_.limit??(m2HistoryActions.has(action)?1:40),offset=arguments_.offset??0; + if(!Array.isArray(page.items)||page.items.length>limit||page.offset!==offset||page.limit!==limit||!Number.isSafeInteger(page.total)||page.total<0||page.items.length!==Math.min(limit,Math.max(0,page.total-offset))){const failure=Error('Saved page differs from its requested continuation.');failure.code='transport';throw failure;} + } + if(['files_inspect','file_get','file_history','file_adopt','file_adopt_deletion','material_intents','material_intent','material_intent_capabilities','fork_info'].includes(action)){await m4Response(action,value.value,arguments_,project);if(ticket!==epoch)return null;} + if(action.startsWith('execution_')){ + const result=value.value,invalid=()=>{const failure=Error('Current execution response differs from the selected project, material or action.');failure.code='transport';throw failure;}; + if(Object.hasOwn(result,'origin_catalog_id')&&result.origin_catalog_id!==project)invalid(); + if(['execution_approve_external','execution_study_approve'].includes(action)){ + const grant=result.grant,expected=arguments_.expected_digest??arguments_.expected_material_sha256; + if(result.format!=='project_current_execution_approval_v1'||result.current_local!==true||!id(grant?.id)||grant.catalog_id!==project||grant.consumer_actor_id!==arguments_.consumer_actor_id||grant.material_sha256!==expected||grant.revoked_at!==null||!Number.isSafeInteger(grant.created_at)||!Number.isSafeInteger(grant.expires_at)||grant.expires_at<=grant.created_at)invalid(); + } + if(action==='execution_cancel'&&result.run_id!==arguments_.run_id)invalid(); + if(['execution_study_admit','execution_study_stop'].includes(action)&&result.study_id!==arguments_.study_id)invalid(); + if(action==='execution_study_admit'&&(result.trial_id!==arguments_.trial_id||result.reservation?.trial_id!==result.trial_id||result.reservation?.run_id!==result.run_id))invalid(); + const actions={execution_producer_start:'start',execution_generation_resume:'resume',execution_grounded_review:'review',execution_producer_decide:'decide',execution_producer_cancel:'cancel'}; + if(Object.hasOwn(actions,action)){ + const family=arguments_.family??(action==='execution_generation_resume'?'generation_history':'grounded_history'); + if(result.format!=='project_producer_action_v1'||result.family!==family||result.action!==actions[action]||!id(result.producer_id)||(arguments_.producer_id&&result.producer_id!==arguments_.producer_id)||(arguments_.document?.request_id&&result.producer_id!==arguments_.document.request_id))invalid(); + } + } + if(definition.mutation){state={...state,project:{...state.project,checkpoint:value.checkpoint}};pending=null;showState();} + return value.value; + } + async function explore(ticket,more=false){ + const action=$('project-explore-kind').value,offset=more?exploreOffset:0,args={offset,limit:40}; + if(['policies','lineage'].includes(action))args.origin_catalog_id=state.project.project_id; + if(action==='producers')args.family=$('project-producer-family').value; + if(action==='data_query'){if($('project-data-search').value)args.q=$('project-data-search').value;if($('project-data-kind').value)args.kind=$('project-data-kind').value;} + const value=await m2Request(action,args,ticket);if(!value)return; + if(!more)$('project-explore-list').replaceChildren(); + for(const item of value.items){ + const row=document.createElement('li'),button=document.createElement('button');button.type='button'; + const fact=item.study??item.run??item.current??item.value??item,origin=item.origin_catalog_id??fact.ref?.catalog_id??state.project.project_id; + button.textContent=[fact.name??fact.id??item.sha256??item.id??'Saved record',fact.kind??fact.format??item.family??'',fact.state??fact.review??item.availability?.status??'',fact.revision?`revision ${fact.revision}`:''].filter(Boolean).join(' · '); + button.onclick=()=>run(ticket=>inspectItem(action,item,origin,ticket),{clean:true});row.append(button);$('project-explore-list').append(row); + } + exploreOffset=value.offset+value.items.length;exploreTotal=value.total;$('project-explore-more').hidden=exploreOffset>=exploreTotal; + $('project-explore-summary').textContent=`Showing ${value.total?offset+1:0}–${exploreOffset} of ${exploreTotal} saved records.`; + } + async function inspectItem(category,item,origin,ticket){ + const fact=item.study??item.run??item.current??item.value??item; + const entry={category,origin,id:fact.id??item.id,value:fact,checkpoint:checkpoint()}; + const request={data_query:['data_get','record_id'],models:['model_get','model_id'],graphs:['graph_get','graph_id'],recipes:['recipe_get','recipe_id'],studies:['study','study_id'],runs:['run','run_id'],selections:['selection_get','selection_id']}[category]; + let detail=fact; + if(category==='producers'){detail=await m2Request('producer',{origin_catalog_id:origin,producer_id:entry.id,family:$('project-producer-family').value},ticket);if(!detail)return;} + if(request){const args={[request[1]]:entry.id};if(['studies','runs'].includes(category))args.origin_catalog_id=origin;detail=await m2Request(request[0],args,ticket);if(!detail)return;} + clearExplorationDetail();entry.value=detail.study??detail;entry.saveAction={models:'model_save',graphs:'graph_save',recipes:'recipe_save',studies:'study_save'}[category];explored=entry; + $('project-detail-title').textContent=entry.value.name??`${category==='runs'?'Historical Run':'Saved record'} ${entry.id??''}`; + $('project-detail-facts').append(projectValue(detail)); + if(category==='artifacts'){entry.artifactSha=detail.sha256;$('project-artifact-status').hidden=false;artifactResolutionForm(detail.availability?.state);} + if(category==='models')$('project-model-publish').hidden=false; + if(entry.saveAction&&origin===state.project.project_id){$('project-metadata-form').hidden=false;$('project-metadata-name').value=entry.value.name??'';} + if(category==='data_query'){ + $('project-detail-history').hidden=false;$('project-detail-availability').hidden=false;$('project-rights-form').hidden=false;$('project-selection-form').hidden=false; + $('project-rights-note').value=entry.value.provenance?.rights_note_correction?.note??entry.value.provenance?.rights??'unknown'; + const key=entry.value.task?.endsWith('_classification')?'label':entry.value.task==='image_caption'?'caption':entry.value.task==='text_corpus'||['sequence','mesh','pointcloud','volume','field_sequence'].includes(entry.value.kind)?'note':null; + if(key){entry.annotationKey=key;$('project-data-note-form').hidden=false;$('project-data-note').value=entry.value.annotation?.[key]??'';$('project-data-note-help').textContent=`${entry.value.task.replaceAll('_',' ')} · save a ${key==='label'?'class label':key}.`;$('project-data-note').maxLength=key==='label'?80:4000;$('project-data-review').value=entry.value.review==='human_reviewed'?'human_reviewed':'draft';} + } + if(category==='runs'){entry.document=detail.document;$('project-run-document').hidden=!detail.document;$('project-run-evidence').hidden=false;$('project-run-disclose').hidden=!selectedContracts.has('project_files.expose_holdout');$('project-disclosure-label').hidden=$('project-run-disclose').hidden;} + controls();$('project-detail-title').focus(); + } + function clearExplorationDetail(){ + explored=null;historyOffset=0;historyTotal=0;$('project-detail-facts').replaceChildren();$('project-detail-history-list').replaceChildren(); + for(const name of ['project-metadata-form','project-data-note-form','project-rights-form','project-selection-form','project-detail-history','project-detail-history-more','project-detail-availability','project-run-evidence','project-run-document','project-run-disclose','project-disclosure-label','project-model-publish','project-artifact-status','project-artifact-resolve-form'])$(name).hidden=true; + } + async function dataHistory(ticket,more=false){ + if(!explored||explored.category!=='data_query')throw Error('Select saved data first.'); + const value=await m2Request('data_history',{record_id:explored.id,offset:more?historyOffset:0,limit:1},ticket);if(!value)return; + if(!more)$('project-detail-history-list').replaceChildren(); + for(const snapshot of value.items){const row=document.createElement('li');row.append(projectValue(snapshot));$('project-detail-history-list').append(row);} + historyOffset=value.offset+value.items.length;historyTotal=value.total;$('project-detail-history-more').hidden=historyOffset>=historyTotal; + message(`Saved revisions loaded: ${historyOffset} of ${historyTotal}.`); + } + async function metadataSaved(ticket,action,args){ + const value=await m2Request(action,args,ticket);if(!value)return; + const form={model_save:'project-metadata-form',graph_save:'project-metadata-form',recipe_save:'project-metadata-form',study_save:'project-metadata-form',data_annotate:'project-data-note-form',data_rights:'project-rights-form',selection_create:'project-selection-form',search_create:'project-search-form',data_import_image:'project-source-form',data_import_audio:'project-source-form',model_create:'project-model-create-form',study_create:'project-study-create-form',data_import_scientific:'project-scientific-form'}[action]; + if(form)for(const field of $(form).querySelectorAll('input'))if(!['number','file'].includes(field.type))field.value=''; + m2DirtyForms.delete(form);m2Dirty=m2DirtyForms.size>0; + if(!m2Dirty)clearExplorationDetail();await query(ticket);message('Change saved to the project. Load saved records to inspect the new revision.');controls(); + } + $('project-explore-load').onclick=()=>run(ticket=>explore(ticket),{clean:true}); + $('project-explore-more').onclick=()=>run(ticket=>explore(ticket,true),{clean:true}); + $('project-detail-history').onclick=()=>run(ticket=>dataHistory(ticket)); + $('project-detail-history-more').onclick=()=>run(ticket=>dataHistory(ticket,true)); + function artifactResolutionForm(availability){ + $('project-artifact-resolve-form').hidden=availability!=='missing'; + const roots=$('project-artifact-root'),previous=roots.value;roots.replaceChildren(); + for(const root of state.roots){const option=document.createElement('option');option.value=root;option.textContent=root;roots.append(option);} + if(state.roots.includes(previous))roots.value=previous; + } + async function artifactStatus(ticket){ + if(!explored?.artifactSha)throw Error('Select a saved artifact first.'); + const value=await m2Request('artifact_status',{sha256:explored.artifactSha},ticket);if(!value)return; + $('project-detail-facts').append(projectValue(value));artifactResolutionForm(value.state);controls(); + } + $('project-artifact-status').onclick=()=>run(artifactStatus); + $('project-artifact-resolve-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + if(!explored?.artifactSha)throw Error('Select a missing artifact first.'); + const expected=explored.artifactSha,value=await m2Request('artifact_resolve',{sha256:expected,root:$('project-artifact-root').value,path:$('project-artifact-path').value},ticket);if(!value)return; + if(value.format!=='project_artifact_resolution_v1'||value.sha256!==expected)throw Error('Artifact resolution observation differs from this artifact.'); + m2DirtyForms.delete('project-artifact-resolve-form');m2Dirty=m2DirtyForms.size>0;$('project-artifact-path').value=''; + $('project-detail-facts').append(projectValue(value));await artifactStatus(ticket);message('Artifact resolution observation saved. Current availability refreshed.'); + });}; + for(const field of ['project-artifact-root','project-artifact-path'])$(field).addEventListener('input',()=>{m2DirtyForms.add('project-artifact-resolve-form');m2Dirty=true;controls();}); + $('project-detail-availability').onclick=()=>run(async ticket=>{const value=await m2Request('data_availability',{record_id:explored.id},ticket);if(value)$('project-detail-facts').append(projectValue(value));}); + $('project-run-evidence').onclick=()=>run(async ticket=>{const value=await m2Request('evidence',{run_id:explored.id,origin_catalog_id:explored.origin},ticket);if(value){$('project-detail-facts').append(projectValue(value));message('Retained evidence read through current disclosure and integrity checks.');}}); + $('project-run-disclose').onclick=()=>run(async ticket=>{const value=await m2Request('expose_holdout',{run_id:explored.id,origin_catalog_id:explored.origin,reason:$('project-disclosure-reason').value},ticket);if(value){$('project-detail-facts').append(projectValue(value));message('Held-out disclosure allowed for this Run on this installation.');}},{clean:true}); + $('project-metadata-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + const value=explored.value,name=$('project-metadata-name').value;if(!str(name,120))throw Error('Enter a name up to 120 characters.'); + const args=explored.category==='studies'?{study_id:explored.id,revision:value.revision,name,limits:value.limits,policy:value.policy}:{[{models:'model_id',graphs:'graph_id',recipes:'recipe_id'}[explored.category]]:explored.id,revision:value.revision,document:{...value,name}}; + await metadataSaved(ticket,explored.saveAction,args); + });}; + $('project-data-note-form').onsubmit=event=>{event.preventDefault();run(ticket=>metadataSaved(ticket,'data_annotate',{record_id:explored.id,revision:explored.value.revision,source_revision:explored.value.source_revision,task:explored.value.task,annotation:{[explored.annotationKey]:$('project-data-note').value},groups:explored.value.groups,review:$('project-data-review').value}));}; + $('project-rights-form').onsubmit=event=>{event.preventDefault();run(ticket=>metadataSaved(ticket,'data_rights',{record_id:explored.id,revision:explored.value.revision,source_revision:explored.value.source_revision,note:$('project-rights-note').value}));}; + $('project-selection-form').onsubmit=event=>{event.preventDefault();run(ticket=>metadataSaved(ticket,'selection_create',{document:{name:$('project-selection-name').value,items:[{id:explored.id,revision:explored.value.revision,source_revision:explored.value.source_revision}]}}));}; + $('project-search-form').onsubmit=event=>{event.preventDefault();run(ticket=>metadataSaved(ticket,'search_create',{document:{name:$('project-search-name').value,criteria:{q:$('project-data-search').value,kind:$('project-data-kind').value,review:'',sort:'',task:'',label:'',group:'',rights:''}}}));}; + $('project-source-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + const file=$('project-source-file').files?.[0];if(!file||file.size>524288)throw Error('Choose a source file of at most 512 KiB.'); + const bytes=new Uint8Array(await file.arrayBuffer());let raw='';for(const byte of bytes)raw+=String.fromCharCode(byte); + if(ticket!==epoch)return; + const kind=$('project-source-kind').value,args={name:$('project-source-name').value,groups:groups($('project-source-groups').value),rights:$('project-source-rights').value,[kind==='audio'?'wav':'image']:btoa(raw)}; + await metadataSaved(ticket,'data_import_'+kind,args);$('project-source-file').value='';$('project-source-name').value=''; + });}; + for(const field of ['project-metadata-name','project-data-note','project-data-review','project-rights-note','project-selection-name','project-search-name','project-source-file','project-source-name','project-source-groups','project-source-rights'])$(field).addEventListener('input',()=>{const form=field.startsWith('project-source-')?'project-source-form':field.startsWith('project-metadata-')?'project-metadata-form':field.startsWith('project-data-')?'project-data-note-form':field.startsWith('project-rights-')?'project-rights-form':field.startsWith('project-selection-')?'project-selection-form':'project-search-form';m2DirtyForms.add(form);m2Dirty=true;controls();}); + $('project-source-kind').addEventListener('change',()=>{m2DirtyForms.add('project-source-form');m2Dirty=true;controls();}); + $('project-explore-kind').addEventListener('change',controls); + $('project-definitions-load').onclick=()=>run(async ticket=>{ + const value=await m2Request('definitions',{},ticket);if(!value)return; + if(!Array.isArray(value.recipes))throw Error('No model template definitions are available.'); + modelTemplates=value.recipes.filter(item=>item.model_template).map(item=>item.model_template);templateBinding=state.binding; + $('project-model-template').replaceChildren(); + for(const [index,template] of modelTemplates.entries()){const option=document.createElement('option');option.value=String(index);option.textContent=template.name;$('project-model-template').append(option);} + controls();message(modelTemplates.length?'Available model templates loaded.':'This installation has no model templates.'); + },{clean:true}); + $('project-model-create-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + if(templateBinding!==state.binding)throw Error('Load model templates for this project first.'); + const template=modelTemplates[Number($('project-model-template').value)];if(!template)throw Error('Choose a model template.'); + const document=JSON.parse(JSON.stringify(template));document.name=$('project-model-create-name').value; + await metadataSaved(ticket,'model_create',document); + });}; + $('project-model-publish').onclick=()=>run(ticket=>metadataSaved(ticket,'model_publish',{model_id:explored.id,revision:explored.value.revision}),{clean:true}); + for(const field of ['project-model-template','project-model-create-name'])$(field).addEventListener('input',()=>{m2DirtyForms.add('project-model-create-form');m2Dirty=true;controls();}); + $('project-study-create-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + const limits={};for(const [key,field] of [['attempt_slots','attempts'],['admitted_wall_seconds','seconds'],['retained_bytes','retained'],['peak_staging_bytes','staging']]){const value=Number($('project-study-'+field).value);if(!Number.isSafeInteger(value)||value<1)throw Error('Study budget values must be positive whole numbers.');limits[key]=value;} + const metric=$('project-study-metric').value;if(!sha(metric))throw Error('Use the exact SHA256 of a saved metric definition.'); + await metadataSaved(ticket,'study_create',{name:$('project-study-create-name').value,limits,policy:{format:'selection_policy_v1',metric_definition_sha256:metric,checkpoint_rule:$('project-study-checkpoint').value,replicate_reduce:$('project-study-replicates').value,required_capabilities:[],numerical_acceptance:$('project-study-numerical').value,tie_break:'trial_id'}}); + });}; + function scientificReferenceHash(reference){ + if(reference.status!=='available'||!sha(reference.observed_sha256))return null; + if(Object.hasOwn(reference,'sha256'))return sha(reference.sha256)&&reference.integrity==='verified'&&reference.observed_sha256===reference.sha256?reference.observed_sha256:null; + return reference.integrity==='unverified'?reference.observed_sha256:null; + } + async function sourceReferences(ticket,more){ + if(!registeredSource||registeredSource.binding!==state.binding)throw Error('Register this source for the current project binding first.'); + const {origin_catalog_id,project_id,manifest_sha256}=registeredSource; + const value=await m2Request('source_references',{origin_catalog_id,project_id,manifest_sha256,offset:more?registeredSource.next_offset:0,limit:40},ticket);if(!value)return; + if(!more){registeredSource.references=[];$('project-scientific-reference').replaceChildren();} + for(const reference of value.items){ + registeredSource.references.push(reference);const observed=scientificReferenceHash(reference),option=document.createElement('option');option.value=reference.id;option.disabled=observed===null; + const integrity=observed?(Object.hasOwn(reference,'sha256')?'declared digest verified':'observed digest unverified; not pinned in the manifest'):(reference.integrity==='mismatch'?'declared digest mismatch':reference.status??'unavailable'); + option.textContent=`${reference.label??reference.id} · ${integrity}`;$('project-scientific-reference').append(option); + } + registeredSource.next_offset=value.offset+value.items.length;$('project-source-references-more').hidden=registeredSource.next_offset>=value.total; + } + $('project-source-references-more').onclick=()=>run(ticket=>sourceReferences(ticket,true)); + $('project-run-document').onclick=()=>run(async ticket=>{ + const descriptor=explored?.document;if(!descriptor||!sha(descriptor.sha256)||!Number.isSafeInteger(descriptor.bytes)||descriptor.bytes<0||descriptor.bytes>64*1024*1024)throw Error('This retained document cannot be downloaded in this browser. Use its exact project API segments.'); + const pieces=[];let offset=0; + do{ + const limit=262144,value=await m2Request('run_document_segment',{run_id:explored.id,origin_catalog_id:explored.origin,expected_sha256:descriptor.sha256,offset,limit},ticket);if(!value)return; + if(value.format!=='project_run_document_segment_v1'||value.origin_catalog_id!==explored.origin||value.run_id!==explored.id||value.sha256!==descriptor.sha256||value.bytes!==descriptor.bytes||value.offset!==offset||value.limit!==limit||typeof value.base64!=='string')throw Error('Retained document segment differs from the selected document.'); + const raw=atob(value.base64),bytes=Uint8Array.from(raw,char=>char.charCodeAt(0)); + if(bytes.length!==Math.min(limit,descriptor.bytes-offset)||value.next_offset!==(offset+bytes.lengthbyte.toString(16).padStart(2,'0')).join('')!==descriptor.sha256)throw Error('Retained document integrity differs; no download was created.'); + const link=document.createElement('a'),url=URL.createObjectURL(blob);link.href=url;link.download='retained-run-'+explored.id+'.json';link.click();URL.revokeObjectURL(url);message('Exact retained Run document downloaded and verified.'); + }); + $('project-register-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + const value=await m2Request('source_register',{root:$('project-files-root').value,manifest_path:$('project-register-path').value},ticket);if(!value)return; + if(!id(value.project_id)||!id(value.origin_catalog_id)||!sha(value.manifest_sha256))throw Error('The registered source identity is unavailable.'); + registeredSource={binding:state.binding,origin_catalog_id:value.origin_catalog_id,project_id:value.project_id,manifest_sha256:value.manifest_sha256,references:[],next_offset:0}; + await sourceReferences(ticket,false); + m2DirtyForms.delete('project-register-form');m2Dirty=m2DirtyForms.size>0;controls();message('Source manifest saved. Select one exact declared data reference to import.'); + });}; + $('project-scientific-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + if(!registeredSource||registeredSource.binding!==state.binding)throw Error('Register a local source for this project binding first.'); + const reference=registeredSource.references.find(item=>item.id===$('project-scientific-reference').value),observed=reference&&scientificReferenceHash(reference);if(!observed)throw Error('Choose an available data reference with a current observed digest. Declared digests must match and be verified.'); + const {origin_catalog_id,project_id,manifest_sha256}=registeredSource; + await metadataSaved(ticket,'data_import_scientific',{snapshot:{origin_catalog_id,project_id,manifest_sha256},reference_id:reference.id,expected_sha256:observed,document:{name:$('project-scientific-name').value,groups:groups($('project-scientific-groups').value),rights:$('project-scientific-rights').value}}); + });}; + for(const [form,fields] of [['project-study-create-form',['project-study-create-name','project-study-metric','project-study-attempts','project-study-seconds','project-study-retained','project-study-staging','project-study-checkpoint','project-study-replicates','project-study-numerical']],['project-register-form',['project-register-path']],['project-scientific-form',['project-scientific-reference','project-scientific-name','project-scientific-groups','project-scientific-rights']]])for(const field of fields)$(field).addEventListener('input',()=>{m2DirtyForms.add(form);m2Dirty=true;controls();}); + function executionControls(){ + const prepared=!!executionPrepared&&executionPrepared.binding===state?.binding,approved=!!executionApproval&&executionApproval.binding===state?.binding; + producerControls();nativeControls(); + $('project-execution-reconcile').disabled=busy||blocked||!state?.project||!['open','recovery_required'].includes(state.state)||!selectedContracts.has('project_files.execution_reconcile_current'); + $('project-execution-graphs-more').disabled=!m2Allowed('graphs');$('project-execution-graphs-more').hidden=executionGraphOffset>=executionGraphTotal; + $('project-study-trials-more').disabled=!executionStudy||!m2Allowed('study');$('project-study-trials-more').hidden=studyTrialOffset>=studyTrialTotal; + $('project-execution-health').disabled=!preparationRecoveryAllowed('execution_health'); + $('project-execution-health-more').disabled=!preparationRecoveryAllowed('execution_health');$('project-execution-health-more').hidden=executionOffset>=executionTotal; + $('project-execution-graphs').disabled=!m2Allowed('graphs'); + $('project-execution-preflight-fields').disabled=!executionGraphs.length||!m2Allowed('execution_preflight_external'); + $('project-execution-approval-fields').disabled=!prepared||!m2Allowed('execution_approve_external'); + $('project-execution-submit-fields').disabled=!prepared||!approved||!m2Allowed('execution_submit_external'); + $('project-execution-cancel-fields').disabled=!m2Allowed('execution_cancel'); + $('project-study-execution-load').disabled=explored?.category!=='studies'||!m2Allowed('study'); + $('project-study-continue-fields').disabled=!executionStudy||!m2Allowed('execution_study_continue'); + $('project-study-approval-fields').disabled=!executionStudy||!executionTrials.length||!m2Allowed('execution_study_approve'); + $('project-study-admit-fields').disabled=!executionStudy||!studyApproval||!m2Allowed('execution_study_admit'); + $('project-study-stop-fields').disabled=!executionStudy||!m2Allowed('execution_study_stop'); + } + function executionFact(value){$('project-execution-facts').replaceChildren(projectValue(value));} + function nativePhaseOK(value,phaseId=null){ + return exact(value,['format','origin_catalog_id','phase_id','phase','run_id','state','spec_sha256','receipt_sha256'])&&value.format==='project_native_phase_v1'&&value.origin_catalog_id===state.project.project_id&&id(value.phase_id)&&(!phaseId||value.phase_id===phaseId)&&['probe','train','verify'].includes(value.phase)&&(value.run_id===null||id(value.run_id))&&['prepared','ready','go_intent','completed','failed','unknown'].includes(value.state)&&sha(value.spec_sha256)&&(value.receipt_sha256===null||sha(value.receipt_sha256)); + } + function nativeValidationOK(value,revision){ + const fields=['status','valid','errors','validation_token','graph_revision','execution_sha256','release','runtime']; + if(!value||typeof value!=='object'||Array.isArray(value)||!fields.every(field=>Object.hasOwn(value,field))||!Object.keys(value).every(field=>fields.includes(field)||field==='selection_policy')||!str(value.status,120)||typeof value.valid!=='boolean'||!Array.isArray(value.errors)||value.errors.length>100||!positive(value.graph_revision)||value.graph_revision!==revision||!sha(value.execution_sha256)||!['release','runtime'].every(field=>value[field]===null||typeof value[field]==='object'&&!Array.isArray(value[field])))return false; + if(!value.errors.every(error=>exact(error,['path','message','code'])&&Object.values(error).every(part=>str(part,2000,true))))return false; + return value.valid?value.status==='valid'&&value.errors.length===0&&sha(value.validation_token)&&value.release!==null&&value.runtime!==null&&value.runtime.available===true:value.errors.length>0&&value.status===value.errors[0].code&&value.validation_token===null; + } + function nativeAdmissionOK(value){return exact(value,['format','origin_catalog_id','run_id','request_sha256'])&&value.format==='project_run_admission_v1'&&value.origin_catalog_id===state.project.project_id&&id(value.run_id)&&sha(value.request_sha256);} + function nativeControls(){ + $('project-native-graphs').disabled=!m2Allowed('graphs');$('project-native-more').disabled=!m2Allowed('graphs');$('project-native-more').hidden=nativeOffset>=nativeTotal; + $('project-native-preflight-fields').disabled=!nativeGraphs.length||!m2Allowed('native_preflight'); + $('project-native-submit-fields').disabled=!nativePrepared||nativePrepared.binding!==state?.binding||nativePrepared.checkpoint!==checkpoint()||!m2Allowed('native_submit'); + $('project-native-retry-fields').disabled=!m2Allowed('native_retry'); + $('project-native-reconcile-fields').disabled=!preparationRecoveryAllowed('native_reconcile'); + $('project-native-pending-retry').disabled=!nativePending||nativePending.body.binding!==state?.binding||!preparationRecoveryAllowed(nativePending.action); + } + async function loadNativeGraphs(ticket,more=false){ + const value=await m2Request('graphs',{offset:more?nativeOffset:0,limit:40},ticket);if(!value)return; + if(!more){nativeGraphs=[];nativePrepared=null;$('project-native-graph').replaceChildren();} + for(const graph of value.items.filter(graph=>graph.recipe!=='external_recipe')){if(!id(graph.id)||!positive(graph.revision))throw Error('Saved native graph has invalid identity.');nativeGraphs.push(graph);const option=document.createElement('option');option.value=graph.id;option.textContent=`${graph.name??graph.id} · ${graph.recipe??'native'} · revision ${graph.revision}`;$('project-native-graph').append(option);} + nativeOffset=value.offset+value.items.length;nativeTotal=value.total;controls();message('Saved graph metadata loaded. No native probe or Run started.'); + } + $('project-native-graphs').onclick=()=>run(ticket=>loadNativeGraphs(ticket));$('project-native-more').onclick=()=>run(ticket=>loadNativeGraphs(ticket,true)); + $('project-native-graph').addEventListener('change',()=>{nativePrepared=null;$('project-native-probe-confirm').checked=false;$('project-native-submit-confirm').checked=false;controls();}); + $('project-native-preflight-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + const graph=nativeGraphs.find(value=>value.id===$('project-native-graph').value);if(!graph||!$('project-native-probe-confirm').checked)throw Error('Select a saved native graph and explicitly authorize its current CPU probe.'); + nativePrepared=null;controls(); + const value=await m2Request('native_preflight',{graph_id:graph.id,revision:graph.revision},ticket);if(!value)return; + if(!nativeValidationOK(value,graph.revision))throw Error('Native validation differs from the selected saved graph.'); + nativePending=null;nativePrepared=value.valid?{binding:state.binding,checkpoint:checkpoint(),graph_id:graph.id,revision:graph.revision,validation_token:value.validation_token}:null; + executionFormSaved('project-native-preflight-form');$('project-native-probe-confirm').checked=false;$('project-native-submit-confirm').checked=false;$('project-native-facts').replaceChildren(projectValue(value));$('project-native-pending').textContent='Native validation acknowledgement saved.';controls();message(value.valid?'Native validation succeeded. Starting a Run requires a separate explicit action.':'Native validation reported errors. No Run was started.'); + });}; + $('project-native-submit-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + if(!nativePrepared||nativePrepared.binding!==state.binding||nativePrepared.checkpoint!==checkpoint()||!$('project-native-submit-confirm').checked)throw Error('Validate this current graph and separately authorize starting its Run.'); + const value=await m2Request('native_submit',{graph_id:nativePrepared.graph_id,graph_revision:nativePrepared.revision,validation_token:nativePrepared.validation_token},ticket);if(!value)return;if(!nativeAdmissionOK(value))throw Error('Native admission differs from the selected project.'); + nativePending=null;nativePrepared=null;$('project-native-run-id').value=value.run_id;$('project-execution-run-id').value=value.run_id;$('project-native-submit-confirm').checked=false;executionFormSaved('project-native-submit-form');$('project-native-facts').replaceChildren(projectValue(value));$('project-native-pending').textContent='Native admission acknowledged. Refresh current custody to inspect execution.';controls();message($('project-native-pending').textContent); + });}; + $('project-native-retry-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + const run_id=$('project-native-run-id').value;if(!id(run_id)||!$('project-native-retry-confirm').checked)throw Error('Enter a current native Run ID and explicitly authorize a new attempt.'); + const value=await m2Request('native_retry',{run_id},ticket);if(!value)return;if(!nativeAdmissionOK(value)||value.run_id===run_id)throw Error('Native retry admission differs from the selected project.'); + nativePending=null;$('project-native-run-id').value=value.run_id;$('project-execution-run-id').value=value.run_id;$('project-native-retry-confirm').checked=false;executionFormSaved('project-native-retry-form');$('project-native-facts').replaceChildren(projectValue(value));$('project-native-pending').textContent='Native retry admission acknowledged. A saved admission is not proof of completion.';controls();message($('project-native-pending').textContent); + });}; + $('project-native-pending-retry').onclick=()=>run(async ticket=>{ + if(!nativePending||nativePending.body.binding!==state.binding)throw Error('This tab has no exact current pending native request.'); + const saved=nativePending,{binding,expected_checkpoint,request_key,...args}=saved.body;if(saved.action==='native_preflight'){nativePrepared=null;controls();}const value=await m2Request(saved.action,args,ticket);if(!value)return; + if(saved.action==='native_preflight'){ + if(!nativeValidationOK(value,args.revision))throw Error('Native validation differs from the original saved graph.'); + nativePrepared=value.valid?{binding:state.binding,checkpoint:checkpoint(),graph_id:args.graph_id,revision:args.revision,validation_token:value.validation_token}:null; + executionFormSaved('project-native-preflight-form');$('project-native-probe-confirm').checked=false; + }else{ + if(!nativeAdmissionOK(value)||saved.action==='native_retry'&&value.run_id===args.run_id)throw Error('Native acknowledgement differs from the selected project.');nativePrepared=null;$('project-native-run-id').value=value.run_id;$('project-execution-run-id').value=value.run_id; + const kind=saved.action==='native_submit'?'submit':'retry';executionFormSaved('project-native-'+kind+'-form');$('project-native-'+kind+'-confirm').checked=false; + } + nativePending=null;$('project-native-facts').replaceChildren(projectValue(value));$('project-native-pending').textContent='Exact pending native request acknowledged. No new request key was created.';controls();message($('project-native-pending').textContent); + }); + $('project-native-reconcile-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + const phase_id=$('project-native-phase-id').value;if(!id(phase_id))throw Error('Enter the original current local phase ID.'); + const value=await m2Request('native_reconcile',{phase_id},ticket);if(!value)return;if(!nativePhaseOK(value,phase_id))throw Error('Native reconciliation differs from the original phase.'); + executionFormSaved('project-native-reconcile-form');$('project-native-facts').replaceChildren(projectValue(value));message('Original current native phase observed. Reconciliation does not start or resume it. Refresh project status before another action.'); + });}; + for(const [form,fields] of [['project-native-preflight-form',['project-native-probe-confirm']],['project-native-submit-form',['project-native-submit-confirm']],['project-native-retry-form',['project-native-run-id','project-native-retry-confirm']],['project-native-reconcile-form',['project-native-phase-id']]])for(const field of fields)$(field).addEventListener(field.endsWith('confirm')?'change':'input',()=>{m2DirtyForms.add(form);m2Dirty=true;controls();}); + function localConsumer(){const actor=window.TuldokAuth.identity?.()?.actor_id;return id(actor)?actor:'';} + async function currentHealth(ticket,more=false){ + const value=await m2Request('execution_health',{offset:more?executionOffset:0,limit:40},ticket);if(!value)return; + if(value.format!=='project_execution_health_v1'||!Array.isArray(value.active_run_ids)||typeof value.drain_required!=='boolean'||value.offset!==(more?executionOffset:0)||value.limit!==40||value.active_run_ids.length!==Math.min(40,Math.max(0,value.active_count-value.offset)))throw Error('Unsupported current custody observation.'); + let nativeTotal=0; + if(selectedVersions.get('project_files.execution_health')>=2){ + const page=value.native_phases,ids=page?.items?.map(item=>item.phase_id); + if(!page||!exact(page,['items','total','offset','limit'])||!Array.isArray(page.items)||!Number.isSafeInteger(page.total)||page.total<0||page.offset!==(more?executionOffset:0)||page.limit!==40||page.items.length!==Math.min(40,Math.max(0,page.total-page.offset))||!page.items.every(item=>nativePhaseOK(item))||ids.join()!==[...new Set(ids)].sort().join())throw Error('Current native phases differ from the selected project or continuation.'); + nativeTotal=page.total;if(!more)$('project-native-phases').replaceChildren(); + for(const phase of page.items){const li=document.createElement('li'),button=document.createElement('button');button.type='button';button.textContent=`${phase.phase_id} · ${phase.phase} · ${phase.state}`;button.onclick=()=>{$('project-native-phase-id').value=phase.phase_id;$('project-native-phase-id').focus();};li.append(button);$('project-native-phases').append(li);} + }else if(Object.hasOwn(value,'native_phases'))throw Error('Version 1 cannot represent native phase custody. Refresh contracts.'); + executionOffset=(more?executionOffset:0)+40;executionTotal=Math.max(value.active_count,nativeTotal);executionFact(value); + $('project-execution-summary').textContent=`${value.active_count} current active or unresolved Runs; ${nativeTotal} original current native phases. Local owner ${value.local_owner_active?'active':'inactive'}. ${value.drain_required?'Explicit reconciliation must drain current custody before closing or extracting.':value.available?'The project projection is available.':'The current outcome needs explicit reconciliation.'}`; + if(value.active_run_ids.length)$('project-execution-run-id').value=value.active_run_ids[0];controls(); + } + $('project-execution-health').onclick=()=>run(ticket=>currentHealth(ticket)); + $('project-execution-health-more').onclick=()=>run(ticket=>currentHealth(ticket,true)); + $('project-execution-reconcile').onclick=()=>run(async ticket=>{const value=await m2Request('execution_reconcile_current',{},ticket);if(!value)return;executionApproval=null;studyApproval=null;executionFact(value);controls();message(value.available?'Current local custody reconciled and drained. No imported admission was started.':'Current reconciliation remains unavailable; custody is still fenced.');}); + async function loadExecutionGraphs(ticket,more=false){ + const value=await m2Request('graphs',{offset:more?executionGraphOffset:0,limit:40},ticket);if(!value)return; + if(!more){executionGraphs=[];$('project-execution-graph').replaceChildren();}const graphs=value.items.filter(graph=>graph.recipe==='external_recipe');executionGraphs.push(...graphs);executionGraphOffset=value.offset+value.items.length;executionGraphTotal=value.total;executionPrepared=null;executionApproval=null; + for(const graph of graphs){const option=document.createElement('option');option.value=graph.id;option.textContent=`${graph.name} · revision ${graph.revision}`;$('project-execution-graph').append(option);} + $('project-execution-consumer').value=localConsumer();$('project-study-consumer').value=localConsumer();controls();message(executionGraphs.length?'Select an external graph and inspect its exact current material.':'No external graphs on this page. Create one through the named external graph operation.'); + } + $('project-execution-graphs').onclick=()=>run(ticket=>loadExecutionGraphs(ticket),{clean:true}); + $('project-execution-graphs-more').onclick=()=>run(ticket=>loadExecutionGraphs(ticket,true),{clean:true}); + $('project-execution-graph').addEventListener('change',()=>{executionPrepared=null;executionApproval=null;$('project-execution-grant').textContent='No current local approval selected.';controls();}); + $('project-execution-preflight-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + const graph=executionGraphs.find(value=>value.id===$('project-execution-graph').value);if(!graph)throw Error('Select a saved external graph.'); + const value=await m2Request('execution_preflight_external',{graph_id:graph.id,revision:graph.revision},ticket);if(!value)return; + if(!sha(value.material_sha256)||value.graph_revision!==graph.revision)throw Error('Current material does not match the selected graph revision.'); + executionPrepared={binding:state.binding,graph_id:graph.id,graph_revision:graph.revision,expected_digest:value.material_sha256};executionApproval=null;$('project-execution-confirm').checked=false;executionFact(value);controls(); + message('Exact current material inspected. Review its sources, inputs and runtime before approving. No Run has been admitted.'); + });}; + $('project-execution-approval-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + if(!executionPrepared||executionPrepared.binding!==state.binding)throw Error('Inspect the exact current material first.'); + if(!$('project-execution-confirm').checked)throw Error('Explicitly approve the reviewed material before granting permission.'); + const consumer=$('project-execution-consumer').value,ttl=Number($('project-execution-ttl').value);if(!id(consumer)||!Number.isInteger(ttl)||ttl<60||ttl>3600)throw Error('Choose the current execution actor and an approval lifetime of 60–3600 seconds.'); + const {binding,...prepared}=executionPrepared,value=await m2Request('execution_approve_external',{...prepared,consumer_actor_id:consumer,ttl_seconds:ttl},ticket);if(!value)return; + if(value.format!=='project_current_execution_approval_v1'||value.current_local!==true||value.grant?.material_sha256!==prepared.expected_digest||value.grant.consumer_actor_id!==consumer||!id(value.grant.id))throw Error('Current local approval does not match the reviewed request.'); + executionFormSaved('project-execution-approval-form');executionApproval={binding:state.binding,grant:value.grant};$('project-execution-grant').textContent=`Current local approval ${value.grant.id} for actor ${consumer}, expires ${new Date(value.grant.expires_at*1000).toLocaleString()}.`; + controls();message('Current local approval granted. Starting the Run is a separate explicit action.'); + });}; + $('project-execution-submit-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + if(executionPrepared?.binding!==state.binding||executionApproval?.binding!==state.binding)throw Error('Review and approve the current material on this installation first.'); + const {binding,...prepared}=executionPrepared,value=await m2Request('execution_submit_external',{...prepared,approval_id:executionApproval.grant.id},ticket);if(!value)return; + $('project-execution-run-id').value=value.run_id;executionApproval=null;executionFact(value);controls();message(`Admission saved for Run ${value.run_id}. This acknowledges durable intent; refresh current custody to inspect execution.`); + });}; + $('project-execution-cancel-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + const run_id=$('project-execution-run-id').value;if(!id(run_id))throw Error('Choose a current local Run ID.'); + const value=await m2Request('execution_cancel',{run_id},ticket);if(!value)return;executionFact(value);message('Cancellation intent saved. Refresh current custody to establish whether execution has ceased.'); + });}; + $('project-study-execution-load').onclick=()=>run(async ticket=>{ + if(explored?.category!=='studies'||explored.origin!==state.project.project_id)throw Error('Select a current-namespace Study in the scientific history view.'); + const value=await m2Request('study',{study_id:explored.id,origin_catalog_id:explored.origin,offset:0,limit:40},ticket);if(!value)return; + executionStudy=value.study;studyApproval=null;executionTrials=value.trials.items;studyTrialOffset=value.trials.offset+value.trials.items.length;studyTrialTotal=value.trials.total;$('project-study-execution-trial').replaceChildren(); + for(const trial of executionTrials){const option=document.createElement('option');option.value=trial.id;option.textContent=trial.id;$('project-study-execution-trial').append(option);} + $('project-study-consumer').value=localConsumer();$('project-study-execution-summary').textContent=`${executionStudy.name} · revision ${executionStudy.revision} · epoch ${executionStudy.admission_epoch} · ${executionStudy.state}. ${value.trials.total} prepared Trials.`;controls(); + },{clean:true}); + $('project-study-trials-more').onclick=()=>run(async ticket=>{const value=await m2Request('study',{study_id:executionStudy.id,origin_catalog_id:state.project.project_id,revision:executionStudy.revision,offset:studyTrialOffset,limit:40},ticket);if(!value)return;for(const trial of value.trials.items){executionTrials.push(trial);const option=document.createElement('option');option.value=trial.id;option.textContent=trial.id;$('project-study-execution-trial').append(option);}studyTrialOffset=value.trials.offset+value.trials.items.length;studyTrialTotal=value.trials.total;controls();}); + $('project-study-execution-trial').addEventListener('change',()=>{studyApproval=null;controls();}); + $('project-study-continue-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + const value=await m2Request('execution_study_continue',{study_id:executionStudy.id,revision:executionStudy.revision,admission_epoch:executionStudy.admission_epoch},ticket);if(!value)return;executionStudy=value;controls();$('project-study-execution-summary').textContent=`Current epoch ${value.admission_epoch} · revision ${value.revision} · ${value.state}.`;message('Current Study epoch saved. No Trial has been admitted by this action.'); + });}; + $('project-study-approval-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + const trial_id=$('project-study-execution-trial').value,consumer_actor_id=$('project-study-consumer').value;if(!id(trial_id)||!id(consumer_actor_id))throw Error('Select a prepared Trial and current execution actor.'); + const trial=await m2Request('trial',{trial_id,origin_catalog_id:state.project.project_id},ticket);if(!trial)return; + const value=await m2Request('execution_study_approve',{study_id:executionStudy.id,trial_id,expected_material_sha256:trial.material.sha256,consumer_actor_id},ticket);if(!value)return;executionFormSaved('project-study-approval-form');studyApproval={binding:state.binding,trial_id,grant:value.grant};executionFact(value);controls();message('Exact Trial material approved locally. Admission is a separate explicit action.'); + });}; + $('project-study-admit-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + if(studyApproval?.binding!==state.binding||studyApproval.trial_id!==$('project-study-execution-trial').value)throw Error('Approve this exact Trial under current local authority first.'); + const value=await m2Request('execution_study_admit',{study_id:executionStudy.id,revision:executionStudy.revision,admission_epoch:executionStudy.admission_epoch,trial_id:studyApproval.trial_id,approval_id:studyApproval.grant.id},ticket);if(!value)return; + $('project-execution-run-id').value=value.run_id;studyApproval=null;executionStudy=null;executionFact(value);controls();message('Trial admission and budget reservation saved. Reload the Study before another epoch action; refresh current custody for execution status.'); + });}; + $('project-study-stop-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + const value=await m2Request('execution_study_stop',{study_id:executionStudy.id,revision:executionStudy.revision},ticket);if(!value)return;executionStudy=null;studyApproval=null;executionFact(value);controls();message('Finite Study epoch closure saved and cancellation requested. Current child custody must still prove cessation.'); + });}; + function producerControls(){ + const family=$('project-producer-task').value,current=!!producerCurrent&&producerCurrent.binding===state?.binding; + $('project-producer-health').disabled=!m2Allowed('execution_producer_health');$('project-producer-health-more').disabled=!m2Allowed('execution_producer_health');$('project-producer-health-more').hidden=producerHealthOffset>=producerHealthTotal; + $('project-producer-start-fields').disabled=!m2Allowed('execution_producer_start'); + $('project-producer-source-load').disabled=explored?.category!=='data_query'||!m2Allowed('data_get'); + $('project-producer-current-load').disabled=explored?.category!=='producers'||explored.origin!==state?.project?.project_id||!m2Allowed('producer'); + $('project-producer-label-fields').hidden=family!=='text_proposal_history'; + $('project-producer-count-fields').hidden=!['generation_history','grounded_history'].includes(family); + $('project-producer-generation-fields').hidden=family!=='generation_history'; + $('project-producer-resume-fields').disabled=!current||producerCurrent.family!=='generation_history'||!m2Allowed('execution_generation_resume'); + $('project-producer-review-fields').disabled=!current||producerCurrent.family!=='grounded_history'||!m2Allowed('execution_grounded_review'); + $('project-producer-decision-fields').disabled=!current||!['caption_proposal_history','text_proposal_history'].includes(producerCurrent.family)||!m2Allowed('execution_producer_decide'); + $('project-producer-cancel-fields').disabled=!current||!m2Allowed('execution_producer_cancel'); + } + async function producerHealth(ticket,more=false){const value=await m2Request('execution_producer_health',{offset:more?producerHealthOffset:0,limit:40},ticket);if(!value)return;producerHealthOffset=value.offset+value.items.length;producerHealthTotal=value.total;executionFact(value);$('project-producer-health-summary').textContent=`${value.total} current local producer admissions. ${value.fenced?'Current custody is fenced.':'Inspect each current worker and provider outcome before closing.'}`;controls();} + $('project-producer-health').onclick=()=>run(ticket=>producerHealth(ticket)); + $('project-producer-health-more').onclick=()=>run(ticket=>producerHealth(ticket,true)); + function executionFormSaved(form){m2DirtyForms.delete(form);m2Dirty=m2DirtyForms.size>0;} + $('project-producer-task').addEventListener('change',()=>{producerSource=null;$('project-producer-source-summary').textContent='Select matching saved data as source.';controls();}); + $('project-producer-source-load').onclick=()=>run(async ticket=>{ + if(explored?.category!=='data_query')throw Error('Select saved data in the scientific history view.'); + const value=await m2Request('data_get',{record_id:explored.id},ticket);if(!value)return; + const family=$('project-producer-task').value,kind=family==='caption_proposal_history'?'image':'text'; + if(family==='generation_history'||value.kind!==kind)throw Error(`This task requires selected ${kind} data.`); + producerSource={binding:state.binding,source_id:value.id,revision:value.revision,source_revision:value.source_revision};$('project-producer-source-summary').textContent=`${value.name} · revision ${value.revision} · source revision ${value.source_revision}`;controls(); + }); + $('project-producer-start-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + const family=$('project-producer-task').value;if(!$('project-producer-confirm').checked)throw Error('Explicitly authorize this model request.'); + const instruction=$('project-producer-instruction').value,seed=Number($('project-producer-seed').value); + if(!Number.isInteger(seed)||seed<0||seed>4294967295)throw Error('Use a nonnegative 32-bit seed.'); + const document={server_url:$('project-producer-url').value,model:$('project-producer-model').value,seed}; + if($('project-producer-transport').value==='typed'){document.protocol='pumas_typed_v1';document.profile=$('project-producer-profile').value||null;if(family!=='generation_history')document.seed=null;} + if(family==='generation_history'){ + Object.assign(document,{prompt:instruction,count:Number($('project-producer-count').value),strategy:$('project-producer-strategy').value,session_id:$('project-producer-session').value,width:Number($('project-producer-width').value),height:Number($('project-producer-height').value)}); + if(document.strategy==='varied'){document.prompt_url=$('project-producer-prompt-url').value;document.prompt_model=$('project-producer-prompt-model').value;} + }else{ + if(producerSource?.binding!==state.binding)throw Error('Load exact saved source revisions for this project first.'); + const {binding,...source}=producerSource;Object.assign(document,source,{instruction}); + if(family==='grounded_history')document.count=Number($('project-producer-count').value);else document.request_id=key(); + if(family==='text_proposal_history')document.labels=$('project-producer-labels').value.split('\n').filter(value=>value!==''); + } + const value=await m2Request('execution_producer_start',{family,document},ticket);if(!value)return; + executionFormSaved('project-producer-start-form');$('project-producer-confirm').checked=false;producerCurrent=null;executionFact(value);controls();message(`Producer admission saved: ${value.producer_id}. Read its saved history for captured results and current observations.`); + });}; + $('project-producer-current-load').onclick=()=>run(async ticket=>{ + if(explored?.category!=='producers'||explored.origin!==state.project.project_id)throw Error('Select current-namespace producer history first.'); + const family=explored.value.family,value=await m2Request('producer',{family,producer_id:explored.id,origin_catalog_id:explored.origin,entries_offset:0,entries_limit:40},ticket);if(!value)return; + producerCurrent={binding:state.binding,family,producer_id:explored.id,job:value.job};$('project-producer-current-summary').textContent=`${family.replaceAll('_',' ')} · ${explored.id} · ${value.status}. Retained history alone does not grant current authority.`; + $('project-producer-candidate').replaceChildren();for(const candidate of value.job.candidates??[]){const option=document.createElement('option');option.value=candidate.id;option.textContent=`${candidate.text} · ${candidate.status}`;$('project-producer-candidate').append(option);}controls(); + },{clean:true}); + async function producerAction(ticket,action,document){ + if(producerCurrent?.binding!==state.binding)throw Error('Load a current-namespace producer first.'); + const {family,producer_id}=producerCurrent,args={producer_id};if(!['execution_generation_resume','execution_grounded_review'].includes(action))args.family=family;if(document)args.document=document; + const value=await m2Request(action,args,ticket);if(!value)return; + for(const form of ['project-producer-review-form','project-producer-decision-form'])executionFormSaved(form); + producerCurrent=null;executionFact(value);controls();message(action==='execution_producer_cancel'?'Cancellation intent saved. Refresh current producer custody; a retained acknowledgement does not prove cessation.':'Scientific producer action saved. Reload its history before another action.'); + } + $('project-producer-resume-form').onsubmit=event=>{event.preventDefault();run(ticket=>producerAction(ticket,'execution_generation_resume'));}; + $('project-producer-cancel-form').onsubmit=event=>{event.preventDefault();run(ticket=>producerAction(ticket,'execution_producer_cancel'));}; + $('project-producer-decision-form').onsubmit=event=>{event.preventDefault();run(ticket=>{ + const document={revision:producerCurrent.job.revision,decision:$('project-producer-decision').value};if(producerCurrent.family==='text_proposal_history'&&document.decision==='apply_draft')document.labels=producerCurrent.job.config.labels;return producerAction(ticket,'execution_producer_decide',document); + });}; + $('project-producer-review-form').onsubmit=event=>{event.preventDefault();run(ticket=>producerAction(ticket,'execution_grounded_review',{revision:producerCurrent.job.revision,candidate_id:$('project-producer-candidate').value,decision:$('project-producer-review-decision').value,note:$('project-producer-note').value}));}; + + const preparationRecoveryAllowed=action=>!busy&&!!state?.project&&['open','recovery_required'].includes(state.state)&&selectedContracts.has('project_files.'+action); + function candidateControls(){ + const kind=$('project-candidate-kind').value||'package';$('project-candidate-verifier-fields').hidden=kind!=='verifier'; + $('project-candidate-prepare-fields').disabled=!m2Allowed('execution_study_prepare_'+kind); + const reconcile=preparationRecoveryAllowed('execution_study_reconcile_preparation'),abandon=preparationRecoveryAllowed('execution_study_abandon_preparation'); + $('project-candidate-recovery-fields').disabled=!(reconcile||abandon);$('project-candidate-reconcile').disabled=!reconcile;$('project-candidate-abandon').disabled=!abandon; + } + function candidatePreparedOK(value,study,candidate=null,runtime=null){ + const keys=['state','candidate_id','trial','graph','model','runtime_snapshot_sha256',...(Object.hasOwn(value??{},'package_run_id')?['package_run_id','package_sha256']:[])]; + return exact(value,keys)&&value.state==='prepared'&&id(value.candidate_id)&&(!candidate||value.candidate_id===candidate)&&sha(value.runtime_snapshot_sha256)&&(!runtime||value.runtime_snapshot_sha256===runtime)&&value.trial?.study_id===study&&id(value.trial?.id)&&value.trial.material?.catalog_id===state.project.project_id&&value.model?.catalog_id===state.project.project_id&&id(value.graph?.id)&&positive(value.graph?.revision)&&(!Object.hasOwn(value,'package_run_id')||id(value.package_run_id)&&sha(value.package_sha256)); + } + $('project-candidate-prepare-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + const study_id=$('project-candidate-study').value,candidate_id=$('project-candidate-id').value,revision=Number($('project-candidate-revision').value),runtime_snapshot_sha256=$('project-candidate-runtime').value,root=$('project-candidate-root').value,directory=$('project-candidate-directory').value,kind=$('project-candidate-kind').value; + if(!id(study_id)||!id(candidate_id)||!positive(revision)||!sha(runtime_snapshot_sha256)||!root||!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,119}$/.test(directory)||!['package','verifier'].includes(kind))throw Error('Choose exact current Study/Candidate identities, revision, publication directory and captured runtime digest.'); + const args={study_id,candidate_id,revision,root,directory,runtime_snapshot_sha256}; + if(kind==='verifier'){args.package_run_id=$('project-candidate-package-run').value;args.package_sha256=$('project-candidate-package-sha').value;if(!id(args.package_run_id)||!sha(args.package_sha256))throw Error('Choose the exact completed package Run and SHA256.');} + const value=await m2Request('execution_study_prepare_'+kind,args,ticket);if(!value)return; + if(!candidatePreparedOK(value,study_id,candidate_id,runtime_snapshot_sha256)||kind==='verifier'&&(value.package_run_id!==args.package_run_id||value.package_sha256!==args.package_sha256))throw Error('Preparation result differs from its current Study, Candidate or captured runtime.'); + executionFormSaved('project-candidate-prepare-form');$('project-candidate-preparation-result').textContent=JSON.stringify(value,null,2);$('project-candidate-preparation-status').textContent='Preparation acknowledged. This saved Trial still requires current local approval and a separate explicit admission.';message('Candidate preparation saved. No Run was launched.'); + });}; + async function candidateRecover(ticket,abandon){ + const study_id=$('project-candidate-recovery-study').value,preparation_key=$('project-candidate-recovery-key').value; + if(!id(study_id)||!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/.test(preparation_key))throw Error('Choose the exact current Study and original preparation request key.'); + const action='execution_study_'+(abandon?'abandon':'reconcile')+'_preparation';if(!state?.project||!['open','recovery_required'].includes(state.state)||!selectedContracts.has('project_files.'+action))throw Error('Current preparation recovery is unavailable.'); + const args={study_id,preparation_key};if(abandon){args.reason=$('project-candidate-abandon-reason').value;if(!str(args.reason,1000))throw Error('Enter an explicit reason before abandoning the stopped partial preparation.');} + const value=await m2Request(action,args,ticket);if(!value)return; + if(abandon){if(value.format!=='project_candidate_preparation_abandon_v1'||value.state!=='abandoned'||value.origin_catalog_id!==state.project.project_id||value.study_id!==study_id||value.preparation_key!==preparation_key||value.reason!==args.reason||!sha(value.input_sha256)||!sha(value.observed_checkpoint)||value.candidate?.catalog_id!==state.project.project_id)throw Error('Abandonment differs from the exact current preparation intent.');executionFormSaved('project-candidate-recovery-form');} + else if(!candidatePreparedOK(value,study_id))throw Error('Reconciled preparation belongs to another Study.'); + executionFormSaved('project-candidate-prepare-form');$('project-candidate-preparation-result').textContent=JSON.stringify(value,null,2);$('project-candidate-preparation-status').textContent=abandon?'Stopped partial preparation abandoned; original staging and scientific facts remain. Refresh project status.':'Final preparation acknowledged; no writer was replayed and no Run was launched. Refresh project status.';message($('project-candidate-preparation-status').textContent); + } + $('project-candidate-reconcile').onclick=()=>run(ticket=>candidateRecover(ticket,false));$('project-candidate-abandon').onclick=()=>run(ticket=>candidateRecover(ticket,true)); + for(const [form,fields] of [['project-candidate-prepare-form',['project-candidate-study','project-candidate-revision','project-candidate-id','project-candidate-kind','project-candidate-root','project-candidate-directory','project-candidate-runtime','project-candidate-package-run','project-candidate-package-sha']],['project-candidate-recovery-form',['project-candidate-recovery-study','project-candidate-recovery-key','project-candidate-abandon-reason']]])for(const field of fields)$(field).addEventListener(field==='project-candidate-kind'?'change':'input',()=>{m2DirtyForms.add(form);m2Dirty=true;controls();}); + + function runtimeSummaryOK(value,complete=false){ + const keys=['format','project_id','manifest_sha256','file_count','total_bytes','actor_id','observed_at','request_key',...(complete?['runtime_ref']:[])]; + return exact(value,keys)&&value.format===(complete?'project_runtime_capture_v1':'project_runtime_capture_intent_v1')&&id(value.project_id)&&sha(value.manifest_sha256)&&positive(value.file_count)&&value.file_count<=40000&&Number.isSafeInteger(value.total_bytes)&&value.total_bytes>=0&&value.total_bytes<=2147483648&&id(value.actor_id)&&str(value.observed_at,80)&&typeof value.request_key==='string'&&/^[A-Za-z0-9][A-Za-z0-9_.:-]{7,127}$/.test(value.request_key)&&(!complete||exact(value.runtime_ref,['kind','catalog_id','id','sha256'])&&value.runtime_ref.kind==='runtime'&&value.runtime_ref.catalog_id===value.project_id&&sha(value.runtime_ref.id)&&value.runtime_ref.id===value.runtime_ref.sha256); + } + function runtimeControls(){ + $('project-runtime-sources').disabled=!m2Allowed('runtime_sources');$('project-runtime-sources-more').disabled=!m2Allowed('runtime_sources'); + $('project-runtime-inspect').disabled=!$('project-runtime-source').value||!m2Allowed('runtime_inspect'); + $('project-runtime-source').disabled=!m2Allowed('runtime_inspect'); + $('project-runtime-files-more').disabled=!runtimeInspection||runtimeInspection.binding!==state?.binding||!m2Allowed('runtime_inspect'); + $('project-runtime-capture-fields').disabled=!runtimeInspection||runtimeInspection.binding!==state?.binding||!m2Allowed('runtime_capture'); + $('project-runtime-capture').disabled=$('project-runtime-capture-fields').disabled||!$('project-runtime-confirm').checked; + $('project-runtime-status-fields').disabled=!preparationRecoveryAllowed('runtime_status'); + $('project-runtime-abandon-fields').disabled=!preparationRecoveryAllowed('runtime_abandon'); + $('project-runtime-retry').disabled=!runtimePending||runtimePending.body.binding!==state?.binding||!preparationRecoveryAllowed('runtime_capture'); + } + async function runtimeSources(ticket,more=false){ + const offset=more?runtimeSourceOffset:0,value=await m2Request('runtime_sources',{offset,limit:40},ticket);if(!value)return; + if(!value.items.every(item=>exact(item,['source'])&&typeof item.source==='string'&&/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(item.source)))throw Error('Unsupported configured runtime source page.'); + if(!more){$('project-runtime-source').replaceChildren();runtimeInspection=null;$('project-runtime-confirm').checked=false;} + for(const item of value.items){const option=document.createElement('option');option.value=item.source;option.textContent=item.source;$('project-runtime-source').append(option);} + runtimeSourceOffset=offset+value.items.length;runtimeSourceTotal=value.total;$('project-runtime-sources-more').hidden=runtimeSourceOffset>=runtimeSourceTotal;controls(); + message(value.total?'Choose a current source and explicitly inspect it. No runtime has been captured.':'No current runtime sources are configured on this installation.'); + } + async function runtimeInspect(ticket,more=false){ + const source=$('project-runtime-source').value;if(!source)throw Error('Choose a configured source alias.'); + const offset=more?runtimeFileOffset:0,value=await m2Request('runtime_inspect',{source,offset,limit:40},ticket);if(!value)return; + const page=value.files; + if(!exact(value,['format','source','inventory_sha256','profile','python_version','file_count','total_bytes','manifest_bytes','free_bytes','packages','launch','files'])||value.format!=='project_runtime_inspection_v1'||value.source!==source||!sha(value.inventory_sha256)||value.profile!=='captured_cpu_python_v1'||value.python_version!=='3.12'||!positive(value.file_count)||value.file_count>40000||!positive(value.total_bytes)||value.total_bytes>2147483648||!positive(value.manifest_bytes)||value.manifest_bytes>16777216||!Number.isSafeInteger(value.free_bytes)||value.free_bytes<0||!Array.isArray(value.packages)||value.packages.length<2||value.packages.length>256||!value.packages.every(item=>exact(item,['name','version'])&&str(item.name,128)&&str(item.version,128))||!exact(page,['items','total','offset','limit'])||page.total!==value.file_count||page.offset!==offset||page.limit!==40||!Array.isArray(page.items)||page.items.length!==Math.min(40,Math.max(0,page.total-offset)))throw Error('Runtime inventory differs from the reviewed source or continuation.'); + if(!page.items.every(item=>exact(item,['path','bytes','sha256','mode','kind'])&&str(item.path,1024)&&Number.isSafeInteger(item.bytes)&&item.bytes>=0&&item.bytes<=536870912&&sha(item.sha256)&&[256,320].includes(item.mode)&&['data','elf'].includes(item.kind)))throw Error('Unsupported runtime file inventory.'); + if(more&&(!runtimeInspection||runtimeInspection.binding!==state.binding||runtimeInspection.source!==source||runtimeInspection.inventory_sha256!==value.inventory_sha256))throw Error('Current runtime inventory changed between pages. Inspect it again before capture.'); + if(!more){runtimeInspection={binding:state.binding,source,inventory_sha256:value.inventory_sha256};$('project-runtime-files').replaceChildren();$('project-runtime-confirm').checked=false;} + $('project-runtime-summary').textContent=`Current configured source ${source} · ${value.inventory_sha256} · ${value.file_count} files · ${value.total_bytes} bytes · ${value.free_bytes} bytes currently free. This observation does not capture a runtime.`; + $('project-runtime-layout').textContent=JSON.stringify({python_version:value.python_version,packages:value.packages,launch:value.launch},null,2); + for(const item of page.items){const li=document.createElement('li');li.textContent=`${item.path} · ${item.kind} · ${item.bytes} bytes · ${item.sha256}`;$('project-runtime-files').append(li);} + runtimeFileOffset=offset+page.items.length;runtimeFileTotal=page.total;$('project-runtime-files-more').hidden=runtimeFileOffset>=runtimeFileTotal;controls(); + } + $('project-runtime-sources').onclick=()=>run(ticket=>runtimeSources(ticket));$('project-runtime-sources-more').onclick=()=>run(ticket=>runtimeSources(ticket,true)); + $('project-runtime-inspect').onclick=()=>run(ticket=>runtimeInspect(ticket));$('project-runtime-files-more').onclick=()=>run(ticket=>runtimeInspect(ticket,true)); + $('project-runtime-source').addEventListener('change',()=>{runtimeInspection=null;$('project-runtime-confirm').checked=false;controls();}); + function runtimeAbandonmentOK(value,key,reason=null){ + return exact(value,['format','project_id','capture_key','input_sha256','manifest_sha256','actor_id','reason','observed_checkpoint','observed_at'])&&value.format==='project_runtime_capture_abandon_v1'&&value.project_id===state.project.project_id&&value.capture_key===key&&sha(value.input_sha256)&&sha(value.manifest_sha256)&&id(value.actor_id)&&str(value.reason,1000)&&(reason===null||value.reason===reason)&&sha(value.observed_checkpoint)&&str(value.observed_at,80); + } + async function captureRuntime(ticket,retry=false){ + let args; + if(retry){ + if(!runtimePending||runtimePending.body.binding!==state.binding)throw Error('This tab has no exact current pending capture request. Inspect retained status before choosing a new action.'); + const {binding,expected_checkpoint,request_key,...saved}=runtimePending.body;args=saved; + }else{ + if(runtimeInspection?.binding!==state.binding||!$('project-runtime-confirm').checked)throw Error('Inspect and explicitly confirm the current source inventory before capture.'); + const reserve_bytes=Number($('project-runtime-reserve').value);if(!Number.isSafeInteger(reserve_bytes)||reserve_bytes<0)throw Error('Choose a nonnegative safe integer disk reserve.'); + args={source:runtimeInspection.source,expected_inventory_sha256:runtimeInspection.inventory_sha256,reserve_bytes}; + } + const value=await m2Request('runtime_capture',args,ticket);if(!value)return; + if(!runtimeSummaryOK(value,true)||value.project_id!==state.project.project_id||value.manifest_sha256!==args.expected_inventory_sha256||value.request_key!==runtimePending?.body.request_key)throw Error('Runtime capture acknowledgement differs from the reviewed inventory or pending request.'); + $('project-runtime-status-key').value=value.request_key;runtimePending=null; + if(Number($('project-runtime-reserve').value)===args.reserve_bytes)executionFormSaved('project-runtime-capture-form');$('project-runtime-confirm').checked=false; + $('project-runtime-pending').textContent=`Capture request ${value.request_key} acknowledged. Retained capture facts do not grant permission to execute.`;$('project-runtime-status').textContent=JSON.stringify(value,null,2);controls();message('Reviewed runtime bytes captured locally and scientific metadata saved. No project execution started.'); + } + $('project-runtime-capture-form').onsubmit=event=>{event.preventDefault();run(ticket=>captureRuntime(ticket));}; + $('project-runtime-retry').onclick=()=>run(ticket=>captureRuntime(ticket,true)); + $('project-runtime-abandon-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + const capture_key=$('project-runtime-abandon-key').value,reason=$('project-runtime-abandon-reason').value; + if(!/^[A-Za-z0-9][A-Za-z0-9_.:-]{7,127}$/.test(capture_key)||!str(reason,1000))throw Error('Choose the exact current capture key and an explicit abandonment reason.'); + const value=await m2Request('runtime_abandon',{capture_key,reason},ticket);if(!value)return; + if(!runtimeAbandonmentOK(value,capture_key,reason))throw Error('Runtime abandonment differs from the current exact capture and reason.'); + if(runtimePending?.body.request_key===capture_key){if(Number($('project-runtime-reserve').value)===runtimePending.body.reserve_bytes)executionFormSaved('project-runtime-capture-form');runtimePending=null;} + executionFormSaved('project-runtime-abandon-form');$('project-runtime-status-key').value=capture_key;$('project-runtime-status').textContent=JSON.stringify(value,null,2);$('project-runtime-pending').textContent='Stopped partial capture abandoned. Original partial bytes and scientific intent remain. Refresh project status.';message($('project-runtime-pending').textContent);controls(); + });}; + $('project-runtime-status-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + const capture_key=$('project-runtime-status-key').value;if(!/^[A-Za-z0-9][A-Za-z0-9_.:-]{7,127}$/.test(capture_key))throw Error('Choose the exact retained capture request key.'); + const value=await m2Request('runtime_status',{capture_key},ticket);if(!value)return; + const abandoned=value?.state==='abandoned'; + if(!exact(value,abandoned?['state','intent','result','abandonment']:['state','intent','result'])||!['absent','intent_recorded','captured','abandoned'].includes(value.state)||value.state!==(abandoned?'abandoned':value.result?'captured':value.intent?'intent_recorded':'absent')||value.result&&!value.intent||value.intent!==null&&!runtimeSummaryOK(value.intent)||value.result!==null&&!runtimeSummaryOK(value.result,true)||[value.intent,value.result].some(item=>item!==null&&(item.project_id!==state.project.project_id||item.request_key!==capture_key))||value.result&&['project_id','manifest_sha256','file_count','total_bytes','request_key'].some(field=>value.intent[field]!==value.result[field])||abandoned&&(!value.intent||value.result!==null||!runtimeAbandonmentOK(value.abandonment,capture_key)||value.abandonment.manifest_sha256!==value.intent.manifest_sha256))throw Error('Retained runtime status differs from the selected capture request.'); + $('project-runtime-status').textContent=JSON.stringify(value,null,2);message('Retained capture outcome loaded. Current local readiness was not inspected.'); + });}; + for(const field of ['project-runtime-abandon-key','project-runtime-abandon-reason'])$(field).addEventListener('input',()=>{m2DirtyForms.add('project-runtime-abandon-form');m2Dirty=true;controls();}); + for(const field of ['project-runtime-reserve','project-runtime-confirm'])$(field).addEventListener(field.endsWith('confirm')?'change':'input',()=>{m2DirtyForms.add('project-runtime-capture-form');m2Dirty=true;controls();}); + + function m4Controls(){ + $('project-save-as-fields').disabled=!m2Allowed('save_as'); + $('project-working-load').disabled=!m2Allowed('files_inspect');$('project-working-more').disabled=!m2Allowed('files_inspect'); + $('project-working-adopt-fields').disabled=!m2Allowed('file_adopt'); + $('project-working-delete-fields').disabled=!m2Allowed('file_adopt_deletion')||!workingDeletion||workingDeletion.binding!==state?.binding||workingDeletion.checkpoint!==checkpoint(); + $('project-working-history').disabled=!workingSelection||workingSelection.binding!==state?.binding||!m2Allowed('file_history'); + $('project-working-history-more').disabled=!m2Allowed('file_history'); + for(const [button,action] of [['project-fork-load','fork_info'],['project-fork-more','fork_info'],['project-intents-load','material_intents'],['project-intents-more','material_intents'],['project-intent-capabilities','material_intent_capabilities'],['project-intent-capabilities-more','material_intent_capabilities']])$(button).disabled=!m2Allowed(action); + } + function m4Reset(){ + runtimeInspection=null;runtimeSourceOffset=runtimeSourceTotal=runtimeFileOffset=runtimeFileTotal=0;$('project-runtime-source').replaceChildren();$('project-runtime-files').replaceChildren();$('project-runtime-layout').textContent='';$('project-runtime-summary').textContent='';$('project-runtime-confirm').checked=false; + workingSelection=null;clearWorkingDeletion();workingOffset=workingTotal=workingHistoryOffset=workingHistoryTotal=intentsOffset=intentsTotal=forkOffset=forkTotal=intentCapsOffset=intentCapsTotal=0; + for(const name of ['project-working-list','project-working-history-list','project-fork-list','project-intents-list'])$(name).replaceChildren(); + $('project-working-text').value='';$('project-working-summary').textContent='';$('project-intent-detail').textContent=''; + for(const name of ['project-working-more','project-working-history-more','project-fork-more','project-intents-more','project-intent-capabilities-more'])$(name).hidden=true; + } + function forkOK(value,body){ + if(!exact(value,['state','fork'])||!stateOK(value.state)||value.state.state!=='open')return false; + const f=value.fork; + return exact(f,['source_project_id','source_checkpoint','project_id','checkpoint','root','directory','request_key','reconciled','mapping_count','copied_count','missing_regenerable_count'])&& + id(f.source_project_id)&&id(f.project_id)&&f.source_project_id!==f.project_id&&sha(f.source_checkpoint)&&sha(f.checkpoint)&& + f.source_checkpoint===body.expected_checkpoint&&f.source_project_id===forkPending?.source_project_id&& + ['root','directory','request_key'].every(field=>f[field]===body[field])&&typeof f.reconciled==='boolean'&& + ['mapping_count','copied_count','missing_regenerable_count'].every(field=>Number.isSafeInteger(f[field])&&f[field]>=0)&& + value.state.binding!==body.binding&&value.state.project.project_id===f.project_id&&value.state.project.checkpoint===f.checkpoint&&value.state.project.name===body.name; + } + $('project-save-as-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + if(importDirty||annotationDirty||[...m2DirtyForms].some(form=>form!=='project-save-as-form'))throw Error('Submit or discard other unsubmitted edits before Save As.'); + const fields={root:$('project-save-as-root').value,directory:$('project-save-as-directory').value,name:$('project-save-as-name').value.trim()},signature=JSON.stringify(fields); + if(!fields.root||!fields.directory||!fields.name)throw Error('Choose a new root, relative directory and project name.'); + if(forkPending&&forkPending.signature!==signature)throw Error('Inspect the pending Save As destination, then keep its exact request or explicitly discard this form before choosing another destination.'); + if(!forkPending)forkPending={signature,source_project_id:state.project.project_id,body:mutationBody('/save-as',fields)}; + const result=await request('/save-as',{body:forkPending.body,ticket,kind:'fork'});if(!result)return; + state=result.state;forkPending=null;pending=null;executionFormSaved('project-save-as-form');m4Reset();clearExploration();selected=null;history=[];nextHistory=null;showRecord();showState(); + const f=result.fork;$('project-save-as-summary').textContent=`Independent project ${f.project_id}; ${f.mapping_count} editable mappings, ${f.copied_count} copied payloads, ${f.missing_regenerable_count} missing regenerable payloads. Source checkpoint ${f.source_checkpoint} is retained as its origin.`; + await query(ticket);message('Independent project created and selected. Review its fork origins and binding readiness.');return $('project-fork-load'); + });}; + async function inspectWorking(ticket,more=false){ + const offset=more?workingOffset:0,value=await m2Request('files_inspect',{offset,limit:40},ticket);if(!value)return; + clearWorkingDeletion();if(!more)$('project-working-list').replaceChildren(); + for(const item of value.items){const li=document.createElement('li'),button=document.createElement('button');button.type='button';button.textContent=`${item.path} · ${item.state} · captured revision ${item.revision}`;button.onclick=()=>run(async next=>{ + const version=await m2Request('file_get',{file_id:item.id},next);if(!version)return;showWorking(version); + if(item.kind==='note'&&item.state==='missing'&&sha(item.expected_sha256)){workingDeletion={binding:state.binding,checkpoint:checkpoint(),path:item.path,revision:item.revision,sha256:item.expected_sha256};$('project-working-delete-summary').textContent=`Missing note ${item.path} · captured revision ${item.revision} · SHA256 ${item.expected_sha256}. Earlier text will remain in revision history.`;} + controls();$('project-working-kind').value=item.kind;$('project-working-path').value=item.path;$('project-working-revision').value=String(item.revision);$('project-working-sha').value=item.observed_sha256??''; + message(item.state==='unchanged'?'Captured bytes match the current file.':'Current file differs or is unavailable. Review its exact current digest before adoption.');return $('project-working-heading'); + },{clean:true});li.append(button);$('project-working-list').append(li);} + workingOffset=offset+value.items.length;workingTotal=value.total;$('project-working-more').hidden=workingOffset>=workingTotal; + } + function clearWorkingDeletion(){ + workingDeletion=null;$('project-working-delete-confirm').checked=false;executionFormSaved('project-working-delete-form'); + $('project-working-delete-summary').textContent='Inspect and select a known missing note first. This action records its absence and keeps all captured revisions.'; + } + function showWorking(value){ + clearWorkingDeletion(); + workingSelection={id:value.id,binding:state.binding};$('project-working-heading').textContent=value.path; + $('project-working-summary').textContent=value.version.deleted===true?`Captured revision ${value.version.revision} · note deletion retained. No text is present in this revision; select an earlier revision to read its captured text.`:`Captured revision ${value.version.revision} · ${value.version.sha256} · ${value.version.bytes} bytes${value.version.bytes===0?' · empty file':''}.`; + $('project-working-text').value=value.version.deleted===true?'':value.version.text;controls(); + } + async function workingHistory(ticket,more=false){ + if(!workingSelection||workingSelection.binding!==state.binding)throw Error('Select a file from the current project first.'); + const offset=more?workingHistoryOffset:0,value=await m2Request('file_history',{file_id:workingSelection.id,offset,limit:40},ticket);if(!value)return; + if(value.id!==workingSelection.id)throw Error('Working-file history belongs to another file.'); + if(!more)$('project-working-history-list').replaceChildren(); + for(const version of value.items){const li=document.createElement('li'),button=document.createElement('button');button.type='button';button.textContent=`Revision ${version.revision} · ${version.deleted===true?'note deletion':version.sha256} · ${version.adopted_at}`;button.onclick=()=>run(async next=>{const result=await m2Request('file_get',{file_id:workingSelection.id,revision:version.revision},next);if(result)showWorking(result);return $('project-working-heading');});li.append(button);$('project-working-history-list').append(li);} + workingHistoryOffset=offset+value.items.length;workingHistoryTotal=value.total;$('project-working-history-more').hidden=workingHistoryOffset>=workingHistoryTotal; + } + $('project-working-local').addEventListener('change',()=>run(async()=>{ + const file=$('project-working-local').files?.[0];if(!file)return;if(file.size>1048576)throw Error('Choose a UTF-8 file of at most 1 MiB.'); + const bytes=await file.arrayBuffer();new TextDecoder('utf-8',{fatal:true}).decode(bytes); + $('project-working-sha').value=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',bytes)),value=>value.toString(16).padStart(2,'0')).join(''); + m2DirtyForms.add('project-working-adopt-form');m2Dirty=true;message('Matching local file digest calculated. Review the server project path and expected adopted revision before adoption.'); + })); + $('project-working-adopt-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + const args={kind:$('project-working-kind').value,path:$('project-working-path').value,expected_revision:Number($('project-working-revision').value),expected_sha256:$('project-working-sha').value}; + const result=await m2Request('file_adopt',args,ticket);if(!result)return;executionFormSaved('project-working-adopt-form');$('project-working-revision').value=String(result.revision); + await inspectWorking(ticket);const saved=await m2Request('file_get',{file_id:result.id,revision:result.revision},ticket);if(saved)showWorking(saved);message('Exact working-file bytes adopted. The captured text remains inert.');return $('project-working-heading'); + });}; + $('project-working-delete-confirm').addEventListener('change',()=>{m2DirtyForms.add('project-working-delete-form');m2Dirty=true;controls();}); + $('project-working-delete-form').onsubmit=event=>{event.preventDefault();run(async ticket=>{ + const selected=workingDeletion;if(!selected||selected.binding!==state.binding||selected.checkpoint!==checkpoint()||!$('project-working-delete-confirm').checked)throw Error('Inspect and select a known missing note, then explicitly confirm its deletion revision.'); + const result=await m2Request('file_adopt_deletion',{path:selected.path,expected_revision:selected.revision,expected_sha256:selected.sha256},ticket);if(!result)return; + executionFormSaved('project-working-delete-form');clearWorkingDeletion();await inspectWorking(ticket); + const saved=await m2Request('file_get',{file_id:result.id,revision:result.revision},ticket);if(saved)showWorking(saved); + message('Note deletion retained. Earlier captured text remains available in revision history.');return $('project-working-heading'); + });}; + async function forkInfo(ticket,more=false){ + const offset=more?forkOffset:0,value=await m2Request('fork_info',{offset,limit:40},ticket);if(!value)return; + const page=value.mapping;if(!exact(page,['items','total','offset','limit'])||page.offset!==offset||page.limit!==40||!Array.isArray(page.items)||page.items.length!==Math.min(40,Math.max(0,page.total-offset)))throw Error('Fork mapping continuation differs.'); + if(!more)$('project-fork-list').replaceChildren();$('project-fork-summary').textContent=value.source?`Fork origin ${value.source.project_id} at ${value.source.checkpoint}; ${value.archived_count} archived records, ${value.copied_count} copied payloads, ${value.missing_regenerable_count} missing regenerable payloads.`:'This project has no independent-fork origin overlay.'; + for(const mapping of page.items){const li=document.createElement('li');li.textContent=`${mapping.family}: ${mapping.source_id} revision ${mapping.source_revision??'none'} → ${mapping.destination_id}`;$('project-fork-list').append(li);} + forkOffset=offset+page.items.length;forkTotal=page.total;$('project-fork-more').hidden=forkOffset>=forkTotal; + } + async function intentList(ticket,more=false,capabilities=false){ + const offset=more?(capabilities?intentCapsOffset:intentsOffset):0,action=capabilities?'material_intent_capabilities':'material_intents'; + const value=await m2Request(action,{offset,limit:40},ticket);if(!value)return;if(!more)$('project-intents-list').replaceChildren(); + for(const item of value.items){const li=document.createElement('li'),fields=capabilities?item.requires_reselection:item.required_reselection; + if(capabilities)li.textContent=`${item.family} · ${item.id} · ${fields.length?'Reselect '+fields.join(', '):'Current editable definition; execution still requires current permission.'}`; + else{const button=document.createElement('button');button.type='button';button.textContent=`${item.name} · ${item.family} · reselect ${fields.join(', ')}`;button.onclick=()=>run(async next=>{const detail=await m2Request('material_intent',{family:item.family,definition_id:item.definition_id},next);if(!detail)return;$('project-intent-title').textContent=detail.current.name;$('project-intent-detail').textContent=JSON.stringify(detail,null,2);return $('project-intent-title');});li.append(button);} + $('project-intents-list').append(li);} + if(capabilities){intentCapsOffset=offset+value.items.length;intentCapsTotal=value.total;$('project-intent-capabilities-more').hidden=intentCapsOffset>=intentCapsTotal;} + else{intentsOffset=offset+value.items.length;intentsTotal=value.total;$('project-intents-more').hidden=intentsOffset>=intentsTotal;} + } + $('project-working-load').onclick=()=>run(ticket=>inspectWorking(ticket),{clean:true});$('project-working-more').onclick=()=>run(ticket=>inspectWorking(ticket,true)); + $('project-working-history').onclick=()=>run(ticket=>workingHistory(ticket));$('project-working-history-more').onclick=()=>run(ticket=>workingHistory(ticket,true)); + $('project-fork-load').onclick=()=>run(ticket=>forkInfo(ticket));$('project-fork-more').onclick=()=>run(ticket=>forkInfo(ticket,true)); + $('project-intents-load').onclick=()=>run(ticket=>intentList(ticket));$('project-intents-more').onclick=()=>run(ticket=>intentList(ticket,true)); + $('project-intent-capabilities').onclick=()=>run(ticket=>intentList(ticket,false,true));$('project-intent-capabilities-more').onclick=()=>run(ticket=>intentList(ticket,true,true)); + for(const [form,fields] of [['project-save-as-form',['project-save-as-directory','project-save-as-name','project-save-as-root']],['project-working-adopt-form',['project-working-path','project-working-revision','project-working-sha','project-working-kind']]])for(const field of fields)$(field).addEventListener('input',()=>{m2DirtyForms.add(form);m2Dirty=true;controls();}); + + for(const [form,fields] of [['project-producer-start-form',['project-producer-url','project-producer-model','project-producer-instruction','project-producer-labels','project-producer-seed','project-producer-profile','project-producer-session','project-producer-width','project-producer-height','project-producer-count','project-producer-prompt-url','project-producer-prompt-model']],['project-producer-review-form',['project-producer-note']],['project-execution-approval-form',['project-execution-consumer','project-execution-ttl']],['project-study-approval-form',['project-study-consumer']]])for(const field of fields)$(field).addEventListener('input',()=>{m2DirtyForms.add(form);m2Dirty=true;controls();}); + for(const action of ['new','open','save','close'])$('project-files-'+action).onclick=()=>lifecycle(action); + $('project-files-refresh').onclick=refresh; + $('records-more').onclick=()=>run(async ticket=>{await query(ticket,true);message('More text records loaded.');return nextRecord===null?$('records-title'):$('records-more');}); + $('record-history-more').onclick=olderHistory; + $('project-files-discard').onclick=()=>{importDirty=false;annotationDirty=false;m2Dirty=false;m2DirtyForms.clear();clearWorkingDeletion();nativePending=null;nativePrepared=null;for(const field of ['project-native-probe-confirm','project-native-submit-confirm','project-native-retry-confirm'])$(field).checked=false;forkPending=null;runtimePending=null;runtimeInspection=null;for(const field of ['project-candidate-study','project-candidate-id','project-candidate-directory','project-candidate-runtime','project-candidate-package-run','project-candidate-package-sha','project-candidate-recovery-study','project-candidate-recovery-key','project-candidate-abandon-reason','project-runtime-abandon-key','project-runtime-abandon-reason'])$(field).value='';$('project-runtime-confirm').checked=false;$('project-runtime-pending').textContent='';for(const field of ['project-save-as-directory','project-save-as-name','project-working-path','project-working-sha','project-working-local'])$(field).value='';$('project-working-revision').value='0';executionApproval=null;studyApproval=null;producerCurrent=null;for(const field of ['project-producer-url','project-producer-model','project-producer-instruction','project-producer-labels','project-producer-note','project-producer-profile','project-producer-session','project-producer-prompt-url','project-producer-prompt-model'])$(field).value='';$('project-producer-confirm').checked=false;$('project-execution-confirm').checked=false;$('project-artifact-path').value='';for(const form of ['project-model-create-form','project-study-create-form','project-register-form','project-scientific-form'])for(const field of $(form).querySelectorAll('input'))field.value=field.defaultValue??'';clearExplorationDetail();$('project-source-file').value='';$('project-source-name').value='';$('project-search-name').value='';$('project-selection-name').value='';$('text-name').value='';$('text-content').value='';$('text-groups').value='project';$('text-rights').value='unknown';showRecord();clearError();message('Unsubmitted edits discarded. Saved project records are unchanged.');}; + for(const field of ['text-name','text-content','text-groups','text-rights'])$(field).addEventListener('input',()=>{importDirty=true;controls();}); + for(const field of ['annotation-value','annotation-groups','record-review','annotation-task'])$(field).addEventListener(field==='annotation-task'||field==='record-review'?'change':'input',()=>{annotationDirty=true;annotationHelp();controls();}); + window.addEventListener('beforeunload',event=>{if(dirty()){event.preventDefault();event.returnValue='';}}); + window.addEventListener('tuldok-session',()=>{++epoch;busy=false;blocked=true;selectedContracts=new Set();controls();message('Session changed. Refreshing project status; your unsubmitted edits remain here.');refresh();}); + controls();refresh(); +})(); diff --git a/static/project-transfer.css b/static/project-transfer.css new file mode 100644 index 00000000..c7acbc75 --- /dev/null +++ b/static/project-transfer.css @@ -0,0 +1,2 @@ +:root{color-scheme:light dark;--bg:#f5f6f8;--panel:#fff;--ink:#172333;--muted:#4c5b6c;--line:#b9c3cf;--accent:#1648a2;--notice:#fff3d5;--error:#8a1720} +*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.55 system-ui,sans-serif}main{max-width:1080px;margin:0 auto;padding:2rem 1.2rem 5rem}header{max-width:800px;margin-bottom:1.7rem}.eyebrow{font-size:.85rem;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}h1{line-height:1.15;font-size:clamp(2rem,4vw,2.8rem)}h2{font-size:1.35rem;margin-top:0}h3{font-size:1.05rem}p{overflow-wrap:anywhere}section{background:var(--panel);border:1px solid var(--line);border-radius:.6rem;padding:1.3rem;margin:1.2rem 0}fieldset{border:0;padding:0;margin:0}legend{font-weight:650;margin-bottom:.7rem}label{display:flex;flex-direction:column;gap:.35rem;margin:.7rem 0;font-weight:550}input,select,textarea,button{font:inherit}input,select,textarea{max-width:100%;width:100%;padding:.55rem;border:1px solid var(--line);border-radius:.25rem;background:var(--panel);color:var(--ink)}textarea{resize:vertical}button{padding:.55rem .8rem;margin:.3rem .35rem .3rem 0;cursor:pointer;border:1px solid var(--accent);border-radius:.3rem;color:var(--accent);background:var(--panel)}button:disabled{opacity:.5;cursor:default}a{color:var(--accent)}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.2rem 1rem}.actions{align-self:end}.help{font-size:.9rem;color:var(--muted)}.check{display:flex;flex-direction:row;align-items:flex-start;gap:.65rem;font-weight:400}.check input{width:auto;flex:0 0 auto;margin-top:.35rem}.facts{padding-left:1.4rem;overflow-wrap:anywhere}.facts li{margin:.65rem 0}.facts li p{margin:.15rem 0}.facts button{font-size:.85rem;padding:.15rem .4rem}.notice{padding:.8rem;border-left:4px solid #9c6800;background:var(--notice);overflow-wrap:anywhere}#transfer-error{padding:1rem;border:2px solid var(--error);color:var(--error);background:var(--panel);overflow-wrap:anywhere}#transfer-receipt,#report-summary,#transfer-context{overflow-wrap:anywhere}details{margin:1rem 0;border-top:1px solid var(--line);padding-top:.7rem}summary{cursor:pointer;font-weight:600}:focus-visible{outline:3px solid var(--accent);outline-offset:3px}.skip-link{position:absolute;left:1rem;top:-5rem;padding:.5rem;background:var(--panel);z-index:10}.skip-link:focus{top:.5rem}[hidden]{display:none!important}@media(max-width:650px){main{padding:1rem .7rem 3rem}.grid{grid-template-columns:1fr}section{padding:1rem}button{min-height:44px}h1{font-size:2rem}}@media(prefers-color-scheme:dark){:root{--bg:#10171f;--panel:#19232e;--ink:#ecf0f5;--muted:#b7c5d5;--line:#647487;--accent:#99bbff;--notice:#362d18;--error:#ffabb2}} diff --git a/static/project-transfer.html b/static/project-transfer.html new file mode 100644 index 00000000..504a2c44 --- /dev/null +++ b/static/project-transfer.html @@ -0,0 +1,91 @@ + + + + + Extract a legacy project · Tuldok + + + + + +
                      +

                      Tuldok / legacy catalog

                      Extract an independent project

                      +

                      Choose one registered project and the global records that belong with it. Review required dependencies before explicitly stopping the source and publishing a new project directory.

                      +

                      Preview reads the source. Extraction preserves its verified post-stop baseline. Scripts stay inert; these controls do not run computations or restore old permissions.

                      +
                      +

                      Reading extraction status…

                      + +
                      +

                      Current controller

                      Sign in to inspect this controller.

                      + + +
                      +
                      +

                      1. Choose the project and source facts

                      +
                      Explicit source selection +
                      +
                      +

                      Global data, Studies and Runs are not assigned to a project by their names or location. Enter the exact IDs you choose; review additional dependencies and unassigned facts in the report.

                      +
                      +
                      + +
                      Add producer observations or working files +
                      +
                      +
                      +
                      +

                      Selected working files become new UTF-8 adoptions attributed to this extraction. No earlier adoption is invented and no script is executed.

                      +
                      +
                      Explicit ownership decisions +
                      +
                      +

                      A required dependency cannot be excluded while claiming a complete extraction.

                      +
                      +
                      Load a prepared selection document + +

                      This imports only the named project snapshot, finite source families and ownership decisions. It does not accept commands, credentials or execution options. Review its summary before preview.

                      +
                      +

                      Selection summary

                        +

                        No selection has been previewed.

                        + +
                        +
                        +
                        +

                        2. Review dependency closure

                        Preview a selection to see its report.

                        +
                        +
                        +

                          + +
                          +
                          +

                          3. Stop the source and capture its backup

                          +

                          Stopping fences new source requests and drains the existing owners. A failed or unknown stop keeps the source fenced and cannot authorize extraction. Keep the private backup separate from the destination.

                          +
                          Explicit stop destinations +
                          +
                          + + +
                          +
                          + +
                          +

                          5. Publish the reviewed project

                          +
                          Independent destination +

                          No stopped destination is confirmed.

                          + +

                          Keep this exact key and destination when reconciling an uncertain outcome. Refreshing status never retries publication.

                          + + +
                          + +

                          Retained outcome

                          No extraction has been acknowledged.

                          + +

                          +
                          +
                          + diff --git a/static/project-transfer.js b/static/project-transfer.js new file mode 100644 index 00000000..58d00e5c --- /dev/null +++ b/static/project-transfer.js @@ -0,0 +1,203 @@ +'use strict'; +// Five installed controller operations; discovery never executes remote schemas. +(()=>{ + const families={records:'Data records',responses:'Responses',preferences:'Comparative judgments',selections:'Saved selections',searches:'Saved searches',graphs:'Pipeline graphs',recipe_drafts:'Recipe definitions',model_definitions:'Model definitions',studies:'Studies',runs:'Historical Runs',releases:'Frozen releases',exposures:'Exposure facts',model_materials:'Model materials',recipe_materials:'Recipe materials',runtime_materials:'Runtime materials'}; + const canonicalFamilies=['workbench_record','workbench_response','workbench_preference','saved_selection','saved_search','pipeline_graph','recipe_definition','model_definition','study_history','trial_history','run_history','candidate_history','dataset_release','dataset_exposure','model_material','recipe_material','runtime_material','project_snapshot','project_artifact','project_file','generation_history','grounded_history','caption_proposal_history','text_proposal_history']; + const shaRoots=new Set(['releases','model_materials','recipe_materials','runtime_materials']),producerKinds=['generation','grounded','caption','text_proposal']; + const sections=['included','required','unassigned','excluded','blockers','warnings','artifacts','working_files']; + const actions=['status','preview','report_page','stop','extract']; + const exact=(v,keys)=>v!==null&&typeof v==='object'&&!Array.isArray(v)&&Object.keys(v).length===keys.length&&keys.every(k=>Object.hasOwn(v,k)); + const text=(v,max,empty=false)=>typeof v==='string'&&Array.from(v).length<=max&&(empty||/\S/u.test(v)); + const id=v=>typeof v==='string'&&/^[a-f0-9]{32}$/.test(v),sha=v=>typeof v==='string'&&/^[a-f0-9]{64}$/.test(v); + const integer=(v,max=1000000)=>Number.isSafeInteger(v)&&v>=0&&v<=max; + const clone=v=>JSON.parse(JSON.stringify(v)); + function protocol(message){const failure=Error(message);failure.code='invalid_response';return failure;} + const leaf=v=>text(v,200)&&!/[\/\\\x00-\x1f]/.test(v)&&!['.','..'].includes(v); + const working=v=>exact(v,['kind','path'])&&['script','note'].includes(v.kind)&&text(v.path,512)&&v.path.startsWith(v.kind==='script'?'scripts/':'notes/')&&!/[\\\x00-\x1f]/.test(v.path)&&v.path.split('/').length<=16&&v.path.split('/').every(p=>p&&!['.','..'].includes(p)); + function normalizeSelection(value){ + if(!exact(value,['project_snapshot','roots','decisions'])||!exact(value.project_snapshot,['id','manifest_sha256'])||!id(value.project_snapshot.id)||!sha(value.project_snapshot.manifest_sha256))throw Error('Choose the exact registered project ID and manifest SHA256.'); + if(!value.roots||typeof value.roots!=='object'||Array.isArray(value.roots)||Object.keys(value.roots).some(k=>!Object.hasOwn(families,k)&&!['producer_jobs','working_files'].includes(k)))throw Error('The selection contains an unknown source family.'); + const roots={}; + for(const family of Object.keys(families)){ + const list=value.roots[family]??[]; + if(!Array.isArray(list)||list.length>10000||new Set(list).size!==list.length||!list.every(shaRoots.has(family)?sha:id))throw Error('Use unique exact '+(shaRoots.has(family)?'SHA256 digests':'IDs')+' for '+families[family]+'.'); + roots[family]=[...list].sort(); + } + const jobs=value.roots.producer_jobs??[],files=value.roots.working_files??[]; + if(!Array.isArray(jobs)||jobs.length>10000||!jobs.every(j=>exact(j,['kind','id'])&&producerKinds.includes(j.kind)&&id(j.id))||new Set(jobs.map(j=>j.kind+':'+j.id)).size!==jobs.length)throw Error('Choose known producer kinds and unique job IDs.'); + if(!Array.isArray(files)||files.length>1000||!files.every(working)||new Set(files.map(f=>f.kind+':'+f.path)).size!==files.length)throw Error('Working files must be unique scripts/... or notes/... paths within the selected project.'); + roots.producer_jobs=clone(jobs).sort((a,b)=>(a.kind+':'+a.id).localeCompare(b.kind+':'+b.id)); + roots.working_files=clone(files).sort((a,b)=>(a.kind+':'+a.path).localeCompare(b.kind+':'+b.path)); + if(!Array.isArray(value.decisions)||value.decisions.length>10000||!value.decisions.every(d=>exact(d,['family','id','include','reason'])&&canonicalFamilies.includes(d.family)&&text(d.id,128)&&typeof d.include==='boolean'&&text(d.reason,1000))||new Set(value.decisions.map(d=>d.family+':'+d.id)).size!==value.decisions.length)throw Error('Each ownership decision needs a known family, exact report ID and reason.'); + const result={project_snapshot:clone(value.project_snapshot),roots,decisions:clone(value.decisions).sort((a,b)=>(a.family+':'+a.id).localeCompare(b.family+':'+b.id))}; + if(new TextEncoder().encode(JSON.stringify(result)).length>1048576)throw Error('The selection exceeds one MiB.'); + return result; + } + const sourceOK=v=>exact(v,['catalog_id','schema_version','catalog_signature','snapshot_sha256','backup_receipt_sha256','external_inventory_sha256'])&&id(v.catalog_id)&&v.schema_version===10&&sha(v.catalog_signature)&&sha(v.snapshot_sha256)&&(v.backup_receipt_sha256===null||sha(v.backup_receipt_sha256))&&sha(v.external_inventory_sha256); + const failureOK=v=>v===null||(v&&typeof v==='object'&&Object.keys(v).every(k=>['action','code','detail','staging_directory'].includes(k))&&['stop','extract'].includes(v.action)&&text(v.code,120)&&text(v.detail,1000,true)&&(v.staging_directory===undefined||leaf(v.staging_directory))); + function receiptOK(v){ + if(v===null)return true; + if(exact(v,['format','committed','project_id','checkpoint','request_key','input_sha256','report_sha256','source','artifacts','verification','replayed']))return v.format==='project_transfer_receipt_v1'&&v.committed===true&&id(v.project_id)&&sha(v.checkpoint)&&text(v.request_key,128)&&sha(v.input_sha256)&&sha(v.report_sha256)&&sourceOK(v.source)&&v.source.backup_receipt_sha256!==null&&exact(v.artifacts,['copied_count','missing_regenerable_count'])&&integer(v.artifacts.copied_count)&&integer(v.artifacts.missing_regenerable_count)&&exact(v.verification,['checkpoint','active_data_records'])&&v.verification.checkpoint===v.checkpoint&&integer(v.verification.active_data_records)&&typeof v.replayed==='boolean'; + return v&&typeof v==='object'&&!Array.isArray(v)&&Object.keys(v).every(k=>['committed','selected','project_id','checkpoint','request_key','directory'].includes(k))&&v.committed===true&&typeof v.selected==='boolean'&&id(v.project_id)&&sha(v.checkpoint)&&text(v.request_key,128)&&(v.directory===undefined||leaf(v.directory)); + } + function stateOK(v){ + if(!exact(v,['format','binding','phase','source_catalog_id','control_catalog_id','roots','available_actions','report','backup','destination','failure','last_receipt'])||v.format!=='project_transfer_state_v1'||!id(v.binding)||!['source_live','source_stopped','source_fenced'].includes(v.phase)||!id(v.source_catalog_id)||!id(v.control_catalog_id)||v.source_catalog_id===v.control_catalog_id||!Array.isArray(v.roots)||!v.roots.length||v.roots.length>100||!v.roots.every(r=>typeof r==='string'&&/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(r))||new Set(v.roots).size!==v.roots.length||!Array.isArray(v.available_actions)||!v.available_actions.includes('status')||!v.available_actions.every(a=>actions.includes(a))||new Set(v.available_actions).size!==v.available_actions.length||!failureOK(v.failure)||!receiptOK(v.last_receipt))return false; + if(v.report!==null&&(!exact(v.report,['report_sha256','selection_sha256','source','can_extract','counts'])||!sha(v.report.report_sha256)||!sha(v.report.selection_sha256)||!sourceOK(v.report.source)||v.report.source.catalog_id!==v.source_catalog_id||typeof v.report.can_extract!=='boolean'||!exact(v.report.counts,sections)||!Object.values(v.report.counts).every(n=>integer(n))))return false; + if(v.backup!==null&&(!exact(v.backup,['root','directory','receipt_sha256'])||!v.roots.includes(v.backup.root)||!leaf(v.backup.directory)||!sha(v.backup.receipt_sha256)))return false; + if(v.destination!==null&&(!exact(v.destination,['root','directory'])||!v.roots.includes(v.destination.root)||!leaf(v.destination.directory)))return false; + return v.phase!=='source_fenced'||v.available_actions.every(a=>a==='status'); + } + function itemOK(section,v){ + if(['included','unassigned'].includes(section))return exact(v,['family','id','revision'])&&text(v.family,64)&&text(v.id,128)&&(v.revision===null||integer(v.revision)&&v.revision>0); + if(['required','excluded'].includes(section))return exact(v,['family','id'])&&text(v.family,64)&&text(v.id,128); + if(['blockers','warnings'].includes(section))return exact(v,['family','id','code','detail'])&&text(v.family,64)&&text(v.id,128)&&text(v.code,120)&&text(v.detail,1000,true); + if(section==='artifacts')return exact(v,['sha256','bytes','preservation','state'])&&sha(v.sha256)&&integer(v.bytes,Number.MAX_SAFE_INTEGER)&&['unique','regenerable'].includes(v.preservation)&&['verified','missing','corrupt','unverified'].includes(v.state); + return section==='working_files'&&exact(v,['kind','path','sha256','bytes'])&&working({kind:v.kind,path:v.path})&&sha(v.sha256)&&integer(v.bytes,1048576); + } + function pageOK(v){return exact(v,['format','binding','report_sha256','section','items','offset','limit','total'])&&v.format==='project_transfer_report_page_v1'&&id(v.binding)&&sha(v.report_sha256)&§ions.includes(v.section)&&integer(v.offset)&&v.limit===40&&integer(v.total)&&Array.isArray(v.items)&&v.items.length<=40&&v.items.length===Math.min(40,Math.max(0,v.total-v.offset))&&v.items.every(item=>itemOK(v.section,item));} + if(typeof module==='object'&&module.exports)module.exports={normalizeSelection,stateOK,pageOK,receiptOK}; + if(typeof document==='undefined')return; + const $=name=>document.getElementById(name),base='/api/workspace/project-transfer'; + const pins=/* PROJECT_TRANSFER_CONTRACTS_START */ +{ + "project_transfer.extract": "00e0ae9a09d36279684f3614e76445ba1dca50557e55047f699a48b43f823aa8", + "project_transfer.preview": "7072d7f08763dd05e36c9be6b5aa730411948bb34c32361ec80215dd8b31bf24", + "project_transfer.report_page": "09dd19eb4299b323710181ea20a7b4dcffce0c2173eeb51a315d551e72811515", + "project_transfer.status": "190e72fe33a75105b3da034469931b4e30e646ef38af6b55ff8a3c2444725870", + "project_transfer.stop": "050519dab950115508123eef3bf9a9fe4aad40b920a6612b1b850688afec90b8" +} +/* PROJECT_TRANSFER_CONTRACTS_END */; + const routes={status:{method:'GET',path:''},preview:{method:'POST',path:'/preview'},report_page:{method:'GET',path:'/report'},stop:{method:'POST',path:'/stop'},extract:{method:'POST',path:'/extract'}}; + let state=null,contracts=new Set(),draft={roots:{},decisions:[]},busy=false,epoch=0,revision=0,reviewed=false,onlyCessation=false; + let handoff=false,freshControlAttempt=false,freshControl=false,refreshAfter=false,pending=null,lastReceipt=null,page=null,controlCatalog=null,dirty=false; + function error(message){$('transfer-error').hidden=false;$('transfer-error').textContent=message;$('transfer-error').focus();} + function message(value){$('transfer-status').textContent=value;} + function clearError(){$('transfer-error').hidden=true;$('transfer-error').textContent='';} + function currentOwner(){const value=window.TuldokAuth?.identity?.();return value?.kind==='owner'&&value.scopes?.includes('admin');} + function action(name){return currentOwner()&&contracts.has('project_transfer.'+name)&&state?.available_actions.includes(name);} + function signedControl(){return freshControl&&window.TuldokAuth?.identity?.()?.catalog_id===controlCatalog;} + function controls(){ + const live=state?.phase==='source_live',stopped=state?.phase==='source_stopped',report=state?.report; + $('selection-fields').disabled=busy||!!pending||!action('preview')||(stopped&&!signedControl()); + $('transfer-refresh').disabled=busy; + $('report-load').disabled=busy||!report||!action('report_page')||(stopped&&!signedControl()); + $('report-previous').disabled=$('report-load').disabled||!page||page.offset===0; + $('report-next').disabled=$('report-load').disabled||!page||page.offset+page.items.length>=page.total; + $('report-reviewed').disabled=busy||!reviewed||!report; + $('stop-fields').disabled=busy||!live||!action('stop')||!reviewed; + $('transfer-stop').disabled=$('stop-fields').disabled||!onlyCessation||!$('report-reviewed').checked||!$('stop-confirm').checked; + $('extract-fields').disabled=busy||!!pending||!stopped||!signedControl()||!action('extract')||!reviewed||!report?.can_extract; + $('transfer-extract').disabled=$('extract-fields').disabled||!$('report-reviewed').checked||!$('extract-confirm').checked; + $('transfer-reconcile').hidden=!pending; + $('transfer-reconcile').disabled=busy||!pending||!stopped||!signedControl()||!action('extract')||state.report?.report_sha256!==pending.report_sha256; + $('control-signin-panel').hidden=!handoff&&!(stopped&&!signedControl()); + $('control-signin').disabled=busy; + $('transfer-fence').hidden=state?.phase!=='source_fenced'; + $('transfer-fence').textContent='The source remains fenced. No stopped-source proof is available; extraction is unavailable. Retain any reported receipt or staging directory for operator review.'; + } + function invalidate(){revision++;dirty=true;reviewed=false;onlyCessation=false;page=null;$('report-reviewed').checked=false;$('stop-confirm').checked=false;$('extract-confirm').checked=false;$('report-items').replaceChildren();$('selection-dirty').textContent='Selection changed. Explicitly preview it again before any source action.';controls();} + function selection(){return normalizeSelection({project_snapshot:{id:$('snapshot-id').value.trim(),manifest_sha256:$('snapshot-sha').value.trim()},roots:clone(draft.roots),decisions:clone(draft.decisions)});} + function summarize(){ + const list=$('selection-summary');list.replaceChildren(); + for(const [key,values] of Object.entries(draft.roots))if(values.length){const li=document.createElement('li');li.textContent=`${families[key]??(key==='producer_jobs'?'Producer observations':'Working files')}: ${values.length}`;const remove=document.createElement('button');remove.type='button';remove.textContent='Remove this family';remove.onclick=()=>{delete draft.roots[key];invalidate();summarize();};li.append(remove);list.append(li);} + for(const decision of draft.decisions){const li=document.createElement('li');li.textContent=`${decision.include?'Include':'Exclude'} ${decision.family} ${decision.id}: ${decision.reason}`;const remove=document.createElement('button');remove.type='button';remove.textContent='Remove decision';remove.onclick=()=>{draft.decisions=draft.decisions.filter(d=>d!==decision);invalidate();summarize();};li.append(remove);list.append(li);} + if(!list.children.length){const li=document.createElement('li');li.textContent='No global source facts selected. Only the exact registered snapshot will be included unless you add roots.';list.append(li);} + } + function fillRoots(){for(const name of ['backup-root','destination-root']){const node=$(name),old=node.value;node.replaceChildren();for(const alias of state.roots){const option=document.createElement('option');option.value=alias;option.textContent=alias;node.append(option);}if(state.roots.includes(old))node.value=old;}} + function keepReceipt(value){if(!receiptOK(value)||value===null)return;lastReceipt=clone(value);$('transfer-receipt').textContent=`Publication recorded: project ${value.project_id}; ${value.verification?'current verified checkpoint':'recorded checkpoint'} ${value.checkpoint}; request ${value.request_key}.${value.replayed?' Exact request reconciled.':''}`;$('project-files-link').hidden=false;} + function adopt(value,{ownPreview=false}={}){ + if(!stateOK(value))throw protocol('Unsupported extraction controller response. Refresh status before continuing.'); + const changed=state?.binding!==value.binding;state=value;controlCatalog=value.control_catalog_id; + fillRoots();if(value.last_receipt)keepReceipt(value.last_receipt); + if(!ownPreview)$('selection-dirty').textContent='Controller status is current. Explicitly preview your local selection before any source effect.'; + if(changed||!ownPreview){reviewed=false;onlyCessation=false;$('report-reviewed').checked=false;page=null;$('report-items').replaceChildren();} + if(value.phase==='source_live'){handoff=false;freshControl=false;freshControlAttempt=false;} + else{handoff=!signedControl();} + $('transfer-context').textContent=`${value.phase.replaceAll('_',' ')} · source ${value.source_catalog_id} · separate control host ${value.control_catalog_id}`; + $('control-signin-status').textContent=`Control catalog: ${value.control_catalog_id}. ${signedControl()?'Fresh control sign-in verified.':'Submit a new control-host login code using the standard session dialog.'}`; + $('report-summary').textContent=value.report?`Report ${value.report.report_sha256}. Included ${value.report.counts.included}; required ${value.report.counts.required}; unassigned ${value.report.counts.unassigned}; blockers ${value.report.counts.blockers}; warnings ${value.report.counts.warnings}; artifacts ${value.report.counts.artifacts}. ${value.report.can_extract?'Complete stopped-source closure is available.':'Extraction is unavailable until every blocker is resolved.'}`:'No current report. Explicitly preview this selection.'; + if(value.destination){$('destination-root').value=value.destination.root;$('destination-directory').value=value.destination.directory;} + if(value.backup){$('backup-root').value=value.backup.root;$('backup-directory').value=value.backup.directory;} + $('extract-destination').textContent=value.destination?`Confirmed destination: ${value.destination.root} / ${value.destination.directory}.`:'Confirm the destination before stopping the source.'; + if(value.failure){$('transfer-pending').hidden=false;$('transfer-pending').textContent=`${value.failure.action}: ${value.failure.code}. ${value.failure.detail}${value.failure.staging_directory?' Retained stage: '+value.failure.staging_directory+'.':''}`;} + controls(); + } + function row(section,item){const li=document.createElement('li'); + if(section==='artifacts')li.textContent=`${item.sha256} · ${item.bytes} bytes · ${item.preservation} · ${item.state}`; + else if(section==='working_files')li.textContent=`${item.kind}: ${item.path} · ${item.bytes} bytes · ${item.sha256}`; + else{li.textContent=`${item.family} · ${item.id}${item.revision!==undefined&&item.revision!==null?' · revision '+item.revision:''}${item.code?' · '+item.code:''}`;if(item.detail){const detail=document.createElement('p');detail.textContent=item.detail;li.append(detail);}} + return li; + } + async function discover(ticket){ + const response=await window.TuldokAuth.fetch('/api/agent/capabilities',{cache:'no-store'}),value=await response.json(); + if(ticket!==epoch)return false;if(!response.ok)throw Error(value?.error??'Sign in, then refresh extraction status.'); + if(!exact(value,['format','envelope_version','catalog_id','operations','limits'])||value.format!=='agent_capabilities_v1'||value.envelope_version!==1||!id(value.catalog_id)||!Array.isArray(value.operations)||value.operations.length>256||!exact(value.limits,['request_bytes','response_bytes','max_operations'])||value.limits.request_bytes!==1048576||value.limits.response_bytes!==16777216||value.limits.max_operations!==256)throw protocol('Unsupported capability discovery response.'); + if(window.TuldokAuth.identity?.()?.catalog_id!==value.catalog_id)throw Error('Capabilities belong to a different current session.'); + const chosen=new Set(),seen=new Set(); + for(const operation of value.operations){if(!exact(operation,['id','contracts','availability'])||typeof operation.id!=='string'||seen.has(operation.id)||!Array.isArray(operation.contracts)||operation.contracts.length>4||!['available','unavailable','unsupported'].includes(operation.availability))throw Error('Unsupported capability entry.');seen.add(operation.id); + const versions=new Set();for(const contract of operation.contracts){if(!exact(contract,['version','sha256','input_schema','output_schema','scopes','authority','effects','retry','bounds'])||!Number.isSafeInteger(contract.version)||contract.version<1||versions.has(contract.version)||!sha(contract.sha256)||!contract.input_schema||typeof contract.input_schema!=='object'||!contract.output_schema||typeof contract.output_schema!=='object'||!Array.isArray(contract.scopes)||!contract.scopes.every(s=>text(s,40))||!['owner','agent'].includes(contract.authority)||!['read','mutation'].includes(contract.effects)||!['read','cas','receipt','never'].includes(contract.retry)||!exact(contract.bounds,['request_bytes','response_bytes','inline_decoded_bytes','json_depth'])||!Object.values(contract.bounds).every(v=>Number.isSafeInteger(v)&&v>0))throw protocol('Unsupported installed contract discovery frame.');versions.add(contract.version);if(contract.version===1&&operation.availability==='available'&&pins[operation.id]===contract.sha256)chosen.add(operation.id);} + } + contracts=chosen;if(!contracts.has('project_transfer.status'))throw Error('No compatible extraction status contract is available.');return true; + } + async function call(name,args,ticket,{mutation=false}={}){ + const operation='project_transfer.'+name,route=routes[name];if(!contracts.has(operation))throw Error('No compatible contract is available for '+name+'. Refresh status.'); + let response,value; + try{const query=route.method==='GET'&&Object.keys(args).length?'?'+new URLSearchParams(args):'';response=await window.TuldokAuth.fetch(base+route.path+query,{method:route.method,cache:'no-store',headers:{'Tuldok-Operation-Contract':operation+'/1/'+pins[operation],...(route.method==='POST'?{'Content-Type':'application/json'}:{})},...(route.method==='POST'?{body:JSON.stringify(args)}:{})});value=await response.json();} + catch(_){const failure=Error(mutation?'Acknowledgement unavailable. Keep this exact request and refresh status; the source may be fenced or publication may already exist.':'Response unavailable. Refresh status explicitly.');failure.code='transport';throw failure;} + if(new TextEncoder().encode(JSON.stringify(value)).length>1048576)throw Error('Extraction response exceeds its interface bound.'); + if(mutation&&value?.receipt&&receiptOK(value.receipt))keepReceipt(value.receipt); + if(ticket!==epoch){message('A session change superseded the response. Any known publication receipt has been retained. Refresh status before continuing.');return null;} + if(!response.ok){const keys=['error',...(Object.hasOwn(value??{},'code')?['code']:[]),...(Object.hasOwn(value??{},'receipt')?['receipt']:[])];const valid=exact(value,keys)&&text(value.error,1000)&&(!value.code||text(value.code,120))&&(!value.receipt||receiptOK(value.receipt));const failure=Error(valid?`${value.code??'error'}: ${value.error}`:'Unsupported error response. Retain the current request and refresh status.');failure.code=valid?(value.code??'http_error'):'transport';throw failure;} + return value; + } + async function run(work){if(busy)return;busy=true;clearError();const ticket=++epoch;controls();try{await work(ticket);}catch(failure){if(ticket===epoch){if(['invalid_response','transport','stale'].includes(failure.code)){reviewed=false;onlyCessation=false;$('report-reviewed').checked=false;if(failure.code==='invalid_response')contracts.clear();}error(failure.message);}}finally{busy=false;controls();if(refreshAfter){refreshAfter=false;void refresh();}}} + async function refresh(){return run(async ticket=>{if(!await discover(ticket))return;const value=await call('status',{},ticket);if(value){adopt(value);message('Controller status refreshed. No source or publication action was retried.');}});} + async function loadPage(ticket,offset=0,section=$('report-section').value){ + const binding=state?.binding,report=state?.report?.report_sha256;if(!report)throw Error('Preview the selection before loading its report.'); + const value=await call('report_page',{binding,report_sha256:report,section,offset,limit:40},ticket); + if(!value)return;if(!pageOK(value)||value.binding!==binding||value.report_sha256!==report||value.section!==section||value.offset!==offset||value.total!==state.report.counts[section])throw protocol('Report page is stale or unsupported. Preview and review the current report again.'); + page=value;$('report-items').replaceChildren(...value.items.map(item=>row(section,item)));$('report-page-status').textContent=`${section.replaceAll('_',' ')}: ${value.items.length?value.offset+1:0}–${value.offset+value.items.length} of ${value.total}.`; + if(section==='blockers')onlyCessation=value.total===1&&value.items.length===1&&value.items[0].code==='cessation_proof_unavailable'&&value.items[0].family==='source'&&value.items[0].id===state.source_catalog_id; + controls(); + } + function selectionAction(work){try{work();invalidate();summarize();}catch(failure){error(failure.message);}} + for(const [value,label] of Object.entries(families)){const option=document.createElement('option');option.value=value;option.textContent=label;$('selection-family').append(option);} + for(const value of canonicalFamilies){const option=document.createElement('option');option.value=value;option.textContent=value.replaceAll('_',' ');$('decision-family').append(option);} + $('selection-add').onclick=()=>selectionAction(()=>{const key=$('selection-family').value,values=$('selection-ids').value.split(/[\s,]+/u).filter(Boolean);if(!values.length||!values.every(shaRoots.has(key)?sha:id))throw Error('Enter exact '+(shaRoots.has(key)?'SHA256 digests':'32-character IDs')+' for this family.');draft.roots[key]=[...new Set([...(draft.roots[key]??[]),...values])].sort();if(draft.roots[key].length>10000)throw Error('Select at most 10,000 roots per family.');$('selection-ids').value='';}); + $('producer-add').onclick=()=>selectionAction(()=>{const value={kind:$('producer-kind').value,id:$('producer-id').value.trim()};if(!producerKinds.includes(value.kind)||!id(value.id))throw Error('Choose a known producer kind and exact job ID.');const list=draft.roots.producer_jobs??=[];if(!list.some(v=>v.kind===value.kind&&v.id===value.id))list.push(value);$('producer-id').value='';}); + $('working-add').onclick=()=>selectionAction(()=>{const value={kind:$('working-kind').value,path:$('working-path').value.trim()};if(!working(value))throw Error('Use an exact scripts/... or notes/... path without traversal.');const list=draft.roots.working_files??=[];if(!list.some(v=>v.kind===value.kind&&v.path===value.path))list.push(value);$('working-path').value='';}); + $('decision-add').onclick=()=>selectionAction(()=>{const value={family:$('decision-family').value,id:$('decision-id').value.trim(),include:$('decision-include').value==='include',reason:$('decision-reason').value};if(!canonicalFamilies.includes(value.family)||!text(value.id,128)||!text(value.reason,1000))throw Error('Enter a known report family, exact ID and reason.');draft.decisions=draft.decisions.filter(v=>!(v.family===value.family&&v.id===value.id));draft.decisions.push(value);$('decision-id').value='';$('decision-reason').value='';}); + $('selection-clear').onclick=()=>selectionAction(()=>{draft={roots:{},decisions:[]};}); + $('selection-file').onchange=async()=>{const ticket=epoch;try{const file=$('selection-file').files?.[0];if(!file)return;if(file.size>1048576)throw Error('Selection files are limited to one MiB.');const value=normalizeSelection(JSON.parse(await file.text()));if(ticket!==epoch){message('A superseded selection file was ignored. The current selection is unchanged.');return;}$('snapshot-id').value=value.project_snapshot.id;$('snapshot-sha').value=value.project_snapshot.manifest_sha256;draft={roots:value.roots,decisions:value.decisions};invalidate();summarize();message('Loaded a closed selection document. Review its friendly summary, then explicitly preview.');}catch(failure){error('Selection file refused: '+failure.message);}finally{$('selection-file').value='';}}; + for(const name of ['snapshot-id','snapshot-sha'])$(name).addEventListener('input',invalidate); + for(const name of ['report-reviewed','stop-confirm','extract-confirm'])$(name).addEventListener('change',controls); + $('transfer-refresh').onclick=refresh; + $('transfer-preview-form').onsubmit=event=>{event.preventDefault();return run(async ticket=>{const body=selection(),selectedRevision=revision;if(!await discover(ticket))return;const value=await call('preview',{binding:state.binding,selection:body},ticket);if(value&&selectedRevision===revision){adopt(value,{ownPreview:true});reviewed=true;dirty=false;onlyCessation=false;$('selection-dirty').textContent='This exact selection was previewed. Review the report before proceeding.';$('report-section').value='blockers';await loadPage(ticket,0,'blockers');message(state.phase==='source_live'?'Read-only preview complete. Review the closure before any explicit stop.':'Stopped-source preview complete. Review this exact report before extraction.');$('report-title').focus();controls();}});}; + $('report-load').onclick=()=>run(ticket=>loadPage(ticket));$('report-previous').onclick=()=>run(ticket=>loadPage(ticket,Math.max(0,(page?.offset??0)-40)));$('report-next').onclick=()=>run(ticket=>loadPage(ticket,(page?.offset??0)+(page?.items.length??0))); + $('report-section').addEventListener('change',()=>{page=null;controls();}); + $('transfer-stop-form').onsubmit=event=>{event.preventDefault();return run(async ticket=>{ + if(!reviewed||!onlyCessation||!$('report-reviewed').checked||!$('stop-confirm').checked)throw Error('Review this report and explicitly confirm source stop first.'); + const body={binding:state.binding,report_sha256:state.report.report_sha256,backup_root:$('backup-root').value,backup_directory:$('backup-directory').value.trim(),destination_root:$('destination-root').value,directory:$('destination-directory').value.trim()}; + if(!leaf(body.backup_directory)||!leaf(body.directory)||body.backup_root===body.destination_root&&body.backup_directory===body.directory)throw Error('Choose different new backup and destination directory names without separators.'); + handoff=true;freshControl=false;freshControlAttempt=false;reviewed=false;$('report-reviewed').checked=false;message('Explicit source stop is pending. No other effect is available.'); + try{const value=await call('stop',body,ticket,{mutation:true});if(value){adopt(value);message('Source stop returned. Sign in separately to the control host, then explicitly preview again.');$('control-title').focus();}} + finally{handoff=true;$('control-signin-panel').hidden=false;$('control-signin-status').textContent=`Separate control catalog: ${controlCatalog??'refresh controller status'}. Submit a new login code, then refresh status.`;window.dispatchEvent(new MessageEvent('message',{origin:location.origin,data:{type:'tuldok-session-changed'}}));} + });}; + $('control-signin').onclick=()=>{const button=$('session-open');if(button)button.click();else error('The standard session dialog is unavailable.');}; + const loginForm=$('session-form');if(loginForm)loginForm.addEventListener('submit',()=>{if(handoff||state?.phase!=='source_live')freshControlAttempt=true;},true); + const logout=$('session-logout');if(logout)logout.addEventListener('click',()=>{epoch++;contracts.clear();reviewed=false;onlyCessation=false;freshControl=false;freshControlAttempt=false;$('report-reviewed').checked=false;controls();message('Session sign-out requested. Local selection and retained receipts remain; sign in before continuing.');},true); + async function extract(ticket,body){ + pending=clone(body);$('transfer-pending').hidden=false;$('transfer-pending').textContent=`Retained request ${body.request_key} for ${body.destination_root} / ${body.directory}. No automatic retry will occur.`;controls(); + const value=await call('extract',body,ticket,{mutation:true});if(!value)return; + if(!exact(value,['format','binding','receipt','selection'])||value.format!=='project_transfer_extraction_result_v1'||value.binding!==state.binding||!receiptOK(value.receipt)||!exact(value.selection,['selected','state','error'])||typeof value.selection.selected!=='boolean')throw protocol('Unsupported extraction result. Retain its known receipt and refresh status.'); + keepReceipt(value.receipt);const selected=value.selection.selected,project=value.selection.state?.project; + if(selected&&(!project||project.project_id!==value.receipt.project_id||project.checkpoint!==value.receipt.checkpoint))throw protocol('Publication is recorded, but the selected project identity or checkpoint differs. Inspect the file workspace explicitly.'); + pending=null;reviewed=false;$('report-reviewed').checked=false;$('extract-confirm').checked=false;$('transfer-pending').hidden=true;$('selection-outcome').textContent=selected?'At acknowledgement, the verified destination was selected. The file workspace link opens the current inert metadata view.':'Publication is recorded. Selection was unavailable; inspect the file workspace and Open this directory explicitly.'; + message(value.receipt.replayed?'Exact extraction request reconciled against the current destination.':'Project published. No computation was started.');$('outcome-title').focus(); + } + $('transfer-extract-form').onsubmit=event=>{event.preventDefault();return run(async ticket=>{if(!signedControl()||!reviewed||!state?.report?.can_extract||!$('report-reviewed').checked||!$('extract-confirm').checked)throw Error('Sign in to the control host and review the stopped-source report before extraction.');const body={binding:state.binding,report_sha256:state.report.report_sha256,destination_root:state.destination?.root,directory:state.destination?.directory,name:$('destination-name').value,request_key:$('extraction-key').value.trim()};if(!text(body.name,200)||!text(body.request_key,128)||!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/.test(body.request_key))throw Error('Enter a project name and exact request key.');await extract(ticket,body);});}; + $('transfer-reconcile').onclick=()=>run(async ticket=>{if(!pending||!signedControl()||state?.report?.report_sha256!==pending.report_sha256)throw Error('Refresh current authorization and this exact report before an explicit reconciliation.');await extract(ticket,{...pending,binding:state.binding});}); + window.addEventListener('tuldok-session',()=>{epoch++;contracts.clear();reviewed=false;onlyCessation=false;$('report-reviewed').checked=false;$('report-items').replaceChildren();if(freshControlAttempt&&window.TuldokAuth.identity?.()?.catalog_id===controlCatalog){freshControl=true;handoff=false;message('Fresh control-host sign-in verified. Explicitly preview the stopped source again.');}controls();if(busy)refreshAfter=true;else void refresh();}); + window.addEventListener('beforeunload',event=>{if(dirty||pending){event.preventDefault();event.returnValue='';}}); + $('extraction-key').value=crypto.randomUUID().replaceAll('-','');summarize();controls();void refresh(); +})(); diff --git a/synthetic.py b/synthetic.py index 33d552ab..50f1c781 100644 --- a/synthetic.py +++ b/synthetic.py @@ -38,9 +38,47 @@ def saved_dimensions(value): return dict(value, **dimensions) -def prompt_batch(config, offset, count, previous): - """Bound context independently of dataset size; uniqueness is checked locally.""" - payload = { +def validate_saved_queue(job, entries): + """Pure retained geometry policy shared by live recovery and inert history.""" + config = saved_dimensions(job['config']) + normalized = [saved_dimensions(entry) for entry in entries] + if any((entry['width'], entry['height']) != (config['width'], config['height']) for entry in normalized): + raise ValueError('Saved queue-entry dimensions conflict with the job configuration.') + return config, normalized + + +def validate_retained_attempt(job, entry, evidence): + """Validate a retained typed request against its original queue intent. + + Capability observations are already decoded values. This policy consumes + those historical values without discovering a server or admitting work. + """ + config = job['config'] + if not pumas.typed(config): + raise ValueError('Typed image evidence requires a typed image job.') + observed = evidence['capability_observation'] + checked = pumas.validate_manifest(json.dumps(observed['capabilities']).encode()) + if checked['capabilities']['model'] != config['model']: + raise ValueError('Retained image capability belongs to another model.') + options = {'width': entry['width'], 'height': entry['height']} + if entry['requested_seed'] is not None: + options['seed'] = entry['requested_seed'] + pumas.require(observed, 'image_generation', 'text', 'png_base64', options) + request = {'contract_version': 1, 'request_id': entry['request_id'], 'model': config['model'], + 'profile': observed['capabilities']['profile'], 'capability': 'image_generation', + 'input': {'kind': 'text', 'text': entry['prompt']}, 'output': 'png_base64', + 'options': dict(kind='image_generation', **options), 'stream': False} + retained = dict(capability_observation=observed) + pumas.request_evidence(retained, request) + if (evidence.get('canonical_request') != request + or evidence.get('canonical_request_sha256') != retained['canonical_request_sha256']): + raise ValueError('Retained image request differs from its queue intent.') + return request + + +def prompt_payload(config, offset, count, previous, variation_key): + """Pure bounded prompt request policy, shared by live capture and decoding.""" + return { 'model': config['prompt_model'], 'stream': False, 'max_tokens': 6000, 'response_format': {'type': 'json_object'}, 'messages': [ @@ -53,13 +91,54 @@ def prompt_batch(config, offset, count, previous): {'role': 'user', 'content': json.dumps({ 'brief': config['prompt'], 'total_images': config['count'], 'first_image_number': offset + 1, 'number_of_prompts': count, - 'batch_variation_key': uuid.uuid4().hex, + 'batch_variation_key': variation_key, 'recent_prompts_to_avoid': previous[-BATCH_SIZE:], })}, ], } + + +def decode_prompt_batch(data, count): + try: + choice = json.loads(data)['choices'][0] + if choice.get('finish_reason') != 'stop': + raise ValueError() + prompts = json.loads(choice['message']['content'])['prompts'] + if not isinstance(prompts, list) or len(prompts) != count or any( + not isinstance(p, str) or not p.strip() or len(p) > 4000 for p in prompts): + raise ValueError() + return [p.strip() for p in prompts] + except (ValueError, KeyError, IndexError, TypeError): + raise ValueError('Prompt model must return the requested number of complete JSON prompts. Try a smaller brief or a model with more output capacity.') from None + + + +def validate_retained_prompt_batch(config, batch, request, raw=None): + """Bind exact retained variation wire to its queue, without HTTP authority.""" + brief = json.loads(request['messages'][1]['content']) + previous = brief['recent_prompts_to_avoid'] + if (type(previous) is not list or len(previous)>BATCH_SIZE + or any(type(p) is not str or not p.strip() or len(p)>4000 for p in previous)): + raise ValueError('Invalid retained prompt context.') + if request != prompt_payload(config,batch['offset'],batch['count'],previous,batch['id']): + raise ValueError('Retained prompt request differs from its queue intent.') + if raw is not None: + try:decoded=decode_prompt_batch(raw,batch['count']) + except ValueError: + if batch['status']!='failed':raise + else: + if batch['status']!='completed' or decoded!=batch['prompts']: + raise ValueError('Retained prompt result differs from its exact wire.') + + +def prompt_batch(config, offset, count, previous, *, cancel_event=None, + on_request=None, on_response=None): + """Bound context independently of dataset size; publish before HTTP/entries.""" + payload = prompt_payload(config,offset,count,previous,uuid.uuid4().hex) + if on_request is not None:on_request(payload) + with ai_http.request(config['prompt_url'], '/v1/chat/completions', payload, - timeout=180, label='Pumas prompt model') as (response, transport, deadline): + timeout=180, label='Pumas prompt model', cancel_event=cancel_event) as (response, transport, deadline): data = bytearray() while not response.isclosed(): transport.settimeout(max(.001, deadline - time.monotonic())) @@ -72,16 +151,12 @@ def prompt_batch(config, offset, count, previous): if len(data) > 256 * 1024: raise ValueError('Prompt model returned an oversized response.') try: - choice = json.loads(data)['choices'][0] - if choice.get('finish_reason') != 'stop': - raise ValueError() - prompts = json.loads(choice['message']['content'])['prompts'] - if not isinstance(prompts, list) or len(prompts) != count or any( - not isinstance(p, str) or not p.strip() or len(p) > 4000 for p in prompts): - raise ValueError() - return [p.strip() for p in prompts] - except (ValueError, KeyError, IndexError, TypeError): - raise ValueError('Prompt model must return the requested number of complete JSON prompts. Try a smaller brief or a model with more output capacity.') from None + prompts=decode_prompt_batch(data,count) + except ValueError as error: + if on_response is not None:on_response(payload,bytes(data),None,str(error)) + raise + if on_response is not None:on_response(payload,bytes(data),prompts,'') + return prompts def validate(body): @@ -145,6 +220,10 @@ def _jobs(self): def _save(self, job): self.db.execute('INSERT INTO generation_jobs (id, data) VALUES (%s,%s) ON CONFLICT(id) DO UPDATE SET data=excluded.data', (job['id'], json.dumps(job))) + execution = getattr(self.dataset, 'project_execution', None) + if execution is not None: + execution.mark_generation(job['id']) + def _entries(self, job_id): return [json.loads(r[0]) for r in self.db.execute('SELECT data FROM generation_entries WHERE job_id=%s ORDER BY ordinal', (job_id,))] @@ -152,12 +231,13 @@ def _entry(self, entry): self.db.execute('INSERT INTO generation_entries (id, job_id, ordinal, data) VALUES (%s,%s,%s,%s) ON CONFLICT(id) DO UPDATE SET data=excluded.data', (entry['id'], entry['job_id'], entry['ordinal'], json.dumps(entry))) + execution = getattr(self.dataset, 'project_execution', None) + if execution is not None: + execution.mark_generation(entry['job_id']) + def _normalize_saved(self, job): - config = saved_dimensions(job['config']) entries = self._entries(job['id']) - normalized = [saved_dimensions(entry) for entry in entries] - if any((entry['width'], entry['height']) != (config['width'], config['height']) for entry in normalized): - raise ValueError('Saved queue-entry dimensions conflict with the job configuration.') + config, normalized = validate_saved_queue(job, entries) # Decode the whole queue before writing, keeping migration atomic under # the caller's lock/transaction. Retain size and all original provenance. if config != job['config']: @@ -182,7 +262,7 @@ def restore_progress(): self.db.on_rollback(restore_progress) entry.update(status='completed', sample_id=sample_id, metadata=metadata) - if pumas.typed(job['config']): entry['provider_outcome'] = 'result_received' + if pumas.typed(job['config']) or getattr(self.dataset,'project_execution',None) is not None: entry['provider_outcome'] = 'result_received' self._entry(entry) job['completed'] += 1 self._save(job) @@ -194,21 +274,26 @@ def snapshot(self): @authorized('execute', 'synthetic.start', owner_only=True) def start(self, body): - # Validate everything before starting billable work or persisting a job. - from app import metadata - config, meta = validate(body), metadata(body) with self.lock: if self.db.in_transaction: raise ValueError('Generation dispatch requires its own committed transaction.') with self.db.transaction(): - self._idle() - job = dict(id=uuid.uuid4().hex, config=config, meta=meta, status='preparing', error='', completed=0) - self._admit(job, 'synthetic.start') - self._save(job) - # Dispatch only after durable admission, including a successful commit. - self._launch(job) + job, launch = self._prepare_start(body) + if launch: + self._launch(job) return job + def _prepare_start(self, body): + if not self.db.in_transaction: + raise RuntimeError('Producer preparation requires an owned transaction.') + from app import metadata + config, meta = validate(body), metadata(body) + self._idle() + job = dict(id=uuid.uuid4().hex, config=config, meta=meta, status='preparing', error='', completed=0) + self._admit(job, 'synthetic.start') + self._save(job) + return job, True + def _idle(self): if not self.runtime_owner: raise ValueError('Generation workers belong to another open application. Use that application or reopen after it closes.') @@ -223,19 +308,26 @@ def resume(self, job_id): if self.db.in_transaction: raise ValueError('Generation dispatch requires its own committed transaction.') with self.db.transaction(): - self._idle() - job = next((j for j in self._jobs() if j['id'] == job_id), None) - if not job or job['status'] == 'completed': - raise ValueError('Choose an unfinished generation job.') - job = self._normalize_saved(job) - if pumas.typed(job['config']) and any(e.get('provider_outcome') == 'unknown' for e in self._entries(job_id)): - raise ValueError('An image attempt has an unknown provider outcome. Inspect it; this queue cannot replay it.') - job.update(status='preparing', error='') - self._admit(job, 'synthetic.resume') - self._save(job) - self._launch(job) + job, launch = self._prepare_resume(job_id) + if launch: + self._launch(job) return job + def _prepare_resume(self, job_id): + if not self.db.in_transaction: + raise RuntimeError('Producer preparation requires an owned transaction.') + self._idle() + job = next((j for j in self._jobs() if j['id'] == job_id), None) + if not job or job['status'] == 'completed': + raise ValueError('Choose an unfinished generation job.') + job = self._normalize_saved(job) + if (pumas.typed(job['config']) or getattr(self.dataset,'project_execution',None) is not None) and any(e.get('provider_outcome') == 'unknown' for e in self._entries(job_id)): + raise ValueError('An image attempt has an unknown provider outcome. Inspect it; this queue cannot replay it.') + job.update(status='preparing', error='') + self._admit(job, 'synthetic.resume') + self._save(job) + return job, True + @staticmethod def _request_digest(job): return hashlib.sha256(json.dumps({'config': job['config'], 'meta': job['meta']}, @@ -261,18 +353,35 @@ def _launch(self, job): @authorized('cancel', 'synthetic.cancel') def cancel(self, job_id): with self.lock, self.db.transaction(): - if not self.runtime_owner: - raise ValueError('Generation workers belong to another open application. Cancel in that application.') - job = next((j for j in self._jobs() if j['id'] == job_id), None) - if not job or job['status'] not in ACTIVE: - return {'cancelled': False} - self.stop.set() - job['status'] = 'stopping' - self._save(job) - self.access.audit('synthetic.cancel', job_id) - if self.request_id: - self.dataset.image_requests.cancel(self.request_id) - return {'cancelled': True} + result, pending = self._prepare_cancel(job_id) + if pending is not None and self._signal_cancel(pending): + self._cancel_transport(pending) + return result + + def _prepare_cancel(self, job_id): + """Persist intent only; project dispatch signals after canonical ACK.""" + if not self.db.in_transaction: + raise RuntimeError('Producer cancellation preparation requires an owned transaction.') + if not self.runtime_owner: + raise ValueError('Generation workers belong to another open application. Cancel in that application.') + job = next((j for j in self._jobs() if j['id'] == job_id), None) + if not job or job['status'] not in ACTIVE: + return {'cancelled': False}, None + pending = (self.worker, job_id, self.request_id) + job['status'] = 'stopping' + self._save(job) + self.access.audit('synthetic.cancel', job_id) + return {'cancelled': True}, pending + + def _signal_cancel(self, pending): + if self.worker is not pending[0]: + return False + self.stop.set() + return True + + def _cancel_transport(self, pending): + if pending[2]: + self.dataset.image_requests.cancel(pending[2]) def close(self): self.stop.set() @@ -307,7 +416,28 @@ def _run_admitted(self, job): while len(entries) < config['count']: self._check() count = min(BATCH_SIZE, config['count'] - len(entries)) - prompts = prompt_batch(config, len(entries), count, [e['prompt'] for e in entries[-BATCH_SIZE:]]) + def retain_request(payload): + if getattr(self.dataset,'project_execution',None) is None:return + brief=json.loads(payload['messages'][1]['content']) + batch=dict(id=brief['batch_variation_key'],offset=len(entries),count=count,status='requesting',error='', + request_sha256=hashlib.sha256(json.dumps(payload,ensure_ascii=False,sort_keys=True,separators=(',',':'),allow_nan=False).encode()).hexdigest(), + response_sha256=None,prompts=None,canonical_request=payload,raw_response_base64=None) + with self.lock,self.db.transaction(): + job.setdefault('prompt_batches',[]).append(batch) + self._save(job) + def retain_response(payload,raw,prompts,error): + if getattr(self.dataset,'project_execution',None) is None:return + import base64 + with self.lock,self.db.transaction(): + batch=job['prompt_batches'][-1] + batch.update(status='failed' if error else 'completed',error=error,prompts=prompts, + raw_response_base64=base64.b64encode(raw).decode('ascii'),response_sha256=hashlib.sha256(raw).hexdigest()) + self._save(job) + if getattr(self.dataset,'project_execution',None) is None: + prompts = prompt_batch(config,len(entries),count,[e['prompt'] for e in entries[-BATCH_SIZE:]]) + else: + prompts = prompt_batch(config,len(entries),count,[e['prompt'] for e in entries[-BATCH_SIZE:]], + cancel_event=self.stop,on_request=retain_request,on_response=retain_response) self._check() added = [] for prompt in prompts: @@ -338,7 +468,7 @@ def _run_admitted(self, job): entry.update(status='generating', error='') with self.lock, self.db.transaction(): self.request_id = uuid.uuid4().hex - if pumas.typed(config): + if pumas.typed(config) or getattr(self.dataset,'project_execution',None) is not None: entry.update(request_id=self.request_id, provider_outcome='unknown') self._entry(entry) body = {key: config[key] for key in ('server_url', 'model', 'width', 'height')} @@ -348,6 +478,7 @@ def _run_admitted(self, job): body['seed'] = (config['seed'] + ordinal) % 4294967296 self._check() def retain_admission(evidence): + validate_retained_attempt(job, entry, evidence) with self.lock, self.db.transaction(): if self.stop.is_set(): raise pumas.OperationError('Cancelled before image provider admission.') diff --git a/tests/browser_project_file_deletion.cjs b/tests/browser_project_file_deletion.cjs new file mode 100644 index 00000000..ab811e53 --- /dev/null +++ b/tests/browser_project_file_deletion.cjs @@ -0,0 +1,127 @@ +'use strict'; +// Actual explicit note deletion, restore, independent fork and cold keyboard review. +const assert=require('node:assert/strict'),fs=require('node:fs'),os=require('node:os'),path=require('node:path'),crypto=require('node:crypto'); +const {spawn,execFileSync}=require('node:child_process'),{once}=require('node:events'); +const {authenticatedFixture}=require('./auth_helpers.cjs'),{waitForDebugger}=require('./browser_startup.cjs'),{cdpSession}=require('./browser_cdp_session.cjs'),{qaDirectory,screenshotOptions}=require('./qa_artifacts.cjs'); +const root=path.resolve(__dirname,'..'),hash=raw=>crypto.createHash('sha256').update(raw).digest('hex'); +assert.equal(process.env.TULDOK_NOTE_DELETION_GO,'1','Explicit note-deletion qualification GO required'); +assert.ok(process.env.TULDOK_DATABASE_URL&&process.env.TULDOK_PROJECT_FILES_COLD_DATABASE_URL,'Disposable source and cold PostgreSQL configurations required'); +assert.ok(process.env.TULDOK_M4_FREEZE_MANIFEST,'Lead must freeze and enable M4 before actual browser acceptance'); +const freeze=JSON.parse(fs.readFileSync(process.env.TULDOK_M4_FREEZE_MANIFEST,'utf8')); +const material={manifest_sha256:hash(fs.readFileSync(process.env.TULDOK_M4_FREEZE_MANIFEST)),files:freeze.files}; +function verifySource(){for(const [file,expected] of Object.entries(material.files))assert.equal(hash(fs.readFileSync(path.join(root,file))),expected,'Frozen source changed: '+file);} +verifySource(); +const testSha=hash(fs.readFileSync(__filename)),temporary=fs.mkdtempSync(path.join(os.tmpdir(),'tuldok-note-deletion-browser-')),report=qaDirectory(root,'project-note-deletion'); +// HTML textarea value normalizes CR/CRLF; portable version bytes stay exact. +const displayed=raw=>raw.toString().replace(/\r\n?/g,'\n'); +const sourceDsn=process.env.TULDOK_DATABASE_URL,coldDsn=process.env.TULDOK_PROJECT_FILES_COLD_DATABASE_URL; +assert.notEqual(sourceDsn,coldDsn); +const external=path.join(temporary,'external');fs.mkdirSync(external); +const children=[],catalogs=[],checks=[],errors=[],httpErrors=[],bounds=[]; +const gitEnv={...process.env,GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:'/dev/null',GIT_TERMINAL_PROMPT:'0'}; +const routes=JSON.parse(execFileSync('python3',['-c',"import json,agent_api;names=agent_api.ProjectFilesOwnerClient.OPERATIONS;print(json.dumps({n.split('.')[1]:{**{k:agent_api.operation(n)[k] for k in ('path','method','query','side_effects')},'header':agent_api.selected_header(n)} for n in names}))"],{cwd:root,encoding:'utf8'})); +let server,origin,auth,socket,session,send,evaluate,serverErrors='',isolation; +const pause=ms=>new Promise(resolve=>setTimeout(resolve,ms)); +async function until(fn,label){for(let i=0;i<200;i++){const result=await fn();if(result)return result;await pause(100);}throw Error('Timed out: '+label);} +function launch(command,args,options={}){const child=spawn(command,args,options);children.push(child);return child;} +async function stop(child){if(!child||child.exitCode!==null||child.signalCode!==null)return;const exited=once(child,'exit');child.kill('SIGTERM');await exited;} +async function start(control,directory,dsn){ + process.env.TULDOK_DATABASE_URL=dsn;serverErrors='';let output=''; + server=launch('python3',['-u','app.py','--port','0','--data',control,'--project-files-root','fixture='+directory],{cwd:root,env:{...process.env,TULDOK_DATABASE_URL:dsn},stdio:['ignore','pipe','pipe']}); + server.stdout.on('data',chunk=>output+=chunk);server.stderr.on('data',chunk=>serverErrors+=chunk); + const port=await until(()=>{if(server.exitCode!==null)throw Error('Server exited: '+serverErrors);return output.match(/127\.0\.0\.1:(\d+)/)?.[1];},'actual server startup'); + origin='http://127.0.0.1:'+port;catalogs.push({control,dsn});auth=authenticatedFixture();await auth.register(origin,control,root); +} +async function state(){const response=await auth.fetch(origin+routes.status.path,{headers:{'Tuldok-Operation-Contract':routes.status.header}});const value=await response.json();assert.equal(response.status,200,JSON.stringify(value));return value;} +async function call(action,fields={}){ + const current=await state(),definition=routes[action],arguments_={binding:current.binding,[definition.side_effects?'expected_checkpoint':'checkpoint']:current.project.checkpoint,...(definition.side_effects?{request_key:crypto.randomUUID().replaceAll('-','')} :{}),...fields}; + let target=definition.path;for(const [key,value] of Object.entries(arguments_))target=target.replace('{'+key+'}',encodeURIComponent(value)); + const query=new URLSearchParams(Object.entries(arguments_).filter(([key])=>definition.query.includes(key)));if(query.size)target+='?'+query; + const body=Object.fromEntries(Object.entries(arguments_).filter(([key])=>!definition.query.includes(key)&&!definition.path.includes('{'+key+'}'))); + const response=await auth.fetch(origin+target,{method:definition.method,headers:{'Content-Type':'application/json','Tuldok-Operation-Contract':definition.header},...(definition.method==='POST'?{body:JSON.stringify(body)}:{})}); + const value=await response.json();assert.equal(response.status,200,JSON.stringify(value));return value.value; +} +async function key(key,code=key,modifiers=0){for(const type of ['keyDown','keyUp'])await send('Input.dispatchKeyEvent',{type,key,code,modifiers,windowsVirtualKeyCode:{Enter:13,Tab:9,a:65}[key]||0,...(type==='keyDown'&&key==='Enter'?{text:'\r',unmodifiedText:'\r'}:{})});} +async function focus(selector){await until(()=>evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e||e.disabled||!e.getClientRects().length)return false;e.scrollIntoView({block:'center'});e.focus();return document.activeElement===e;})()`),'focus '+selector);} +async function fill(selector,value){await focus(selector);await key('a','KeyA',2);await send('Input.insertText',{text:value});} +async function activate(selector){await focus(selector);await key('Enter');} +async function choose(selector,value){await focus(selector);await evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!Array.from(e.options).some(o=>o.value===${JSON.stringify(value)}))throw Error('Missing option');e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('change',{bubbles:true}));e.dispatchEvent(new Event('input',{bubbles:true}));})()`);} +async function settled(){await until(async()=>{const failure=await evaluate('document.getElementById("project-files-error").textContent');if(failure)throw Error('UI error: '+failure);return evaluate('!document.getElementById("project-files-save").disabled');},'acknowledged UI state');} +async function navigate(){await auth.browser(send,origin);await send('Page.navigate',{url:origin+'/project-files'});await until(()=>evaluate('!!document.querySelector("#project-files-root option")&&!document.getElementById("project-files-new").disabled'),'negotiated UI startup');} +async function working(kind,file,raw,revision=0){ + await choose('#project-working-kind',kind);await fill('#project-working-path',file);await fill('#project-working-revision',String(revision));await fill('#project-working-sha',hash(raw)); + await activate('#project-working-adopt-form button[type="submit"]');await settled(); + assert.equal(await evaluate('document.getElementById("project-working-text").value'),displayed(raw)); + const observed=(await call('files_inspect')).items.find(item=>item.path===file);assert.ok(observed);const captured=await call('file_get',{file_id:observed.id,revision:observed.revision});assert.deepEqual(Buffer.from(captured.version.text),raw,'Captured public revision retains exact bytes despite native textarea newline rendering'); +} +async function inspectFile(file){ + await activate('#project-working-load');await settled(); + await evaluate(`(()=>{const b=Array.from(document.querySelectorAll('#project-working-list button')).find(e=>e.textContent.startsWith(${JSON.stringify(file+' ·')}));if(!b)throw Error('Missing working file');b.dataset.qaSelected='yes';})()`); + await activate('#project-working-list button[data-qa-selected="yes"]');await settled(); +} +async function capture(width){ + await send('Emulation.setDeviceMetricsOverride',{width,height:950,deviceScaleFactor:1,mobile:false});await evaluate('document.getElementById("project-working-heading").scrollIntoView({block:"start"})'); + await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); + const value=await evaluate('({viewport:innerWidth,width:document.documentElement.scrollWidth,client:document.documentElement.clientWidth,offenders:Array.from(document.querySelectorAll("body *")).filter(e=>e.getClientRects().length&&e.getBoundingClientRect().right>innerWidth+1).map(e=>({tag:e.tagName,id:e.id,width:e.getBoundingClientRect().width,right:e.getBoundingClientRect().right,text:e.textContent.slice(0,100)})).slice(-20)})'); + bounds.push(value);fs.writeFileSync(path.join(report,'bounds-'+width+'.json'),JSON.stringify(value,null,2)+'\n');fs.writeFileSync(path.join(report,'note-cold-'+width+'.jpg'),Buffer.from((await send('Page.captureScreenshot',screenshotOptions)).data,'base64')); + assert.ok(value.width<=value.viewport+1,'Responsive overflow: '+JSON.stringify(value)); +} +function snapshot(directory){const files={};function walk(at){for(const item of fs.readdirSync(at,{withFileTypes:true})){if(['.git','.tuldok-local'].includes(item.name))continue;const next=path.join(at,item.name);if(item.isDirectory())walk(next);else if(item.isFile())files[path.relative(directory,next)]=hash(fs.readFileSync(next));else throw Error('Unexpected special fixture file');}}walk(directory);return files;} +function check(value){checks.push(value);console.log('PASS '+value);} +async function inspectIntent(){ + await activate('#project-intents-load');await settled();await until(()=>evaluate('document.querySelectorAll("#project-intents-list button").length===1'),'one unresolved recipe'); + await activate('#project-intents-list button');await settled(); + return JSON.parse(await evaluate('document.getElementById("project-intent-detail").textContent')); +} +(async()=>{ + isolation=JSON.parse(execFileSync('python3',['-c',"import json,os,psycopg;from psycopg.conninfo import conninfo_to_dict;s=conninfo_to_dict(os.environ['TULDOK_DATABASE_URL']);c=psycopg.connect(os.environ['TULDOK_PROJECT_FILES_COLD_DATABASE_URL']);row=c.execute('SELECT current_database(),current_user,has_database_privilege(current_user,%s,%s)',(s['dbname'],'CONNECT')).fetchone();assert row[0]!=s['dbname'] and not row[2];print(json.dumps(dict(database=row[0],role=row[1],source_connect=row[2])));c.close()"],{cwd:root,encoding:'utf8'})); + await start(path.join(temporary,'control'),external,sourceDsn); + const browser=launch(process.env.BROWSER||'/usr/bin/chromium',['--headless','--no-sandbox','--disable-gpu','--no-first-run','--remote-debugging-port=0','--window-size=1440,950','--user-data-dir='+path.join(temporary,'browser'),'about:blank'],{stdio:'ignore',env:{...process.env,XDG_CONFIG_HOME:temporary,XDG_CACHE_HOME:temporary}}); + const port=await waitForDebugger(browser,path.join(temporary,'browser','DevToolsActivePort')),tabs=await(await fetch('http://127.0.0.1:'+port+'/json')).json(),target=tabs.find(tab=>tab.type==='page'&&tab.url==='about:blank');assert.ok(target); + socket=new WebSocket(target.webSocketDebuggerUrl);await once(socket,'open');session=cdpSession(socket,{onEvent:event=>{if(event.method==='Runtime.exceptionThrown')errors.push(event.params.exceptionDetails);if(event.method==='Network.responseReceived'&&event.params.response.status>=400)httpErrors.push({url:event.params.response.url,status:event.params.response.status});}});send=session.send; + evaluate=async expression=>{const result=await send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(result.exceptionDetails)throw Error(JSON.stringify(result.exceptionDetails));return result.result.value;}; + await send('Page.enable');await send('Runtime.enable');await send('Network.enable');await navigate(); + await fill('#project-files-directory','alpha');await fill('#project-files-name','Note deletion keyboard Ω');await activate('#project-files-new');await settled(); + const originalState=await state(),originalProject=originalState.project.project_id,source=path.join(external,'alpha'),note=path.join(source,'notes/scientist.md'); + fs.mkdirSync(path.join(source,'notes')); + const first=Buffer.from('Exact scientist rationale Ω 山\r\n\n'),restored=Buffer.from('Explicit restored decision 🌸\n'); + fs.writeFileSync(note,first);fs.writeFileSync(path.join(source,'notes/empty.md'),''); + await working('note','notes/scientist.md',first);await working('note','notes/empty.md',Buffer.alloc(0)); + await inspectFile('notes/empty.md');assert.match(await evaluate('document.getElementById("project-working-summary").textContent'),/empty file/);assert.doesNotMatch(await evaluate('document.getElementById("project-working-summary").textContent'),/deletion/); + fs.unlinkSync(note);await inspectFile('notes/scientist.md'); + assert.equal(await evaluate('document.getElementById("project-working-text").value'),displayed(first));assert.equal(await evaluate('document.getElementById("project-working-delete-fields").disabled'),false); + const before=(await state()).project.checkpoint;await activate('#project-working-delete');await pause(100);assert.equal((await state()).project.checkpoint,before,'Unchecked deletion must have no effect'); + await focus('#project-working-delete-confirm');await key(' ','Space');assert.equal(await evaluate('document.getElementById("project-working-delete-confirm").checked'),true); + await activate('#project-working-delete');await settled();assert.ok(!fs.existsSync(note));assert.match(await evaluate('document.getElementById("project-working-summary").textContent'),/note deletion retained/);assert.equal(await evaluate('document.getElementById("project-working-text").value'),''); + await activate('#project-working-history');await settled();assert.equal(await evaluate('document.querySelectorAll("#project-working-history-list button").length'),2);assert.match(await evaluate('document.querySelectorAll("#project-working-history-list button")[1].textContent'),/note deletion/); + await activate('#project-working-history-list button');await settled();assert.equal(await evaluate('document.getElementById("project-working-text").value'),displayed(first));assert.equal(await evaluate('typeof globalThis.noteDeletionSentinel'),'undefined'); + check('Actual keyboard adoption distinguishes empty note and explicit missing-note tombstone; no unchecked effect; original scientific text remains selectable and inert'); + fs.writeFileSync(note,restored);await inspectFile('notes/scientist.md');assert.equal(await evaluate('document.getElementById("project-working-revision").value'),'2');assert.equal(await evaluate('document.getElementById("project-working-sha").value'),hash(restored)); + assert.equal(await evaluate('document.getElementById("project-working-delete-fields").disabled'),true); + await activate('#project-working-adopt-form button[type="submit"]');await settled();assert.equal(await evaluate('document.getElementById("project-working-text").value'),displayed(restored)); + fs.unlinkSync(note);await inspectFile('notes/scientist.md');await focus('#project-working-delete-confirm');await key(' ','Space');await activate('#project-working-delete');await settled(); + assert.match(await evaluate('document.getElementById("project-working-summary").textContent'),/revision 4.*deletion/); + await capture(390);await capture(1440); + const sourceBefore=snapshot(source);await fill('#project-save-as-directory','independent');await fill('#project-save-as-name','Independent deleted note Ω');await activate('#project-files-save-as');await settled(); + const forkState=await state();assert.notEqual(forkState.project.project_id,originalProject);assert.deepEqual(snapshot(source),sourceBefore); + const fork=path.join(external,'independent');assert.ok(!fs.existsSync(path.join(fork,'notes/scientist.md')));await inspectFile('notes/scientist.md');assert.match(await evaluate('document.getElementById("project-working-summary").textContent'),/revision 4.*deletion/); + check('Actual named restore appends positive revision3; second explicit deletion appends revision4; independent Save As retains absence without rewriting source'); + await activate('#project-files-close');await until(async()=>(await state()).state==='no_project_open','close before cold copy');await stop(server); + const coldRoot=path.join(temporary,'cold-external');fs.mkdirSync(coldRoot);const clone=path.join(coldRoot,'clone'),canonical=snapshot(fork); + const git=(...args)=>execFileSync('git',args,{env:gitEnv,stdio:['ignore','pipe','pipe']});git('init','-q','--template=',fork);git('-C',fork,'add','.');git('-C',fork,'-c','user.name=Disposable note QA','-c','user.email=qa@example.invalid','-c','commit.gpgsign=false','-c','core.hooksPath=/dev/null','commit','-qm','Retained explicit note deletion');git('-c','core.hooksPath=/dev/null','clone','-q','--no-hardlinks','--template=',fork,clone);if(fs.existsSync(path.join(fork,'artifacts')))fs.cpSync(path.join(fork,'artifacts'),path.join(clone,'artifacts'),{recursive:true});assert.deepEqual(snapshot(clone),canonical);fs.rmSync(external,{recursive:true}); + await start(path.join(temporary,'cold-control'),coldRoot,coldDsn);await navigate();await fill('#project-files-directory','clone');await activate('#project-files-open');await settled();assert.deepEqual((await state()).project,forkState.project); + await inspectFile('notes/scientist.md');assert.match(await evaluate('document.getElementById("project-working-summary").textContent'),/revision 4.*deletion/);assert.ok(!fs.existsSync(path.join(clone,'notes/scientist.md'))); + await activate('#project-working-history');await settled();assert.equal(await evaluate('document.querySelectorAll("#project-working-history-list button").length'),4); + await activate('#project-working-history-list button');await settled();assert.equal(await evaluate('document.getElementById("project-working-text").value'),displayed(first)); + await activate('#project-working-history-list li:nth-child(3) button');await settled();assert.equal(await evaluate('document.getElementById("project-working-text").value'),displayed(restored)); + await inspectFile('notes/empty.md');assert.match(await evaluate('document.getElementById("project-working-summary").textContent'),/empty file/); + await capture(390);await capture(1440);assert.deepEqual(snapshot(clone),canonical);assert.equal(await evaluate('typeof globalThis.noteDeletionSentinel'),'undefined');assert.deepEqual(errors,[]);assert.deepEqual(httpErrors,[]);verifySource();assert.equal(hash(fs.readFileSync(__filename)),testSha); + check('Cold Git clone on separate database role denied source CONNECT retains exact fork/tombstone/four revisions after source removal; 390/1440 keyboard UI stays responsive and inert'); + fs.writeFileSync(path.join(report,'receipt.json'),JSON.stringify({status:'pass',checks,material,test_sha256:testSha,isolation,bounds,project:forkState.project,source_project:originalProject,browser_runtime_errors:errors,http_errors:httpErrors,canonical_sha256:canonical,fixture:'Actual named keyboard note adoption/deletion/restore; local Git clone; cold distinct DB denied source CONNECT; no runtime/preflight/Run'},null,2)+'\n');console.log('Report '+path.join(report,'receipt.json')); +})().catch(async error=>{ + fs.writeFileSync(path.join(report,'failure-receipt.json'),JSON.stringify({status:'failed',error:error.message,checks,material,test_sha256:testSha,isolation,bounds,browser_runtime_errors:errors,http_errors:httpErrors},null,2)+'\n');console.error(error);console.error('Server:',serverErrors);if(evaluate)try{console.error('UI:',await evaluate('document.body.innerText'));}catch(_){}process.exitCode=1; +}).finally(async()=>{ + session?.close();socket?.close();for(const child of children.reverse())await stop(child); + for(const {control,dsn} of catalogs)try{execFileSync('python3',['-c',"import json,pathlib,os,psycopg;from psycopg import sql;root=pathlib.Path(os.environ['QA_CONTROL']);c=psycopg.connect(os.environ['TULDOK_DATABASE_URL'],autocommit=True);[(c.execute(sql.SQL('DROP SCHEMA IF EXISTS {} CASCADE').format(sql.Identifier('projection_'+v['projection_id'] if v['version']==2 else 'catalog_'+v['dataset_id'])))) for p in root.rglob('.tuldok.json') for v in [json.loads(p.read_text())]];c.close()"],{cwd:root,env:{...process.env,TULDOK_DATABASE_URL:dsn,QA_CONTROL:control},stdio:['ignore','pipe','pipe']});}catch(error){console.error('Owned cleanup failed:',error.message);process.exitCode=1;} + process.env.TULDOK_DATABASE_URL=sourceDsn;fs.rmSync(temporary,{recursive:true,force:true}); +}); diff --git a/tests/browser_project_files.cjs b/tests/browser_project_files.cjs new file mode 100644 index 00000000..19e854a1 --- /dev/null +++ b/tests/browser_project_files.cjs @@ -0,0 +1,187 @@ +'use strict'; +// Real browser authoring, keyboard actions and a cold process on another database. +// Inputs and runtime roots are disposable; project scripts are inert sentinels. +const assert=require('node:assert/strict'); +const fs=require('node:fs'),os=require('node:os'),path=require('node:path'),crypto=require('node:crypto'); +const {spawn,execFileSync}=require('node:child_process'); +const {once}=require('node:events'); +const {authenticatedFixture}=require('./auth_helpers.cjs'); +const {waitForDebugger}=require('./browser_startup.cjs'); +const {cdpSession}=require('./browser_cdp_session.cjs'); +const {qaDirectory,screenshotOptions}=require('./qa_artifacts.cjs'); +const root=path.resolve(__dirname,'..'); +assert.ok(process.env.TULDOK_DATABASE_URL,'Use a disposable PostgreSQL TULDOK_DATABASE_URL'); +assert.ok(process.env.TULDOK_PROJECT_FILES_COLD_DATABASE_URL,'Cold proof requires a separate disposable database URL'); +const sourceDsn=process.env.TULDOK_DATABASE_URL,coldDsn=process.env.TULDOK_PROJECT_FILES_COLD_DATABASE_URL; +const gitOptions={env:{...process.env,GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:'/dev/null',GIT_TERMINAL_PROMPT:'0'}}; +assert.notEqual(sourceDsn,coldDsn,'Source and cold connection configuration must differ'); +const temporary=fs.mkdtempSync(path.join(os.tmpdir(),'tuldok-project-files-browser-')); +const report=qaDirectory(root,'project-files'); +const external=path.join(temporary,'external');fs.mkdirSync(external); +const sourceControl=path.join(temporary,'control'),coldControl=path.join(temporary,'cold-control'); +const TEXT='Café 山 🧪 — browser-authored source\nThe scientist’s unique rationale stays exact.\n'; +const name='Browser source 山',label='positive 山',corpusNote='Earlier corpus note café 山 — preserve this scientific rationale.'; +const checks=[],errors=[],httpErrors=[],children=[],catalogs=[]; +const observationHeaders=JSON.parse(execFileSync('python3',['-c',"import json,agent_api;print(json.dumps({name:agent_api.selected_header('project_files.'+name) for name in ('status','query','get')}))"],{cwd:root,encoding:'utf8'})); +const material={head:execFileSync('git',['rev-parse','HEAD'],{cwd:root,encoding:'utf8',...gitOptions}).trim(),files:Object.fromEntries(['app.py','metadata.py','access.py','workbench.py','agent_api.py','project_store.py','project_text.py','project_workspace.py','static/project-files.html','static/project-files.js','static/project-files.css','tests/browser_project_files.cjs'].map(file=>[file,fs.existsSync(path.join(root,file))?crypto.createHash('sha256').update(fs.readFileSync(path.join(root,file))).digest('hex'):null]))}; +let socket,session,server,serverErrors='',origin,fixtureAuth,send,evaluate; +const pause=ms=>new Promise(resolve=>setTimeout(resolve,ms)); +async function until(fn,message='Timed out'){ + for(let i=0;i<200;i++){const value=await fn();if(value)return value;await pause(100);} + throw Error(message); +} +function launch(command,args,options={}){const child=spawn(command,args,options);children.push(child);return child;} +function snapshot(directory){ + const values={}; + function walk(current){for(const entry of fs.readdirSync(current,{withFileTypes:true})){ + if(['.git','.tuldok-local'].includes(entry.name))continue; + const next=path.join(current,entry.name); + if(entry.isDirectory())walk(next); + else if(entry.isFile())values[path.relative(directory,next)]=crypto.createHash('sha256').update(fs.readFileSync(next)).digest('hex'); + else throw Error('Unexpected project special file '+entry.name); + }}walk(directory);return values; +} +async function stop(child){ + if(!child||child.exitCode!==null||child.signalCode!==null)return; + const exited=once(child,'exit');child.kill('SIGTERM');await exited; +} +async function start(control,configuredRoot,dsn){ + process.env.TULDOK_DATABASE_URL=dsn; + serverErrors=''; + server=launch('python3',['-u','app.py','--port','0','--data',control,'--project-files-root','fixture='+configuredRoot],{cwd:root,stdio:['ignore','pipe','pipe'],env:{...process.env,TULDOK_DATABASE_URL:dsn}}); + let output='';server.stdout.on('data',chunk=>output+=chunk);server.stderr.on('data',chunk=>serverErrors+=chunk); + const port=await until(()=>{if(server.exitCode!==null)throw Error('Server exited: '+serverErrors);return output.match(/127\.0\.0\.1:(\d+)/)?.[1];},'Project-files server readiness: '+serverErrors); + origin='http://127.0.0.1:'+port; + catalogs.push({control,dsn}); + fixtureAuth=authenticatedFixture(); + await fixtureAuth.register(origin,control,root); + return origin; +} +async function jsonApi(relative){ + const route=relative.split('?')[0],operation=route.endsWith('/records')?'query':route.includes('/records/')?'get':'status'; + const response=await fixtureAuth.fetch(origin+relative,{headers:{'Tuldok-Operation-Contract':observationHeaders[operation]}}); + const value=await response.json();assert.equal(response.status,200,JSON.stringify(value));return value; +} +async function state(){return jsonApi('/api/workspace/project-files');} +async function detail(recordId){ + const current=await state(); + return jsonApi('/api/workspace/project-files/records/'+recordId+'?'+new URLSearchParams({binding:current.binding,checkpoint:current.project.checkpoint,history_limit:'4'})); +} +async function key(key,code=key,modifiers=0){ + for(const type of ['keyDown','keyUp'])await send('Input.dispatchKeyEvent',{type,key,code,modifiers,windowsVirtualKeyCode:{Enter:13,Tab:9,Escape:27,a:65}[key]||0,...(type==='keyDown'&&key==='Enter'?{text:'\r',unmodifiedText:'\r'}:{})}); +} +async function focus(selector){ + await until(()=>evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e||e.disabled||!e.getClientRects().length)return false;e.scrollIntoView({block:'center'});e.focus();return document.activeElement===e;})()`),'Focusable control '+selector); +} +async function fill(selector,value){await focus(selector);await key('a','KeyA',2);await send('Input.insertText',{text:value});} +async function activate(selector){await focus(selector);await key('Enter');} +async function choose(selector,value){ + await focus(selector); + await evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!Array.from(e.options).some(o=>o.value===${JSON.stringify(value)}))throw Error('Missing rendered option');e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('change',{bubbles:true}));})()`); +} +async function ready(){await until(()=>evaluate('!!document.querySelector("#project-files-root option")&&!document.getElementById("project-files-new").disabled'),'Project files UI ready');} +async function navigate(){await fixtureAuth.browser(send,origin);await send('Page.navigate',{url:origin+'/project-files'});await ready();} +async function capture(filename,width,selector='#record-heading'){ + await send('Emulation.setDeviceMetricsOverride',{width,height:950,deviceScaleFactor:1,mobile:false}); + await evaluate(`document.querySelector(${JSON.stringify(selector)}).scrollIntoView({block:'start'})`); + await pause(80); + assert.ok(await evaluate('document.documentElement.scrollWidth<=innerWidth+1'),'No horizontal overflow at '+width); + fs.writeFileSync(path.join(report,filename),Buffer.from((await send('Page.captureScreenshot',screenshotOptions)).data,'base64')); +} +function check(message){checks.push(message);console.log('PASS '+message);} +(async()=>{ + // Cold runtime role must genuinely lack CONNECT to the source database. + const isolation=JSON.parse(execFileSync('python3',['-c', + "import json,os,psycopg;from psycopg.conninfo import conninfo_to_dict;source=conninfo_to_dict(os.environ['TULDOK_DATABASE_URL']);cold=conninfo_to_dict(os.environ['TULDOK_PROJECT_FILES_COLD_DATABASE_URL']);assert source['dbname']!=cold['dbname'];c=psycopg.connect(os.environ['TULDOK_PROJECT_FILES_COLD_DATABASE_URL']);row=c.execute('SELECT current_database(), current_user, has_database_privilege(current_user,%s,%s)',(source['dbname'],'CONNECT')).fetchone();assert not row[2], 'Cold role must not connect to source database';print(json.dumps({'database':row[0],'role':row[1],'source_connect':row[2]}));c.close()" + ],{cwd:root,encoding:'utf8'})); + await start(sourceControl,external,sourceDsn); + const browser=launch(process.env.BROWSER||'/usr/bin/chromium',['--headless','--no-sandbox','--disable-gpu','--no-first-run','--no-default-browser-check','--remote-debugging-port=0','--window-size=1440,950','--user-data-dir='+path.join(temporary,'browser'),'about:blank'],{stdio:'ignore',env:{...process.env,XDG_CONFIG_HOME:temporary,XDG_CACHE_HOME:temporary}}); + const port=await waitForDebugger(browser,path.join(temporary,'browser','DevToolsActivePort')); + const tabs=await(await fetch('http://127.0.0.1:'+port+'/json')).json(); + const target=tabs.find(tab=>tab.type==='page'&&tab.url==='about:blank');assert.ok(target); + socket=new WebSocket(target.webSocketDebuggerUrl);await once(socket,'open'); + session=cdpSession(socket,{onEvent:event=>{ + if(event.method==='Runtime.exceptionThrown')errors.push(event.params.exceptionDetails); + if(event.method==='Network.responseReceived'&&event.params.response.status>=400)httpErrors.push({url:event.params.response.url,status:event.params.response.status}); + }});send=session.send; + evaluate=async expression=>{const value=await send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(value.exceptionDetails)throw Error(JSON.stringify(value.exceptionDetails));return value.result.value;}; + await send('Page.enable');await send('Runtime.enable');await send('Network.enable'); + await navigate(); + const initial=await state();assert.equal(initial.state,'no_project_open'); + await choose('#project-files-root','fixture');await fill('#project-files-directory','alpha');await fill('#project-files-name','Browser café 🧪'); + await activate('#project-files-new'); + await until(async()=>((await state()).state==='open'),'New via keyboard opens project'); + await until(()=>evaluate('!document.getElementById("import-text").disabled')); + await fill('#text-name',name);await fill('#text-content',TEXT);await fill('#text-groups','source-fixture');await fill('#text-rights','Owned synthetic browser input'); + await activate('#import-text'); + await until(()=>evaluate('document.querySelectorAll("#records-list [data-record-id]").length===1'),'Import appears in actual UI'); + const recordId=await evaluate('document.querySelector("#records-list [data-record-id]").dataset.recordId'); + await activate('#records-list [data-record-id]'); + await until(()=>evaluate('document.getElementById("record-text").value==='+JSON.stringify(TEXT)),'Imported source shown exact'); + await choose('#annotation-task','text_corpus');await fill('#annotation-value',corpusNote);await choose('#record-review','human_reviewed'); + await activate('#annotation-save'); + await until(()=>evaluate('document.getElementById("record-summary").textContent.includes("Revision 2")&&!document.getElementById("annotation-save").disabled'),'Corpus note acknowledged in actual editor'); + await choose('#annotation-task','text_classification'); + await fill('#annotation-value',label);await fill('#annotation-groups','source-fixture,review-fixture');await choose('#record-review','human_reviewed'); + await activate('#annotation-save'); + await until(()=>evaluate('document.getElementById("record-summary").textContent.includes("Revision 3")&&!document.getElementById("annotation-save").disabled'),'Annotation acknowledged in actual editor'); + const expected=await detail(recordId); + assert.equal(expected.record.text,TEXT);assert.equal(expected.record.name,name);assert.equal(expected.record.review,'human_reviewed');assert.equal(expected.history.length,3);assert.ok(expected.history.some(item=>item.annotation?.note===corpusNote)); + await activate('#record-history-more'); + await until(()=>evaluate('document.getElementById("record-history").textContent.includes('+JSON.stringify(corpusNote)+')'),'Prior corpus note is inspectable through older-history UI'); + check('Keyboard New, Unicode import, historical corpus note and human-reviewed classification commit through real UI controls'); + await activate('#project-files-save'); + await until(()=>evaluate('!document.getElementById("project-files-save").disabled')); + const beforeClose=await state(); + await capture('authored-1440.jpg',1440);await capture('authored-390.jpg',390); + await activate('#project-files-close');await until(async()=>((await state()).state==='no_project_open')); + await until(()=>evaluate('document.querySelectorAll("#records-list [data-record-id]").length===0'),'Close clears record panel'); + await stop(server); + const source=path.join(external,'alpha');fs.mkdirSync(path.join(source,'scripts'),{recursive:true}); + fs.writeFileSync(path.join(source,'scripts','must-not-run.py'),"from pathlib import Path\nPath(__file__).with_name('EXECUTED').write_text('must never run')\n"); + const before=snapshot(source); + const coldExternal=path.join(temporary,'cold-external');fs.mkdirSync(coldExternal); + const clone=path.join(coldExternal,'clone'); + // Use an actual Git commit/clone; canonical source files are plain text. + execFileSync('git',['init','-q','--template=',source],gitOptions); + execFileSync('git',['-C',source,'add','.'],gitOptions); + execFileSync('git',['-C',source,'-c','user.name=Disposable Project QA','-c','user.email=project-qa@example.invalid','-c','commit.gpgsign=false','-c','core.hooksPath=/dev/null','commit','-qm','Synthetic external project fixture'],gitOptions); + execFileSync('git',['-c','core.hooksPath=/dev/null','clone','-q','--no-hardlinks','--template=',source,clone],gitOptions); + assert.deepEqual(snapshot(clone),before,'Git clone retained exact canonical authored bytes'); + await start(coldControl,coldExternal,coldDsn); + await navigate(); + assert.equal((await state()).state,'no_project_open'); + await choose('#project-files-root','fixture');await fill('#project-files-directory','clone');await activate('#project-files-open'); + await until(async()=>((await state()).state==='open'),'Cold process Open through keyboard'); + await until(()=>evaluate('document.querySelectorAll("#records-list [data-record-id]").length===1')); + await activate('#records-list [data-record-id]'); + await until(()=>evaluate('document.getElementById("record-text").value==='+JSON.stringify(TEXT))); + await until(()=>evaluate('document.getElementById("annotation-value").value==='+JSON.stringify(label))); + const cold=await state(),actual=await detail(recordId); + assert.deepEqual(cold.project,beforeClose.project); + assert.deepEqual(actual.record,expected.record);assert.deepEqual(actual.history,expected.history); + assert.equal(await evaluate('document.getElementById("record-review").value'),'human_reviewed'); + await activate('#project-files-save');await until(()=>evaluate('!document.getElementById("project-files-save").disabled')); + assert.deepEqual(snapshot(clone),before,'Cold no-op Save has no semantic or byte changes'); + assert.ok(!fs.existsSync(path.join(clone,'scripts','EXECUTED'))); + check('Fresh application process and distinct PostgreSQL role/database reconstruct exact IDs, text, review and history from Git clone; source DB access denied'); + await activate('#record-history-more'); + await until(()=>evaluate('document.getElementById("record-history").textContent.includes('+JSON.stringify(corpusNote)+')'),'Cold Open retained corpus note is inspectable through older-history UI'); + await capture('cold-open-1440.jpg',1440);await capture('cold-open-390.jpg',390);await capture('cold-history-390.jpg',390,'#record-history'); + await activate('#project-files-close');await until(async()=>((await state()).state==='no_project_open')); + assert.deepEqual(errors,[],'No browser runtime errors');assert.deepEqual(httpErrors,[],'No failed browser requests'); + for(const [file,digest] of Object.entries(material.files))assert.equal(crypto.createHash('sha256').update(fs.readFileSync(path.join(root,file))).digest('hex'),digest,'Material source changed during campaign: '+file); + fs.writeFileSync(path.join(report,'receipt.json'),JSON.stringify({status:'pass',checks,isolation,material,project:beforeClose.project,record_id:recordId,history_count:expected.history.length,canonical_sha256:before,screenshots:['authored-1440.jpg','authored-390.jpg','cold-open-1440.jpg','cold-open-390.jpg','cold-history-390.jpg'],browser_runtime_errors:errors,http_errors:httpErrors,fixture:'Synthetic project only; no lab/scientific execution claim'},null,2)+'\n'); + console.log('Report '+path.join(report,'receipt.json')); +})().catch(async error=>{console.error(error);console.error('Server diagnostics:',serverErrors);console.error('Browser runtime errors:',JSON.stringify(errors));console.error('HTTP errors:',JSON.stringify(httpErrors));if(evaluate)try{console.error('UI diagnostics:',await evaluate('document.body.innerText'));}catch(diagnostic){console.error('UI diagnostics unavailable:',diagnostic.message);}process.exitCode=1;}).finally(async()=>{ + session?.close();socket?.close(); + for(const child of children.reverse())await stop(child); + // Drop only UUID catalog schemas whose manifests live under this owned root. + for(const {control,dsn} of catalogs){ + try{execFileSync('python3',['-c', + "import json,pathlib,os,psycopg;from psycopg import sql;root=pathlib.Path(os.environ['QA_CONTROL']);c=psycopg.connect(os.environ['TULDOK_DATABASE_URL'],autocommit=True);[(c.execute(sql.SQL('DROP SCHEMA IF EXISTS {} CASCADE').format(sql.Identifier(('projection_'+v['projection_id']) if v['version']==2 else ('catalog_'+v['dataset_id']))))) for p in root.rglob('.tuldok.json') for v in [json.loads(p.read_text())]];c.close()" + ],{cwd:root,env:{...process.env,TULDOK_DATABASE_URL:dsn,QA_CONTROL:control},stdio:['ignore','pipe','pipe']});}catch(error){console.error('Owned schema cleanup failed:',error.message);process.exitCode=1;} + } + process.env.TULDOK_DATABASE_URL=sourceDsn; + fs.rmSync(temporary,{recursive:true,force:true}); +}); diff --git a/tests/browser_project_files_m2.cjs b/tests/browser_project_files_m2.cjs new file mode 100644 index 00000000..39b9b3d7 --- /dev/null +++ b/tests/browser_project_files_m2.cjs @@ -0,0 +1,273 @@ +'use strict'; +// Real browser authoring, keyboard actions and a cold process on another database. +// Inputs and runtime roots are disposable; project scripts are inert sentinels. +const assert=require('node:assert/strict'); +const fs=require('node:fs'),os=require('node:os'),path=require('node:path'),crypto=require('node:crypto'); +const {spawn,execFileSync}=require('node:child_process'); +const {once}=require('node:events'); +const {authenticatedFixture}=require('./auth_helpers.cjs'); +const {waitForDebugger}=require('./browser_startup.cjs'); +const {cdpSession}=require('./browser_cdp_session.cjs'); +const {qaDirectory,screenshotOptions}=require('./qa_artifacts.cjs'); +const root=path.resolve(__dirname,'..'); +assert.ok(process.env.TULDOK_DATABASE_URL,'Use a disposable PostgreSQL TULDOK_DATABASE_URL'); +assert.ok(process.env.TULDOK_PROJECT_FILES_COLD_DATABASE_URL,'Cold proof requires a separate disposable database URL'); +const sourceDsn=process.env.TULDOK_DATABASE_URL,coldDsn=process.env.TULDOK_PROJECT_FILES_COLD_DATABASE_URL; +const gitOptions={env:{...process.env,GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:'/dev/null',GIT_TERMINAL_PROMPT:'0'}}; +assert.notEqual(sourceDsn,coldDsn,'Source and cold connection configuration must differ'); +const temporary=fs.mkdtempSync(path.join(os.tmpdir(),'tuldok-project-files-browser-')); +const report=qaDirectory(root,'project-files-m2'); +const external=path.join(temporary,'external');fs.mkdirSync(external); +const sourceControl=path.join(temporary,'control'),coldControl=path.join(temporary,'cold-control'); +const TEXT='Café 山 🧪 — browser-authored source\nThe scientist’s unique rationale stays exact.\n'; +const name='Browser source 山',label='positive 山',corpusNote='Earlier corpus note café 山 — preserve this scientific rationale.'; +const checks=[],errors=[],httpErrors=[],children=[],catalogs=[],responsiveFailures=[]; +const observationHeaders=JSON.parse(execFileSync('python3',['-c',"import json,agent_api;print(json.dumps({name:agent_api.selected_header('project_files.'+name) for name in ('status','query','get')}))"],{cwd:root,encoding:'utf8'})); +const material={head:execFileSync('git',['rev-parse','HEAD'],{cwd:root,encoding:'utf8',...gitOptions}).trim(),files:Object.fromEntries(['app.py','metadata.py','access.py','workbench.py','agent_api.py','project_store.py','project_text.py','project_workspace.py','static/project-files.html','static/project-files.js','static/project-files.css','tests/browser_project_files_m2.cjs'].map(file=>[file,fs.existsSync(path.join(root,file))?crypto.createHash('sha256').update(fs.readFileSync(path.join(root,file))).digest('hex'):null]))}; +const freeze=JSON.parse(fs.readFileSync(process.env.TULDOK_M2_FREEZE_MANIFEST||'/workspace/scratch/tuldok-save-load-qa/m2-product-freeze.json','utf8')); +material.files=freeze.files; +for(const [file,sha] of Object.entries(material.files))assert.equal(crypto.createHash('sha256').update(fs.readFileSync(path.join(root,file))).digest('hex'),sha,'Freeze mismatch '+file); +const testMaterial=Object.fromEntries(['tests/browser_project_files_m2.cjs','tests/test_project_files_m2_http.py'].map(file=>[file,crypto.createHash('sha256').update(fs.readFileSync(path.join(root,file))).digest('hex')])); +let socket,session,server,serverErrors='',origin,fixtureAuth,send,evaluate; +const pause=ms=>new Promise(resolve=>setTimeout(resolve,ms)); +async function until(fn,message='Timed out'){ + for(let i=0;i<200;i++){const value=await fn();if(value)return value;await pause(100);} + throw Error(message); +} +function launch(command,args,options={}){const child=spawn(command,args,options);children.push(child);return child;} +function snapshot(directory){ + const values={}; + function walk(current){for(const entry of fs.readdirSync(current,{withFileTypes:true})){ + if(['.git','.tuldok-local'].includes(entry.name))continue; + const next=path.join(current,entry.name); + if(entry.isDirectory())walk(next); + else if(entry.isFile())values[path.relative(directory,next)]=crypto.createHash('sha256').update(fs.readFileSync(next)).digest('hex'); + else throw Error('Unexpected project special file '+entry.name); + }}walk(directory);return values; +} +async function stop(child){ + if(!child||child.exitCode!==null||child.signalCode!==null)return; + const exited=once(child,'exit');child.kill('SIGTERM');await exited; +} +async function start(control,configuredRoot,dsn){ + process.env.TULDOK_DATABASE_URL=dsn; + serverErrors=''; + server=launch('python3',['-u','app.py','--port','0','--data',control,'--project-files-root','fixture='+configuredRoot],{cwd:root,stdio:['ignore','pipe','pipe'],env:{...process.env,TULDOK_DATABASE_URL:dsn}}); + let output='';server.stdout.on('data',chunk=>output+=chunk);server.stderr.on('data',chunk=>serverErrors+=chunk); + const port=await until(()=>{if(server.exitCode!==null)throw Error('Server exited: '+serverErrors);return output.match(/127\.0\.0\.1:(\d+)/)?.[1];},'Project-files server readiness: '+serverErrors); + origin='http://127.0.0.1:'+port; + catalogs.push({control,dsn}); + fixtureAuth=authenticatedFixture(); + await fixtureAuth.register(origin,control,root); + return origin; +} +async function jsonApi(relative){ + const route=relative.split('?')[0],operation=route.endsWith('/records')?'query':route.includes('/records/')?'get':'status'; + const response=await fixtureAuth.fetch(origin+relative,{headers:{'Tuldok-Operation-Contract':observationHeaders[operation]}}); + const value=await response.json();assert.equal(response.status,200,JSON.stringify(value));return value; +} +async function state(){return jsonApi('/api/workspace/project-files');} +async function detail(recordId){ + const current=await state(); + return jsonApi('/api/workspace/project-files/records/'+recordId+'?'+new URLSearchParams({binding:current.binding,checkpoint:current.project.checkpoint,history_limit:'4'})); +} +async function key(key,code=key,modifiers=0){ + for(const type of ['keyDown','keyUp'])await send('Input.dispatchKeyEvent',{type,key,code,modifiers,windowsVirtualKeyCode:{Enter:13,Tab:9,Escape:27,a:65}[key]||0,...(type==='keyDown'&&key==='Enter'?{text:'\r',unmodifiedText:'\r'}:{})}); +} +async function focus(selector){ + await until(()=>evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e||e.disabled||!e.getClientRects().length)return false;e.scrollIntoView({block:'center'});e.focus();return document.activeElement===e;})()`),'Focusable control '+selector); +} +async function fill(selector,value){await focus(selector);await key('a','KeyA',2);await send('Input.insertText',{text:value});} +async function activate(selector){await focus(selector);await key('Enter');} +async function choose(selector,value){ + await focus(selector); + await evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!Array.from(e.options).some(o=>o.value===${JSON.stringify(value)}))throw Error('Missing rendered option');e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('change',{bubbles:true}));})()`); +} +async function ready(){await until(()=>evaluate('!!document.querySelector("#project-files-root option")&&!document.getElementById("project-files-new").disabled'),'Project files UI ready');} +async function navigate(){await fixtureAuth.browser(send,origin);await send('Page.navigate',{url:origin+'/project-files'});await ready();} +async function capture(filename,width,selector='#record-heading'){ + await send('Emulation.setDeviceMetricsOverride',{width,height:950,deviceScaleFactor:1,mobile:false}); + await evaluate(`document.querySelector(${JSON.stringify(selector)}).scrollIntoView({block:'start'})`); + await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); + const bounds=await evaluate(`(()=>({viewport:innerWidth,width:document.documentElement.scrollWidth,clientWidth:document.documentElement.clientWidth,selectedValues:['project-scientific-reference','project-explore-kind','project-data-kind','project-files-root'].map(id=>({id,value:document.getElementById(id).value,text:document.getElementById(id).selectedOptions?.[0]?.textContent})),fieldsets:Array.from(document.querySelectorAll('fieldset')).filter(e=>e.getClientRects().length).map(e=>({id:e.id,width:Math.round(e.getBoundingClientRect().width),right:Math.round(e.getBoundingClientRect().right),minWidth:getComputedStyle(e).minWidth})),offenders:Array.from(document.querySelectorAll('body *')).filter(e=>e.getClientRects().length&&e.getBoundingClientRect().right>innerWidth+1).map(e=>({tag:e.tagName,id:e.id,className:e.className,width:Math.round(e.getBoundingClientRect().width),right:Math.round(e.getBoundingClientRect().right),scrollWidth:e.scrollWidth,clientWidth:e.clientWidth,minWidth:getComputedStyle(e).minWidth,text:e.textContent.slice(0,100)})).slice(-25)}))()`); + fs.writeFileSync(path.join(report,'bounds-'+width+'.json'),JSON.stringify(bounds,null,2)+'\n'); + fs.writeFileSync(path.join(report,filename),Buffer.from((await send('Page.captureScreenshot',screenshotOptions)).data,'base64')); + if(bounds.width>bounds.viewport+1){responsiveFailures.push({width,filename,bounds});console.error('RESPONSIVE_FAILURE '+JSON.stringify({width,viewport:bounds.viewport,scrollWidth:bounds.width,clientWidth:bounds.clientWidth,report:path.join(report,'bounds-'+width+'.json')}));} +} +function check(message){checks.push(message);console.log('PASS '+message);} + +const routes=JSON.parse(execFileSync('python3',['-c',"import json,agent_api;names=tuple(n.split('.')[1] for n in agent_api.PROJECT_FILES_M2);print(json.dumps({n:{**{k:agent_api.operation('project_files.'+n)[k] for k in ('path','method','query','side_effects')},'header':agent_api.selected_header('project_files.'+n)} for n in names}))"],{cwd:root,encoding:'utf8'})); +async function observe(action,fields={}){ + const current=await state(),route=routes[action],args={binding:current.binding,...(route.side_effects?{expected_checkpoint:current.project.checkpoint,request_key:crypto.randomUUID().replaceAll('-','')}:{checkpoint:current.project.checkpoint}),...fields}; + let target=route.path;for(const [key,value] of Object.entries(args))target=target.replace('{'+key+'}',encodeURIComponent(value)); + const query=new URLSearchParams(Object.entries(args).filter(([key])=>route.query.includes(key))); + if(query.size)target+='?'+query; + const body=Object.fromEntries(Object.entries(args).filter(([key])=>!route.query.includes(key)&&!route.path.includes('{'+key+'}'))); + const response=await fixtureAuth.fetch(origin+target,{method:route.method,headers:{'Content-Type':'application/json','Tuldok-Operation-Contract':route.header},...(route.method==='POST'?{body:JSON.stringify(body)}:{})}); + const envelope=await response.json();assert.equal(response.status,200,JSON.stringify(envelope));return envelope.value; +} +async function settled(){await until(()=>evaluate('!document.getElementById("project-files-save").disabled&&!document.getElementById("project-files-dirty").textContent.includes("unsent")'),'UI acknowledges saved operation');} +async function explore(kind,contains){ + await choose('#project-explore-kind',kind);await activate('#project-explore-load');await settled(); + await until(()=>evaluate(`Array.from(document.querySelectorAll('#project-explore-list button')).some(e=>e.textContent.includes(${JSON.stringify(contains)}))`),'Rendered saved record '+contains); + await evaluate(`(()=>{for(const e of document.querySelectorAll('#project-explore-list button'))if(e.textContent.includes(${JSON.stringify(contains)})){e.dataset.qaSelected='yes';break;}})()`); + await activate('#project-explore-list button[data-qa-selected="yes"]');await settled(); +} +async function fileInput(selector,file){ + const document=await send('DOM.getDocument',{depth:1});const {nodeId}=await send('DOM.querySelector',{nodeId:document.root.nodeId,selector}); + assert.ok(nodeId,'Actual native file input exists');await send('DOM.setFileInputFiles',{nodeId,files:[file]}); +} +(async()=>{ + // Cold runtime role must genuinely lack CONNECT to the source database. + const isolation=JSON.parse(execFileSync('python3',['-c', + "import json,os,psycopg;from psycopg.conninfo import conninfo_to_dict;source=conninfo_to_dict(os.environ['TULDOK_DATABASE_URL']);cold=conninfo_to_dict(os.environ['TULDOK_PROJECT_FILES_COLD_DATABASE_URL']);assert source['dbname']!=cold['dbname'];c=psycopg.connect(os.environ['TULDOK_PROJECT_FILES_COLD_DATABASE_URL']);row=c.execute('SELECT current_database(), current_user, has_database_privilege(current_user,%s,%s)',(source['dbname'],'CONNECT')).fetchone();assert not row[2], 'Cold role must not connect to source database';print(json.dumps({'database':row[0],'role':row[1],'source_connect':row[2]}));c.close()" + ],{cwd:root,encoding:'utf8'})); + await start(sourceControl,external,sourceDsn); + const browser=launch(process.env.BROWSER||'/usr/bin/chromium',['--headless','--no-sandbox','--disable-gpu','--no-first-run','--no-default-browser-check','--remote-debugging-port=0','--window-size=1440,950','--user-data-dir='+path.join(temporary,'browser'),'about:blank'],{stdio:'ignore',env:{...process.env,XDG_CONFIG_HOME:temporary,XDG_CACHE_HOME:temporary}}); + const port=await waitForDebugger(browser,path.join(temporary,'browser','DevToolsActivePort')); + const tabs=await(await fetch('http://127.0.0.1:'+port+'/json')).json(); + const target=tabs.find(tab=>tab.type==='page'&&tab.url==='about:blank');assert.ok(target); + socket=new WebSocket(target.webSocketDebuggerUrl);await once(socket,'open'); + session=cdpSession(socket,{onEvent:event=>{ + if(event.method==='Runtime.exceptionThrown')errors.push(event.params.exceptionDetails); + if(event.method==='Network.responseReceived'&&event.params.response.status>=400)httpErrors.push({url:event.params.response.url,status:event.params.response.status}); + }});send=session.send; + evaluate=async expression=>{const value=await send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(value.exceptionDetails)throw Error(JSON.stringify(value.exceptionDetails));return value.result.value;}; + await send('Page.enable');await send('Runtime.enable');await send('Network.enable'); + + execFileSync('python3',['-c',"import pathlib,sys;sys.path.insert(0,'tests');from test_project_files_m2_http import authored_source;from PIL import Image;root=pathlib.Path(sys.argv[1]);authored_source(root);Image.new('RGB',(20,12),'#3355aa').save(root/'image.png','PNG')",external],{cwd:root}); + await navigate(); + await choose('#project-files-root','fixture');await fill('#project-files-directory','alpha');await fill('#project-files-name','Browser M2 authored project 山'); + await activate('#project-files-new');await until(async()=>((await state()).state==='open'));await settled(); + + + if(process.env.TULDOK_M2_RESPONSIVE_ONLY==='1'){ + const imported=await observe('data_import_image',{image:fs.readFileSync(path.join(external,'image.png')).toString('base64'),name:'Cold responsive image café 山',groups:['responsive-image'],rights:'Owned authored pixels'}); + const corrected=await observe('data_rights',{record_id:imported.id,revision:imported.revision,source_revision:imported.source_revision,note:'Exact cold responsive rights café 山'}); + const expected=await observe('data_get',{record_id:imported.id}),expectedHistory=await observe('data_history',{record_id:imported.id,limit:4}); + await activate('#project-files-refresh');await settled();await activate('#project-files-close');await until(async()=>((await state()).state==='no_project_open'));await stop(server); + const source=path.join(external,'alpha'),before=snapshot(source),coldExternal=path.join(temporary,'cold-external');fs.mkdirSync(coldExternal);const clone=path.join(coldExternal,'clone'); + execFileSync('git',['init','-q','--template=',source],gitOptions);execFileSync('git',['-C',source,'add','.'],gitOptions); + execFileSync('git',['-C',source,'-c','user.name=Disposable Responsive QA','-c','user.email=qa@example.invalid','-c','commit.gpgsign=false','-c','core.hooksPath=/dev/null','commit','-qm','Cold history responsive fixture'],gitOptions); + execFileSync('git',['-c','core.hooksPath=/dev/null','clone','-q','--no-hardlinks','--template=',source,clone],gitOptions);fs.cpSync(path.join(source,'artifacts'),path.join(clone,'artifacts'),{recursive:true}); + await start(coldControl,coldExternal,coldDsn);await navigate();await choose('#project-files-root','fixture');await fill('#project-files-directory','clone');await activate('#project-files-open');await until(async()=>((await state()).state==='open'));await settled(); + assert.deepEqual(await observe('data_get',{record_id:imported.id}),expected);assert.deepEqual(await observe('data_history',{record_id:imported.id,limit:4}),expectedHistory); + await choose('#project-data-kind','image');await explore('data_query','Cold responsive image café 山');await activate('#project-detail-history'); + await until(()=>evaluate('document.getElementById("project-detail-history-list").textContent.includes("Exact cold responsive rights café 山")')); + await capture('focused-cold-history-390.jpg',390,'#project-detail-history-list');await capture('focused-cold-history-1440.jpg',1440,'#project-detail-history-list'); + assert.deepEqual(snapshot(clone),before);assert.deepEqual(errors,[]);assert.deepEqual(httpErrors,[]); + for(const [file,digest] of Object.entries(material.files))assert.equal(crypto.createHash('sha256').update(fs.readFileSync(path.join(root,file))).digest('hex'),digest,'Frozen product source changed '+file); + for(const [file,digest] of Object.entries(testMaterial))assert.equal(crypto.createHash('sha256').update(fs.readFileSync(path.join(root,file))).digest('hex'),digest,'Acceptance source changed '+file); + fs.writeFileSync(path.join(report,'receipt.json'),JSON.stringify({status:responsiveFailures.length?'failed':'pass',gate:'focused_cold_history_responsive',prior_functional_and_cold_receipt:'/workspace/scratch/tuldok-save-load-qa/artifacts/project-files-m2/run-DDH5l1/receipt.json',responsive_failures:responsiveFailures,isolation,material,test_material:testMaterial,image_id:imported.id,history_count:expectedHistory.items.length,browser_runtime_errors:errors,http_errors:httpErrors,canonical_sha256:before,fixture:'Real HTTP-authored image/rights history; real Git metadata clone and explicit untracked payload transfer; actual cold UI history'},null,2)+'\n'); + console.log('Focused responsive report '+path.join(report,'receipt.json'));if(responsiveFailures.length)throw Error('Focused cold history responsive overflow remains');return; + } + let imageId,fieldId,studyId,projectId,expectedImage,expectedField,expectedStudy,rights,scienceNote; + if(process.env.TULDOK_M2_RESUME_HTTP==='1'){ + const seedImage=await observe('data_import_image',{image:fs.readFileSync(path.join(external,'image.png')).toString('base64'),name:'Browser image café 山',groups:['browser-image'],rights:'Owned authored synthetic pixels'});imageId=seedImage.id; + rights='Browser corrected exact rights café 山';await observe('data_rights',{record_id:imageId,revision:seedImage.revision,source_revision:seedImage.source_revision,note:rights});expectedImage=await observe('data_get',{record_id:imageId}); + await observe('selection_create',{document:{name:'Browser fixed image revision 山',items:[{id:imageId,revision:expectedImage.revision,source_revision:expectedImage.source_revision}]}}); + await observe('search_create',{document:{name:'Browser entered search café 山',criteria:{q:'café',kind:'image',review:'',sort:'',task:'',label:'',group:'',rights:''}}}); + const unpinned=JSON.parse(fs.readFileSync(path.join(external,'source-project.json'),'utf8'));delete unpinned.data[0].sha256;fs.writeFileSync(path.join(external,'source-project.json'),JSON.stringify(unpinned)); + const registered=await observe('source_register',{root:'fixture',manifest_path:'source-project.json'}),snapshot=Object.fromEntries(['origin_catalog_id','project_id','manifest_sha256'].map(key=>[key,registered[key]])); + const scientificRaw=fs.readFileSync(path.join(external,'observed-fields.zip')); + const field=await observe('data_import_scientific',{snapshot,reference_id:'fields',expected_sha256:crypto.createHash('sha256').update(scientificRaw).digest('hex'),document:{name:'Browser exact field sequence 山',groups:['browser-science'],rights:'Owned authored scientific arrays'}});fieldId=field.id; + scienceNote='Retained browser observation note café 山';await observe('data_annotate',{record_id:fieldId,revision:field.revision,source_revision:field.source_revision,task:field.task,annotation:{note:scienceNote},groups:field.groups,review:'draft'});expectedField=await observe('data_get',{record_id:fieldId}); + const mismatch={...unpinned,id:crypto.randomUUID().replaceAll('-',''),data:[{...unpinned.data[0],sha256:'0'.repeat(64)}]};fs.writeFileSync(path.join(external,'mismatch-project.json'),JSON.stringify(mismatch)); + const study=await observe('study_create',{name:'Browser Study hypothesis 山',limits:{attempt_slots:1,admitted_wall_seconds:60,retained_bytes:1048576,peak_staging_bytes:1048576},policy:{format:'selection_policy_v1',metric_definition_sha256:'b'.repeat(64),checkpoint_rule:'selected',replicate_reduce:'individual',required_capabilities:[],numerical_acceptance:'diagnostic',tie_break:'trial_id'}}); + const revised=await observe('study_save',{study_id:study.id,revision:study.revision,name:'Browser revised Study hypothesis 山',limits:study.limits,policy:study.policy});studyId=revised.id;projectId=(await state()).project.project_id; + expectedStudy=await observe('study',{study_id:studyId,origin_catalog_id:projectId,limit:1}); + await activate('#project-files-refresh');await settled(); + await fill('#project-register-path','mismatch-project.json');await activate('#project-register-form button[type="submit"]');await settled(); + await explore('studies','Browser revised Study hypothesis 山'); + check('Equivalent authored fixture seeded through actual HTTP; prior4UIauthoringgroups retained, CSS and cold remainder are this targeted campaign'); + }else{ + await fill('#project-source-name','Browser image café 山');await fill('#project-source-groups','browser-image');await fill('#project-source-rights','Owned authored synthetic pixels'); + await fileInput('#project-source-file',path.join(external,'image.png'));await activate('#project-source-form button[type="submit"]');await settled(); + const rows=await observe('data_query',{kind:'image',limit:40});assert.equal(rows.items.length,1);imageId=rows.items[0].id; + await choose('#project-data-kind','image');await explore('data_query','Browser image café 山'); + rights='Browser corrected exact rights café 山';await fill('#project-rights-note',rights);await activate('#project-rights-form button[type="submit"]');await settled(); + await explore('data_query','Browser image café 山'); + await fill('#project-selection-name','Browser fixed image revision 山');await activate('#project-selection-form button[type="submit"]');await settled(); + await fill('#project-data-search','café');await fill('#project-search-name','Browser entered search café 山');await activate('#project-search-form button[type="submit"]');await settled(); + await explore('data_query','Browser image café 山');await activate('#project-detail-history'); + await until(()=>evaluate('document.getElementById("project-detail-history-list").textContent.includes('+JSON.stringify(rights)+')'),'Exact corrected rights shown in actual history controls'); + await activate('#project-detail-availability');await settled(); + expectedImage=await observe('data_get',{record_id:imageId});assert.equal(expectedImage.task,'image_detection');assert.equal(expectedImage.provenance.rights_note_correction.note,rights); + check('Actual native image upload, keyboard rights correction, fixed selection, saved search and visible revision history'); + // Explicit owner registration is a separate form from imported historical paths. + const unpinned=JSON.parse(fs.readFileSync(path.join(external,'source-project.json'),'utf8'));delete unpinned.data[0].sha256;fs.writeFileSync(path.join(external,'source-project.json'),JSON.stringify(unpinned)); + await fill('#project-register-path','source-project.json');await activate('#project-register-form button[type="submit"]');await settled(); + await until(()=>evaluate('document.querySelectorAll("#project-scientific-reference option").length===1'),'Real source reference appears'); + await fill('#project-scientific-name','Browser exact field sequence 山');await fill('#project-scientific-groups','browser-science');await fill('#project-scientific-rights','Owned authored scientific arrays'); + await activate('#project-scientific-form button[type="submit"]');await settled(); + const mismatch={...unpinned,id:crypto.randomUUID().replaceAll('-',''),data:[{...unpinned.data[0],sha256:'0'.repeat(64)}]};fs.writeFileSync(path.join(external,'mismatch-project.json'),JSON.stringify(mismatch)); + await fill('#project-register-path','mismatch-project.json');await activate('#project-register-form button[type="submit"]');await settled(); + assert.equal(await evaluate('Array.from(document.querySelectorAll("#project-scientific-reference option")).filter(option=>!option.disabled).length'),0,'Declared hash mismatch must not be selectable for import'); + await fill('#project-data-search','');await choose('#project-data-kind','field_sequence');await explore('data_query','Browser exact field sequence 山'); + scienceNote='Retained browser observation note café 山';await fill('#project-data-note',scienceNote);await activate('#project-data-note-form button[type="submit"]');await settled(); + const fields=await observe('data_query',{kind:'field_sequence',limit:40});assert.equal(fields.items.length,1);fieldId=fields.items[0].id; + expectedField=await observe('data_get',{record_id:fieldId});assert.equal(expectedField.annotation.note,scienceNote); + await explore('data_query','Browser exact field sequence 山');await activate('#project-detail-availability');await settled(); + check('Actual unpinned source registration uses current observed hash; declared mismatch is unavailable; exact scientific import, note and availability controls'); + // A missing source remains metadata and is resolved only through an explicit + // current-root file selection. No automatic search or source grant occurs. + const artifactSha=expectedField.source_sha256,artifactPath=path.join(external,'alpha','artifacts','sha256',artifactSha); + assert.ok(fs.existsSync(artifactPath),'Imported exact scientific artifact exists'); + const originalArtifact=fs.readFileSync(artifactPath);fs.unlinkSync(artifactPath); + await explore('artifacts',artifactSha);await activate('#project-artifact-status'); + await until(()=>evaluate('!document.getElementById("project-artifact-resolve-form").hidden'),'Missing artifact resolution offered explicitly'); + await choose('#project-artifact-root','fixture');await fill('#project-artifact-path','observed-fields.zip');await activate('#project-artifact-resolve-form button[type="submit"]');await settled(); + await activate('#project-artifact-status');await settled(); + assert.equal((await observe('artifact_status',{sha256:artifactSha})).state,'verified'); + assert.deepEqual(fs.readFileSync(artifactPath),originalArtifact); + check('Missing artifact survives as metadata and explicit current-root resolution restores verified exact bytes'); + await fill('#project-study-create-name','Browser Study hypothesis 山');await fill('#project-study-metric','b'.repeat(64)); + await activate('#project-study-create-form button[type="submit"]');await settled(); + await explore('studies','Browser Study hypothesis 山'); + await fill('#project-metadata-name','Browser revised Study hypothesis 山');await activate('#project-metadata-form button[type="submit"]');await settled(); + await explore('studies','Browser revised Study hypothesis 山'); + const studies=await observe('studies',{limit:40});assert.equal(studies.items.length,1);studyId=studies.items[0].study.id; + projectId=(await state()).project.project_id; + expectedStudy=await observe('study',{study_id:studyId,origin_catalog_id:projectId,limit:1});assert.equal(expectedStudy.study.revision,2); + check('Study creation and metadata revision use actual named controls without execution'); + } + await activate('#project-files-save');await settled();const beforeClose=await state(); + await capture('m2-authored-1440.jpg',1440,'#project-detail-title');await capture('m2-authored-390.jpg',390,'#project-detail-title'); + if(process.env.TULDOK_M2_LAYOUT_ONLY==='1'){console.log('Layout-only probe complete '+report);return;} + await activate('#project-files-close');await until(async()=>((await state()).state==='no_project_open'));await stop(server); + const source=path.join(external,'alpha');fs.mkdirSync(path.join(source,'scripts'),{recursive:true}); + fs.writeFileSync(path.join(source,'scripts','sentinel.py'),"from pathlib import Path\nPath(__file__).with_name('EXECUTED').write_text('bad')\n"); + const before=snapshot(source),coldExternal=path.join(temporary,'cold-external');fs.mkdirSync(coldExternal);const clone=path.join(coldExternal,'clone'); + execFileSync('git',['init','-q','--template=',source],gitOptions);execFileSync('git',['-C',source,'add','.'],gitOptions); + execFileSync('git',['-C',source,'-c','user.name=Disposable M2 QA','-c','user.email=qa@example.invalid','-c','commit.gpgsign=false','-c','core.hooksPath=/dev/null','commit','-qm','Authored M2 browser fixture'],gitOptions); + execFileSync('git',['-c','core.hooksPath=/dev/null','clone','-q','--no-hardlinks','--template=',source,clone],gitOptions);fs.cpSync(path.join(source,'artifacts'),path.join(clone,'artifacts'),{recursive:true});assert.deepEqual(snapshot(clone),before); + await start(coldControl,coldExternal,coldDsn);await navigate();await choose('#project-files-root','fixture');await fill('#project-files-directory','clone');await activate('#project-files-open'); + await until(async()=>((await state()).state==='open'));await settled();assert.deepEqual((await state()).project,beforeClose.project); + assert.deepEqual(await observe('data_get',{record_id:imageId}),expectedImage);assert.deepEqual(await observe('data_get',{record_id:fieldId}),expectedField); + assert.deepEqual(await observe('study',{study_id:studyId,origin_catalog_id:projectId,limit:1}),expectedStudy); + await fill('#project-data-search','');await choose('#project-data-kind','image');await explore('data_query','Browser image café 山'); + assert.equal(await evaluate('document.getElementById("project-rights-note").value'),rights); + await activate('#project-detail-history');await until(()=>evaluate('document.getElementById("project-detail-history-list").textContent.includes('+JSON.stringify(rights)+')')); + await capture('m2-cold-image-390.jpg',390,'#project-detail-history-list'); + await choose('#project-data-kind','field_sequence');await explore('data_query','Browser exact field sequence 山'); + assert.equal(await evaluate('document.getElementById("project-data-note").value'),scienceNote);await activate('#project-detail-availability');await settled(); + await capture('m2-cold-science-1440.jpg',1440,'#project-detail-title'); + await activate('#project-files-save');await settled();assert.deepEqual(snapshot(clone),before);assert.ok(!fs.existsSync(path.join(clone,'scripts','EXECUTED'))); + check('Fresh process, fresh control, distinct PostgreSQL role denied source CONNECT and real Git metadata clone plus explicit untracked artifact transfer reconstruct exact data/history/Study; no-op Save changes no bytes; sentinel stays inert'); + assert.deepEqual(errors,[]);assert.deepEqual(httpErrors,[]); + for(const [file,digest] of Object.entries(material.files))assert.equal(crypto.createHash('sha256').update(fs.readFileSync(path.join(root,file))).digest('hex'),digest,'Frozen product source changed '+file); + for(const [file,digest] of Object.entries(testMaterial))assert.equal(crypto.createHash('sha256').update(fs.readFileSync(path.join(root,file))).digest('hex'),digest,'Acceptance test changed during campaign '+file); + fs.writeFileSync(path.join(report,'receipt.json'),JSON.stringify({status:responsiveFailures.length?'failed':'pass',functional_gate_passed:true,cold_gate_passed:true,responsive_failures:responsiveFailures,checks,isolation,material,test_material:testMaterial,authoring:process.env.TULDOK_M2_RESUME_HTTP==='1'?'real HTTP equivalent fixture; prior UI evidence retained':'actual UI native authoring',project:beforeClose.project,image_id:imageId,field_id:fieldId,study_id:studyId,canonical_sha256:before,browser_runtime_errors:errors,http_errors:httpErrors,fixture:'Authored disposable synthetic fixtures; no training or scientific validity claim'},null,2)+'\n'); + console.log('Report '+path.join(report,'receipt.json'));if(responsiveFailures.length)throw Error('Responsive overflow remains after independently completed cold gate'); +})().catch(async error=>{fs.writeFileSync(path.join(report,'failure-receipt.json'),JSON.stringify({status:'failed',error:error.message,checks,material,test_material:testMaterial,responsive_failures:responsiveFailures,browser_runtime_errors:errors,http_errors:httpErrors},null,2)+'\n');console.error('Failure receipt '+path.join(report,'failure-receipt.json'));console.error(error);console.error('Server diagnostics:',serverErrors);console.error('Browser runtime errors:',JSON.stringify(errors));console.error('HTTP errors:',JSON.stringify(httpErrors));if(evaluate)try{console.error('UI diagnostics:',await evaluate('document.body.innerText'));}catch(diagnostic){console.error('UI diagnostics unavailable:',diagnostic.message);}process.exitCode=1;}).finally(async()=>{ + session?.close();socket?.close(); + for(const child of children.reverse())await stop(child); + // Drop only UUID catalog schemas whose manifests live under this owned root. + for(const {control,dsn} of catalogs){ + try{execFileSync('python3',['-c', + "import json,pathlib,os,psycopg;from psycopg import sql;root=pathlib.Path(os.environ['QA_CONTROL']);c=psycopg.connect(os.environ['TULDOK_DATABASE_URL'],autocommit=True);[(c.execute(sql.SQL('DROP SCHEMA IF EXISTS {} CASCADE').format(sql.Identifier(('projection_'+v['projection_id']) if v['version']==2 else ('catalog_'+v['dataset_id']))))) for p in root.rglob('.tuldok.json') for v in [json.loads(p.read_text())]];c.close()" + ],{cwd:root,env:{...process.env,TULDOK_DATABASE_URL:dsn,QA_CONTROL:control},stdio:['ignore','pipe','pipe']});}catch(error){console.error('Owned schema cleanup failed:',error.message);process.exitCode=1;} + } + process.env.TULDOK_DATABASE_URL=sourceDsn; + fs.rmSync(temporary,{recursive:true,force:true}); +}); diff --git a/tests/browser_project_files_m3.cjs b/tests/browser_project_files_m3.cjs new file mode 100644 index 00000000..3f12ed69 --- /dev/null +++ b/tests/browser_project_files_m3.cjs @@ -0,0 +1,126 @@ +'use strict'; +// Real named runtime controls; offline byte capture, explicit recovery, inert cold Open. +const assert=require('node:assert/strict'),fs=require('node:fs'),os=require('node:os'),path=require('node:path'),crypto=require('node:crypto'); +const {spawn,execFileSync}=require('node:child_process'),{once}=require('node:events'); +const {authenticatedFixture}=require('./auth_helpers.cjs'),{waitForDebugger}=require('./browser_startup.cjs'),{cdpSession}=require('./browser_cdp_session.cjs'),{qaDirectory,screenshotOptions}=require('./qa_artifacts.cjs'); +const root=path.resolve(__dirname,'..'),hash=raw=>crypto.createHash('sha256').update(raw).digest('hex'); +assert.ok(process.env.TULDOK_DATABASE_URL&&process.env.TULDOK_PROJECT_FILES_COLD_DATABASE_URL,'Disposable source and cold PostgreSQL configurations required'); +assert.ok(process.env.TULDOK_M3_FREEZE_MANIFEST,'Lead must freeze and qualify isolated M3 before actual browser acceptance'); +const freeze=JSON.parse(fs.readFileSync(process.env.TULDOK_M3_FREEZE_MANIFEST,'utf8')); +const material={manifest_sha256:hash(fs.readFileSync(process.env.TULDOK_M3_FREEZE_MANIFEST)),files:freeze.files}; +function verifySource(){for(const [file,expected] of Object.entries(material.files))assert.equal(hash(fs.readFileSync(path.join(root,file))),expected,'Frozen source changed: '+file);} +verifySource(); +const testSha=hash(fs.readFileSync(__filename)),temporary=fs.mkdtempSync(path.join(os.tmpdir(),'tuldok-m3-browser-')),report=qaDirectory(root,'project-files-m3'); +const sourceDsn=process.env.TULDOK_DATABASE_URL,coldDsn=process.env.TULDOK_PROJECT_FILES_COLD_DATABASE_URL; +assert.notEqual(sourceDsn,coldDsn); +const external=path.join(temporary,'external');fs.mkdirSync(external); +const children=[],catalogs=[],checks=[],errors=[],httpErrors=[],bounds=[],requests=[]; +const sentinel=path.join(temporary,'EXECUTED'),audit=path.join(temporary,'process-audit.jsonl'),auditDir=path.join(temporary,'audit');fs.mkdirSync(auditDir); +fs.writeFileSync(path.join(auditDir,'sitecustomize.py'),`import sys,os,json +with open(os.environ['QA_AUDIT_INSTALLED'],'w') as f:f.write('installed') +def guard(event,args): + if event in ('subprocess.Popen','os.system','os.exec','os.posix_spawn'): + with open(os.environ['QA_PROCESS_AUDIT'],'a') as f:f.write(json.dumps({'event':event})+'\\n') + raise AssertionError('No process execution is allowed during this capture fixture') +sys.addaudithook(guard) +`); +const configFile=path.join(temporary,'runtime.json'); +const gitEnv={...process.env,GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:'/dev/null',GIT_TERMINAL_PROMPT:'0'}; +const routes=JSON.parse(execFileSync('python3',['-c',"import json,agent_api;names=agent_api.ProjectFilesOwnerClient.OPERATIONS;print(json.dumps({n.split('.')[1]:{**{k:agent_api.operation(n)[k] for k in ('path','method','query','side_effects')},'header':agent_api.selected_header(n)} for n in names}))"],{cwd:root,encoding:'utf8'})); +let server,origin,auth,socket,session,send,evaluate,serverErrors='',isolation; +const pause=ms=>new Promise(resolve=>setTimeout(resolve,ms)); +async function until(fn,label){for(let i=0;i<200;i++){const result=await fn();if(result)return result;await pause(100);}throw Error('Timed out: '+label);} +function launch(command,args,options={}){const child=spawn(command,args,options);children.push(child);return child;} +async function stop(child){if(!child||child.exitCode!==null||child.signalCode!==null)return;const exited=once(child,'exit');child.kill('SIGTERM');await exited;} +async function start(control,directory,dsn,configured=false){ + process.env.TULDOK_DATABASE_URL=dsn;serverErrors='';let output=''; + server=launch('python3',['-u','app.py','--port','0','--data',control,'--project-files-root','fixture='+directory,...(configured?['--project-runtime-source','inert-fixture='+configFile]:[])],{cwd:root,env:{...process.env,TULDOK_DATABASE_URL:dsn,QA_PROCESS_AUDIT:audit,QA_AUDIT_INSTALLED:path.join(temporary,'audit-installed-'+catalogs.length),PYTHONPATH:auditDir+path.delimiter+(process.env.PYTHONPATH||'')},stdio:['ignore','pipe','pipe']}); + server.stdout.on('data',chunk=>output+=chunk);server.stderr.on('data',chunk=>serverErrors+=chunk); + const port=await until(()=>{if(server.exitCode!==null)throw Error('Server exited: '+serverErrors);return output.match(/127\.0\.0\.1:(\d+)/)?.[1];},'actual server startup'); + assert.ok(fs.existsSync(path.join(temporary,'audit-installed-'+catalogs.length)),'App audit assertion installed');origin='http://127.0.0.1:'+port;catalogs.push({control,dsn});auth=authenticatedFixture();await auth.register(origin,control,root); +} +async function state(){const response=await auth.fetch(origin+routes.status.path,{headers:{'Tuldok-Operation-Contract':routes.status.header}});const value=await response.json();assert.equal(response.status,200,JSON.stringify(value));return value;} +async function key(key,code=key,modifiers=0){for(const type of ['keyDown','keyUp'])await send('Input.dispatchKeyEvent',{type,key,code,modifiers,windowsVirtualKeyCode:{Enter:13,Tab:9,a:65,' ':32}[key]||0,...(type==='keyDown'&&key==='Enter'?{text:'\r',unmodifiedText:'\r'}:{})});} +async function focus(selector){await until(()=>evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e||e.disabled||!e.getClientRects().length)return false;e.scrollIntoView({block:'center'});e.focus();return document.activeElement===e;})()`),'focus '+selector);} +async function fill(selector,value){await focus(selector);await key('a','KeyA',2);await send('Input.insertText',{text:value});} +async function activate(selector){await focus(selector);await key('Enter');} +async function choose(selector,value){await focus(selector);await evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!Array.from(e.options).some(o=>o.value===${JSON.stringify(value)}))throw Error('Missing option');e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('change',{bubbles:true}));e.dispatchEvent(new Event('input',{bubbles:true}));})()`);} +async function settled(){await until(async()=>{const failure=await evaluate('document.getElementById("project-files-error").textContent');if(failure)throw Error('UI error: '+failure);return evaluate('!document.getElementById("project-files-save").disabled');},'acknowledged UI state');} +async function navigate(){await auth.browser(send,origin);await send('Page.navigate',{url:origin+'/project-files'});await until(()=>evaluate('!!document.querySelector("#project-files-root option")&&!document.getElementById("project-files-new").disabled'),'negotiated UI startup');} +async function capture(width){ + await send('Emulation.setDeviceMetricsOverride',{width,height:950,deviceScaleFactor:1,mobile:false});await evaluate('document.getElementById("project-runtime-title").scrollIntoView({block:"start"})'); + await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); + const value=await evaluate('({viewport:innerWidth,width:document.documentElement.scrollWidth,client:document.documentElement.clientWidth,offenders:Array.from(document.querySelectorAll("body *")).filter(e=>e.getClientRects().length&&e.getBoundingClientRect().right>innerWidth+1).map(e=>({tag:e.tagName,id:e.id,width:e.getBoundingClientRect().width,right:e.getBoundingClientRect().right,text:e.textContent.slice(0,100)})).slice(-20)})'); + bounds.push(value);fs.writeFileSync(path.join(report,'bounds-'+width+'.json'),JSON.stringify(value,null,2)+'\n');fs.writeFileSync(path.join(report,'m3-runtime-'+width+'.jpg'),Buffer.from((await send('Page.captureScreenshot',screenshotOptions)).data,'base64')); + assert.ok(value.width<=value.viewport+1,'Responsive overflow: '+JSON.stringify(value)); +} +function snapshot(directory){const files={};function walk(at){for(const item of fs.readdirSync(at,{withFileTypes:true})){if(['.git','.tuldok-local'].includes(item.name))continue;const next=path.join(at,item.name);if(item.isDirectory())walk(next);else if(item.isFile())files[path.relative(directory,next)]=hash(fs.readFileSync(next));else throw Error('Unexpected special fixture file');}}walk(directory);return files;} +function check(value){checks.push(value);console.log('PASS '+value);} +(async()=>{ + isolation=JSON.parse(execFileSync('python3',['-c',"import json,os,psycopg;from psycopg.conninfo import conninfo_to_dict;s=conninfo_to_dict(os.environ['TULDOK_DATABASE_URL']);c=psycopg.connect(os.environ['TULDOK_PROJECT_FILES_COLD_DATABASE_URL']);row=c.execute('SELECT current_database(),current_user,has_database_privilege(current_user,%s,%s)',(s['dbname'],'CONNECT')).fetchone();assert row[0]!=s['dbname'] and not row[2];print(json.dumps(dict(database=row[0],role=row[1],source_connect=row[2])));c.close()"],{cwd:root,encoding:'utf8'})); + execFileSync('python3',['-c',`import json,pathlib,shutil,sys +p=pathlib.Path(sys.argv[1]);python=p/'explicit-python';site=p/'explicit-packages' +(python/'bin').mkdir(parents=True);(python/'lib/python3.12/lib-dynload').mkdir(parents=True) +shutil.copyfile('/usr/bin/true',python/'bin/python3.12') +for i in range(45):(python/'lib/python3.12/lib-dynload'/('fixture-%03d.txt'%i)).write_text('Owned inert runtime bytes Ω '+str(i)) +packages=[] +for name in ('torch','numpy'): + (site/name).mkdir(parents=True);(site/name/'__init__.py').write_text('from pathlib import Path'+chr(10)+'Path('+repr(sys.argv[2])+').write_text("executed")'+chr(10)+'raise AssertionError("Never import fixture")'+chr(10)) + info=site/(name+'-0.0.dist-info');info.mkdir();(info/'METADATA').write_text('Name: '+name+chr(10)+'Version: 0.0'+chr(10));(info/'LICENSE').write_text('Owned fixture');packages.extend([name,info.name]) +(p/'runtime.json').write_text(json.dumps(dict(schema_version=1,python_root=str(python),python_version='3.12',site_packages=str(site),packages=packages)))`,temporary,sentinel],{cwd:root}); + await start(path.join(temporary,'control'),external,sourceDsn,true); + const browser=launch(process.env.BROWSER||'/usr/bin/chromium',['--headless','--no-sandbox','--disable-gpu','--no-first-run','--remote-debugging-port=0','--window-size=1440,950','--user-data-dir='+path.join(temporary,'browser'),'about:blank'],{stdio:'ignore',env:{...process.env,XDG_CONFIG_HOME:temporary,XDG_CACHE_HOME:temporary}}); + const port=await waitForDebugger(browser,path.join(temporary,'browser','DevToolsActivePort')),tabs=await(await fetch('http://127.0.0.1:'+port+'/json')).json(),target=tabs.find(tab=>tab.type==='page'&&tab.url==='about:blank');assert.ok(target); + socket=new WebSocket(target.webSocketDebuggerUrl);await once(socket,'open');session=cdpSession(socket,{onEvent:event=>{if(event.method==='Runtime.exceptionThrown')errors.push(event.params.exceptionDetails);if(event.method==='Network.requestWillBeSent')requests.push({url:event.params.request.url,method:event.params.request.method,body:event.params.request.postData});if(event.method==='Network.responseReceived'&&event.params.response.status>=400)httpErrors.push({url:event.params.response.url,status:event.params.response.status});}});send=session.send; + evaluate=async expression=>{const result=await send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(result.exceptionDetails)throw Error(JSON.stringify(result.exceptionDetails));return result.result.value;}; + await send('Page.enable');await send('Runtime.enable');await send('Network.enable');await navigate(); + await fill('#project-files-directory','alpha');await fill('#project-files-name','M3 offline runtime browser Ω');await activate('#project-files-new');await settled(); + const initial=await state(),project=initial.project.project_id; + const automatic=()=>requests.filter(r=>r.url.includes('/runtime/')); + assert.equal(automatic().length,0,'New does not automatically inspect or capture runtime'); + await activate('#project-runtime-sources');await settled();assert.equal(await evaluate('document.querySelectorAll("#project-runtime-source option").length'),1); + await activate('#project-runtime-inspect');await settled();assert.equal(await evaluate('document.querySelectorAll("#project-runtime-files li").length'),40); + await activate('#project-runtime-files-more');await settled();assert.ok(await evaluate('document.querySelectorAll("#project-runtime-files li").length>40')); + const summary=await evaluate('document.getElementById("project-runtime-summary").textContent'),inventory=summary.match(/[a-f0-9]{64}/)[0]; + assert.match(await evaluate('document.getElementById("project-runtime-layout").textContent'),/torch/);assert.equal(automatic().filter(r=>r.method==='POST').length,0); + check('Keyboard loads configured alias and bounded paged inventory without capture or executing package imports'); + await fill('#project-runtime-reserve','9007199254740991');await focus('#project-runtime-confirm');await key(' ','Space'); + await activate('#project-runtime-capture'); + await until(()=>evaluate('document.getElementById("project-files-error").textContent'),'expected real insufficient-reserve error'); + const failedCapture=automatic().find(r=>r.method==='POST'&&r.url.endsWith('/runtime/capture'));assert.ok(failedCapture);const failedBody=JSON.parse(failedCapture.body); + assert.match(await evaluate('document.getElementById("project-files-error").textContent'),/disk|reserve/i); + await activate('#project-files-refresh'); + await until(()=>evaluate('!document.getElementById("project-runtime-abandon-fields").disabled'),'current original capture recovery control'); + const pending=await state();assert.ok(['open','recovery_required'].includes(pending.state)); + await fill('#project-runtime-abandon-key',failedBody.request_key);await fill('#project-runtime-abandon-reason','Explicitly abandon impossible reserve; preserve original inventory and intent Ω');await activate('#project-runtime-abandon'); + await until(()=>evaluate('document.getElementById("project-files-status").textContent.includes("partial bytes and scientific intent remain")'),'canonical abandonment ACK'); + await activate('#project-files-refresh');await settled(); + await fill('#project-runtime-status-key',failedBody.request_key);await activate('#project-runtime-status-form button');await settled(); + const abandoned=JSON.parse(await evaluate('document.getElementById("project-runtime-status").textContent'));assert.equal(abandoned.state,'abandoned');assert.equal(abandoned.intent.manifest_sha256,inventory);assert.equal(abandoned.abandonment.capture_key,failedBody.request_key);assert.equal(abandoned.result,null); + check('Real reserve failure retains durable intent; named current-owner abandonment records exact key/reason and historical abandoned outcome'); + await activate('#project-runtime-sources');await settled();await activate('#project-runtime-inspect');await settled();await fill('#project-runtime-reserve','0');await focus('#project-runtime-confirm');await key(' ','Space');await activate('#project-runtime-capture');await settled(); + const captured=JSON.parse(await evaluate('document.getElementById("project-runtime-status").textContent'));assert.equal(captured.format,'project_runtime_capture_v1');assert.equal(captured.project_id,project);assert.equal(captured.manifest_sha256,inventory);assert.notEqual(captured.request_key,failedBody.request_key); + await activate('#project-runtime-status-form button');await settled();const historical=JSON.parse(await evaluate('document.getElementById("project-runtime-status").textContent'));assert.equal(historical.state,'captured');assert.deepEqual(historical.result,captured); + assert.ok(!fs.existsSync(sentinel));assert.ok(!fs.existsSync(audit),'Application never attempted subprocess/exec/install'); + check('Fresh explicit confirmed capture copies offline bytes and retains intent/result; no child process or package import executes'); + await activate('#project-files-close');await until(async()=>(await state()).state==='no_project_open','close captured project');await stop(server); + const source=path.join(external,'alpha'),coldRoot=path.join(temporary,'cold-external');fs.mkdirSync(coldRoot);const clone=path.join(coldRoot,'clone'),canonical=snapshot(source); + const git=(...args)=>execFileSync('git',args,{env:gitEnv,stdio:['ignore','pipe','pipe']});git('init','-q','--template=',source);git('-C',source,'add','.');git('-C',source,'-c','user.name=Disposable M3 QA','-c','user.email=qa@example.invalid','-c','commit.gpgsign=false','-c','core.hooksPath=/dev/null','commit','-qm','Inert canonical runtime facts');git('-c','core.hooksPath=/dev/null','clone','-q','--no-hardlinks','--template=',source,clone);if(fs.existsSync(path.join(source,'artifacts')))fs.cpSync(path.join(source,'artifacts'),path.join(clone,'artifacts'),{recursive:true});assert.deepEqual(snapshot(clone),canonical); + // Remove the original configured source before cold Open. Canonical metadata cannot restore it. + fs.rmSync(path.join(temporary,'explicit-python'),{recursive:true});fs.rmSync(path.join(temporary,'explicit-packages'),{recursive:true});fs.unlinkSync(configFile); + const beforeCold=automatic().length;await start(path.join(temporary,'cold-control'),coldRoot,coldDsn);await navigate();await fill('#project-files-directory','clone');await activate('#project-files-open');await settled();assert.equal((await state()).project.project_id,project);assert.equal(automatic().length,beforeCold,'Cold Open does not auto inspect/capture/status'); + await activate('#project-runtime-sources');await settled();assert.equal(await evaluate('document.querySelectorAll("#project-runtime-source option").length'),0); + await fill('#project-runtime-status-key',captured.request_key);await activate('#project-runtime-status-form button');await settled();assert.deepEqual(JSON.parse(await evaluate('document.getElementById("project-runtime-status").textContent')),historical); + await fill('#project-runtime-status-key',failedBody.request_key);await activate('#project-runtime-status-form button');await settled();assert.deepEqual(JSON.parse(await evaluate('document.getElementById("project-runtime-status").textContent')),abandoned); + const cached=execFileSync('python3',['-c',"from pathlib import Path;import sys;print(len(list(Path(sys.argv[1]).rglob('runtime-snapshots'))))",path.join(temporary,'cold-control')],{encoding:'utf8'}).trim();assert.equal(cached,'0'); + await capture(390);await capture(1440);assert.deepEqual(snapshot(clone),canonical);assert.ok(!fs.existsSync(sentinel));assert.ok(!fs.existsSync(audit));assert.deepEqual(errors,[]); + assert.equal(httpErrors.length,1,JSON.stringify(httpErrors));assert.ok(httpErrors[0].url.endsWith('/runtime/capture'));verifySource();assert.equal(hash(fs.readFileSync(__filename)),testSha); + check('Cold keyboard UI on distinct role denied source CONNECT reconstructs exact captured/abandoned facts with no source alias/cache or automatic regeneration; 390/1440 responsive'); + fs.writeFileSync(path.join(report,'receipt.json'),JSON.stringify({status:'pass',source_stable:true,checks,material,test_sha256:testSha,isolation,bounds,project_id:project,captured,abandoned,browser_runtime_errors:errors,expected_http_errors:httpErrors,runtime_requests:automatic(),fixture:'M3 enabled only in derived immutable source. Actual app CLI/PG/Chromium; offline true ELF plus inert never-import packages; app-only audit hook rejects child execution; real impossible-reserve failure and explicit current-marker abandonment; Git canonical clone with explicit artifact transfer; source removed before cold Open. No scientific execution or runtime preflight.'},null,2)+'\n');console.log('Report '+path.join(report,'receipt.json')); +})().catch(async error=>{ + fs.writeFileSync(path.join(report,'failure-receipt.json'),JSON.stringify({status:'failed',error:error.message,checks,material,test_sha256:testSha,isolation,bounds,browser_runtime_errors:errors,http_errors:httpErrors,runtime_requests:requests.filter(r=>r.url.includes("/runtime/"))},null,2)+'\n');console.error(error);console.error('Server:',serverErrors);if(evaluate)try{console.error('UI:',await evaluate('document.body.innerText'));}catch(_){}process.exitCode=1; +}).finally(async()=>{ + session?.close();socket?.close();for(const child of children.reverse())await stop(child); + for(const {control,dsn} of catalogs)try{execFileSync('python3',['-c',"import json,pathlib,os,psycopg;from psycopg import sql;root=pathlib.Path(os.environ['QA_CONTROL']);c=psycopg.connect(os.environ['TULDOK_DATABASE_URL'],autocommit=True);[(c.execute(sql.SQL('DROP SCHEMA IF EXISTS {} CASCADE').format(sql.Identifier('projection_'+v['projection_id'] if v['version']==2 else 'catalog_'+v['dataset_id'])))) for p in root.rglob('.tuldok.json') for v in [json.loads(p.read_text())]];c.close()"],{cwd:root,env:{...process.env,TULDOK_DATABASE_URL:dsn,QA_CONTROL:control},stdio:['ignore','pipe','pipe']});}catch(error){console.error('Owned cleanup failed:',error.message);process.exitCode=1;} + process.env.TULDOK_DATABASE_URL=sourceDsn;fs.rmSync(temporary,{recursive:true,force:true}); +}); diff --git a/tests/browser_project_files_m4.cjs b/tests/browser_project_files_m4.cjs new file mode 100644 index 00000000..3cc06c20 --- /dev/null +++ b/tests/browser_project_files_m4.cjs @@ -0,0 +1,115 @@ +'use strict'; +// Real keyboard-driven named M4 controls, followed by a cold metadata clone. +const assert=require('node:assert/strict'),fs=require('node:fs'),os=require('node:os'),path=require('node:path'),crypto=require('node:crypto'); +const {spawn,execFileSync}=require('node:child_process'),{once}=require('node:events'); +const {authenticatedFixture}=require('./auth_helpers.cjs'),{waitForDebugger}=require('./browser_startup.cjs'),{cdpSession}=require('./browser_cdp_session.cjs'),{qaDirectory,screenshotOptions}=require('./qa_artifacts.cjs'); +const root=path.resolve(__dirname,'..'),hash=raw=>crypto.createHash('sha256').update(raw).digest('hex'); +assert.ok(process.env.TULDOK_DATABASE_URL&&process.env.TULDOK_PROJECT_FILES_COLD_DATABASE_URL,'Disposable source and cold PostgreSQL configurations required'); +assert.ok(process.env.TULDOK_M4_FREEZE_MANIFEST,'Lead must freeze and enable M4 before actual browser acceptance'); +const freeze=JSON.parse(fs.readFileSync(process.env.TULDOK_M4_FREEZE_MANIFEST,'utf8')); +const material={manifest_sha256:hash(fs.readFileSync(process.env.TULDOK_M4_FREEZE_MANIFEST)),files:freeze.files}; +function verifySource(){for(const [file,expected] of Object.entries(material.files))assert.equal(hash(fs.readFileSync(path.join(root,file))),expected,'Frozen source changed: '+file);} +verifySource(); +const testSha=hash(fs.readFileSync(__filename)),temporary=fs.mkdtempSync(path.join(os.tmpdir(),'tuldok-m4-browser-')),report=qaDirectory(root,'project-files-m4'); +const sourceDsn=process.env.TULDOK_DATABASE_URL,coldDsn=process.env.TULDOK_PROJECT_FILES_COLD_DATABASE_URL; +assert.notEqual(sourceDsn,coldDsn); +const external=path.join(temporary,'external');fs.mkdirSync(external); +const children=[],catalogs=[],checks=[],errors=[],httpErrors=[],bounds=[]; +const gitEnv={...process.env,GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:'/dev/null',GIT_TERMINAL_PROMPT:'0'}; +const routes=JSON.parse(execFileSync('python3',['-c',"import json,agent_api;names=agent_api.ProjectFilesOwnerClient.OPERATIONS;print(json.dumps({n.split('.')[1]:{**{k:agent_api.operation(n)[k] for k in ('path','method','query','side_effects')},'header':agent_api.selected_header(n)} for n in names}))"],{cwd:root,encoding:'utf8'})); +let server,origin,auth,socket,session,send,evaluate,serverErrors='',isolation; +const pause=ms=>new Promise(resolve=>setTimeout(resolve,ms)); +async function until(fn,label){for(let i=0;i<200;i++){const result=await fn();if(result)return result;await pause(100);}throw Error('Timed out: '+label);} +function launch(command,args,options={}){const child=spawn(command,args,options);children.push(child);return child;} +async function stop(child){if(!child||child.exitCode!==null||child.signalCode!==null)return;const exited=once(child,'exit');child.kill('SIGTERM');await exited;} +async function start(control,directory,dsn){ + process.env.TULDOK_DATABASE_URL=dsn;serverErrors='';let output=''; + server=launch('python3',['-u','app.py','--port','0','--data',control,'--project-files-root','fixture='+directory],{cwd:root,env:{...process.env,TULDOK_DATABASE_URL:dsn},stdio:['ignore','pipe','pipe']}); + server.stdout.on('data',chunk=>output+=chunk);server.stderr.on('data',chunk=>serverErrors+=chunk); + const port=await until(()=>{if(server.exitCode!==null)throw Error('Server exited: '+serverErrors);return output.match(/127\.0\.0\.1:(\d+)/)?.[1];},'actual server startup'); + origin='http://127.0.0.1:'+port;catalogs.push({control,dsn});auth=authenticatedFixture();await auth.register(origin,control,root); +} +async function state(){const response=await auth.fetch(origin+routes.status.path,{headers:{'Tuldok-Operation-Contract':routes.status.header}});const value=await response.json();assert.equal(response.status,200,JSON.stringify(value));return value;} +async function call(action,fields={}){ + const current=await state(),definition=routes[action],arguments_={binding:current.binding,[definition.side_effects?'expected_checkpoint':'checkpoint']:current.project.checkpoint,...(definition.side_effects?{request_key:crypto.randomUUID().replaceAll('-','')} :{}),...fields}; + let target=definition.path;for(const [key,value] of Object.entries(arguments_))target=target.replace('{'+key+'}',encodeURIComponent(value)); + const query=new URLSearchParams(Object.entries(arguments_).filter(([key])=>definition.query.includes(key)));if(query.size)target+='?'+query; + const body=Object.fromEntries(Object.entries(arguments_).filter(([key])=>!definition.query.includes(key)&&!definition.path.includes('{'+key+'}'))); + const response=await auth.fetch(origin+target,{method:definition.method,headers:{'Content-Type':'application/json','Tuldok-Operation-Contract':definition.header},...(definition.method==='POST'?{body:JSON.stringify(body)}:{})}); + const value=await response.json();assert.equal(response.status,200,JSON.stringify(value));return value.value; +} +async function key(key,code=key,modifiers=0){for(const type of ['keyDown','keyUp'])await send('Input.dispatchKeyEvent',{type,key,code,modifiers,windowsVirtualKeyCode:{Enter:13,Tab:9,a:65}[key]||0,...(type==='keyDown'&&key==='Enter'?{text:'\r',unmodifiedText:'\r'}:{})});} +async function focus(selector){await until(()=>evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e||e.disabled||!e.getClientRects().length)return false;e.scrollIntoView({block:'center'});e.focus();return document.activeElement===e;})()`),'focus '+selector);} +async function fill(selector,value){await focus(selector);await key('a','KeyA',2);await send('Input.insertText',{text:value});} +async function activate(selector){await focus(selector);await key('Enter');} +async function choose(selector,value){await focus(selector);await evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!Array.from(e.options).some(o=>o.value===${JSON.stringify(value)}))throw Error('Missing option');e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('change',{bubbles:true}));e.dispatchEvent(new Event('input',{bubbles:true}));})()`);} +async function settled(){await until(async()=>{const failure=await evaluate('document.getElementById("project-files-error").textContent');if(failure)throw Error('UI error: '+failure);return evaluate('!document.getElementById("project-files-save").disabled');},'acknowledged UI state');} +async function navigate(){await auth.browser(send,origin);await send('Page.navigate',{url:origin+'/project-files'});await until(()=>evaluate('!!document.querySelector("#project-files-root option")&&!document.getElementById("project-files-new").disabled'),'negotiated UI startup');} +async function working(kind,file,raw,revision=0){ + await choose('#project-working-kind',kind);await fill('#project-working-path',file);await fill('#project-working-revision',String(revision));await fill('#project-working-sha',hash(raw)); + await activate('#project-working-adopt-form button[type="submit"]');await settled(); + assert.equal(await evaluate('document.getElementById("project-working-text").value'),raw.toString()); +} +async function inspectFile(file){ + await activate('#project-working-load');await settled(); + await evaluate(`(()=>{const b=Array.from(document.querySelectorAll('#project-working-list button')).find(e=>e.textContent.startsWith(${JSON.stringify(file+' ·')}));if(!b)throw Error('Missing working file');b.dataset.qaSelected='yes';})()`); + await activate('#project-working-list button[data-qa-selected="yes"]');await settled(); +} +async function capture(width){ + await send('Emulation.setDeviceMetricsOverride',{width,height:950,deviceScaleFactor:1,mobile:false});await evaluate('document.getElementById("project-intent-title").scrollIntoView({block:"start"})'); + await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); + const value=await evaluate('({viewport:innerWidth,width:document.documentElement.scrollWidth,client:document.documentElement.clientWidth,offenders:Array.from(document.querySelectorAll("body *")).filter(e=>e.getClientRects().length&&e.getBoundingClientRect().right>innerWidth+1).map(e=>({tag:e.tagName,id:e.id,width:e.getBoundingClientRect().width,right:e.getBoundingClientRect().right,text:e.textContent.slice(0,100)})).slice(-20)})'); + bounds.push(value);fs.writeFileSync(path.join(report,'bounds-'+width+'.json'),JSON.stringify(value,null,2)+'\n');fs.writeFileSync(path.join(report,'m4-cold-'+width+'.jpg'),Buffer.from((await send('Page.captureScreenshot',screenshotOptions)).data,'base64')); + assert.ok(value.width<=value.viewport+1,'Responsive overflow: '+JSON.stringify(value)); +} +function snapshot(directory){const files={};function walk(at){for(const item of fs.readdirSync(at,{withFileTypes:true})){if(['.git','.tuldok-local'].includes(item.name))continue;const next=path.join(at,item.name);if(item.isDirectory())walk(next);else if(item.isFile())files[path.relative(directory,next)]=hash(fs.readFileSync(next));else throw Error('Unexpected special fixture file');}}walk(directory);return files;} +function check(value){checks.push(value);console.log('PASS '+value);} +async function inspectIntent(){ + await activate('#project-intents-load');await settled();await until(()=>evaluate('document.querySelectorAll("#project-intents-list button").length===1'),'one unresolved recipe'); + await activate('#project-intents-list button');await settled(); + return JSON.parse(await evaluate('document.getElementById("project-intent-detail").textContent')); +} +(async()=>{ + isolation=JSON.parse(execFileSync('python3',['-c',"import json,os,psycopg;from psycopg.conninfo import conninfo_to_dict;s=conninfo_to_dict(os.environ['TULDOK_DATABASE_URL']);c=psycopg.connect(os.environ['TULDOK_PROJECT_FILES_COLD_DATABASE_URL']);row=c.execute('SELECT current_database(),current_user,has_database_privilege(current_user,%s,%s)',(s['dbname'],'CONNECT')).fetchone();assert row[0]!=s['dbname'] and not row[2];print(json.dumps(dict(database=row[0],role=row[1],source_connect=row[2])));c.close()"],{cwd:root,encoding:'utf8'})); + await start(path.join(temporary,'control'),external,sourceDsn); + const browser=launch(process.env.BROWSER||'/usr/bin/chromium',['--headless','--no-sandbox','--disable-gpu','--no-first-run','--remote-debugging-port=0','--window-size=1440,950','--user-data-dir='+path.join(temporary,'browser'),'about:blank'],{stdio:'ignore',env:{...process.env,XDG_CONFIG_HOME:temporary,XDG_CACHE_HOME:temporary}}); + const port=await waitForDebugger(browser,path.join(temporary,'browser','DevToolsActivePort')),tabs=await(await fetch('http://127.0.0.1:'+port+'/json')).json(),target=tabs.find(tab=>tab.type==='page'&&tab.url==='about:blank');assert.ok(target); + socket=new WebSocket(target.webSocketDebuggerUrl);await once(socket,'open');session=cdpSession(socket,{onEvent:event=>{if(event.method==='Runtime.exceptionThrown')errors.push(event.params.exceptionDetails);if(event.method==='Network.responseReceived'&&event.params.response.status>=400)httpErrors.push({url:event.params.response.url,status:event.params.response.status});}});send=session.send; + evaluate=async expression=>{const result=await send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(result.exceptionDetails)throw Error(JSON.stringify(result.exceptionDetails));return result.result.value;}; + await send('Page.enable');await send('Runtime.enable');await send('Network.enable');await navigate(); + await fill('#project-files-directory','alpha');await fill('#project-files-name','M4 original browser Ω');await activate('#project-files-new');await settled(); + const originalState=await state(),originalProject=originalState.project.project_id,source=path.join(external,'alpha'),sentinel=path.join(temporary,'EXECUTED'); + fs.mkdirSync(path.join(source,'scripts'));fs.mkdirSync(path.join(source,'notes')); + const first=Buffer.from('from pathlib import Path\nPath('+JSON.stringify(sentinel)+').write_text("executed")\n# authored Ω 山\n'),second=Buffer.concat([first,Buffer.from('# exact second decision 🌸\n')]); + fs.writeFileSync(path.join(source,'scripts/inert.py'),first);fs.writeFileSync(path.join(source,'notes/empty.md'),''); + await working('script','scripts/inert.py',first);await working('note','notes/empty.md',Buffer.alloc(0)); + fs.writeFileSync(path.join(source,'scripts/inert.py'),second);await inspectFile('scripts/inert.py'); + assert.equal(await evaluate('document.getElementById("project-working-text").value'),first.toString());assert.equal(await evaluate('document.getElementById("project-working-sha").value'),hash(second)); + await activate('#project-working-adopt-form button[type="submit"]');await settled();assert.equal(await evaluate('document.getElementById("project-working-text").value'),second.toString()); + await activate('#project-working-history');await settled();assert.equal(await evaluate('document.querySelectorAll("#project-working-history-list button").length'),2); + await activate('#project-working-history-list button');await settled();assert.equal(await evaluate('document.getElementById("project-working-text").value'),first.toString());assert.ok(!fs.existsSync(sentinel)); + check('Actual keyboard controls adopt script and empty note, review current changed digest and select exact older captured revision without execution'); + const draft=JSON.parse(execFileSync('python3',['-c',"import json,sys;sys.path.insert(0,'tests');from test_project_material_fork import recipe;v=recipe(sys.argv[1]);print(json.dumps({k:x for k,x in v.items() if k not in ('format','id','revision')}))",originalProject],{cwd:root,encoding:'utf8'})); + const retainedRecipe=await call('recipe_create',draft);await activate('#project-files-refresh');await settled(); + const sourceBefore=snapshot(source),sourceCheckpoint=(await state()).project.checkpoint; + await fill('#project-save-as-directory','independent');await fill('#project-save-as-name','M4 independent browser Ω');await activate('#project-files-save-as');await settled(); + const forkState=await state();assert.notEqual(forkState.project.project_id,originalProject);assert.notEqual(forkState.binding,originalState.binding);assert.deepEqual(snapshot(source),sourceBefore); + await activate('#project-fork-load');await settled();assert.match(await evaluate('document.getElementById("project-fork-summary").textContent'),new RegExp(sourceCheckpoint)); + const intent=await inspectIntent();assert.deepEqual(intent.current.definition,retainedRecipe);assert.equal(intent.fork_origin.project_id,originalProject);assert.equal(intent.current.definition.runtime.catalog_id,originalProject);assert.ok(intent.current.required_reselection.includes('runtime')); + await activate('#project-intent-capabilities');await settled();assert.match(await evaluate('document.getElementById("project-intents-list").textContent'),/Reselect/);assert.ok(!fs.existsSync(sentinel)); + check('Actual named Save As selects new identity; fork mapping and selected readonly original binding intent retain source parameters and require reselection'); + const fork=path.join(external,'independent');await activate('#project-files-close');await until(async()=>(await state()).state==='no_project_open','close before cold copy');await stop(server); + const coldRoot=path.join(temporary,'cold-external');fs.mkdirSync(coldRoot);const clone=path.join(coldRoot,'clone'),canonical=snapshot(fork); + const git=(...args)=>execFileSync('git',args,{env:gitEnv,stdio:['ignore','pipe','pipe']});git('init','-q','--template=',fork);git('-C',fork,'add','.');git('-C',fork,'-c','user.name=Disposable M4 QA','-c','user.email=qa@example.invalid','-c','commit.gpgsign=false','-c','core.hooksPath=/dev/null','commit','-qm','Exact independent M4 project');git('-c','core.hooksPath=/dev/null','clone','-q','--no-hardlinks','--template=',fork,clone);if(fs.existsSync(path.join(fork,'artifacts')))fs.cpSync(path.join(fork,'artifacts'),path.join(clone,'artifacts'),{recursive:true});assert.deepEqual(snapshot(clone),canonical); + await start(path.join(temporary,'cold-control'),coldRoot,coldDsn);await navigate();await fill('#project-files-directory','clone');await activate('#project-files-open');await settled();assert.deepEqual((await state()).project,forkState.project); + await inspectFile('scripts/inert.py');assert.equal(await evaluate('document.getElementById("project-working-text").value'),second.toString());await activate('#project-working-history');await settled();await activate('#project-working-history-list button');await settled();assert.equal(await evaluate('document.getElementById("project-working-text").value'),first.toString()); + assert.deepEqual(await inspectIntent(),intent);await capture(390);await capture(1440);assert.deepEqual(snapshot(clone),canonical);assert.ok(!fs.existsSync(sentinel));assert.deepEqual(errors,[]);assert.deepEqual(httpErrors,[]);verifySource();assert.equal(hash(fs.readFileSync(__filename)),testSha); + check('Cold UI on distinct role denied source CONNECT reconstructs exact fork, working-file history and unresolved intent; 390/1440 responsive and sentinel inert'); + fs.writeFileSync(path.join(report,'receipt.json'),JSON.stringify({status:'pass',checks,material,test_sha256:testSha,isolation,bounds,project:forkState.project,source_project:originalProject,browser_runtime_errors:errors,http_errors:httpErrors,canonical_sha256:canonical,fixture:'Real named keyboard UI; explicitly authored inert recipe references; local Git metadata clone plus explicit untracked payload transfer; no runtime/preflight'},null,2)+'\n');console.log('Report '+path.join(report,'receipt.json')); +})().catch(async error=>{ + fs.writeFileSync(path.join(report,'failure-receipt.json'),JSON.stringify({status:'failed',error:error.message,checks,material,test_sha256:testSha,isolation,bounds,browser_runtime_errors:errors,http_errors:httpErrors},null,2)+'\n');console.error(error);console.error('Server:',serverErrors);if(evaluate)try{console.error('UI:',await evaluate('document.body.innerText'));}catch(_){}process.exitCode=1; +}).finally(async()=>{ + session?.close();socket?.close();for(const child of children.reverse())await stop(child); + for(const {control,dsn} of catalogs)try{execFileSync('python3',['-c',"import json,pathlib,os,psycopg;from psycopg import sql;root=pathlib.Path(os.environ['QA_CONTROL']);c=psycopg.connect(os.environ['TULDOK_DATABASE_URL'],autocommit=True);[(c.execute(sql.SQL('DROP SCHEMA IF EXISTS {} CASCADE').format(sql.Identifier('projection_'+v['projection_id'] if v['version']==2 else 'catalog_'+v['dataset_id'])))) for p in root.rglob('.tuldok.json') for v in [json.loads(p.read_text())]];c.close()"],{cwd:root,env:{...process.env,TULDOK_DATABASE_URL:dsn,QA_CONTROL:control},stdio:['ignore','pipe','pipe']});}catch(error){console.error('Owned cleanup failed:',error.message);process.exitCode=1;} + process.env.TULDOK_DATABASE_URL=sourceDsn;fs.rmSync(temporary,{recursive:true,force:true}); +}); diff --git a/tests/browser_project_files_native.cjs b/tests/browser_project_files_native.cjs new file mode 100644 index 00000000..a4297d5b --- /dev/null +++ b/tests/browser_project_files_native.cjs @@ -0,0 +1,103 @@ +'use strict'; +// Actual current native probe and zero-GO acknowledgement recovery; no model fit. +const assert=require('node:assert/strict'),fs=require('node:fs'),os=require('node:os'),path=require('node:path'),crypto=require('node:crypto'); +const {spawn,execFileSync}=require('node:child_process'),{once}=require('node:events'),readline=require('node:readline'); +const {authenticatedFixture}=require('./auth_helpers.cjs'),{waitForDebugger}=require('./browser_startup.cjs'),{cdpSession}=require('./browser_cdp_session.cjs'),{qaDirectory,screenshotOptions}=require('./qa_artifacts.cjs'); +const root=path.resolve(__dirname,'..'),hash=raw=>crypto.createHash('sha256').update(raw).digest('hex'); +assert.equal(process.env.TULDOK_NATIVE_PROJECT_GO,'1','Explicit native qualification GO required'); +assert.ok(process.env.TULDOK_DATABASE_URL&&process.env.TULDOK_PROJECT_FILES_COLD_DATABASE_URL,'Disposable source and cold PostgreSQL configurations required'); +assert.ok(process.env.TULDOK_NATIVE_PUBLIC_FREEZE_MANIFEST,'Lead must freeze and qualify isolated M3 before actual browser acceptance'); +const freeze=JSON.parse(fs.readFileSync(process.env.TULDOK_NATIVE_PUBLIC_FREEZE_MANIFEST,'utf8')); +const material={manifest_sha256:hash(fs.readFileSync(process.env.TULDOK_NATIVE_PUBLIC_FREEZE_MANIFEST)),files:freeze.files}; +function verifySource(){for(const [file,expected] of Object.entries(material.files))assert.equal(hash(fs.readFileSync(path.join(root,file))),expected,'Frozen source changed: '+file);} +verifySource(); +const testSha=hash(fs.readFileSync(__filename)),temporary=fs.mkdtempSync(path.join(os.tmpdir(),'tuldok-native-browser-')),report=qaDirectory(root,'project-native'); +const sourceDsn=process.env.TULDOK_DATABASE_URL,coldDsn=process.env.TULDOK_PROJECT_FILES_COLD_DATABASE_URL; +assert.notEqual(sourceDsn,coldDsn); +const children=[],catalogs=[],checks=[],errors=[],httpErrors=[],bounds=[],requests=[]; +const gitEnv={...process.env,GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:'/dev/null',GIT_TERMINAL_PROMPT:'0'}; +const routes=JSON.parse(execFileSync('python3',['-c',"import json,agent_api;names=agent_api.ProjectFilesOwnerClient.OPERATIONS;print(json.dumps({n.split('.')[1]:{**{k:agent_api.operation(n)[k] for k in ('path','method','query','side_effects')},'header':agent_api.selected_header(n)} for n in names}))"],{cwd:root,encoding:'utf8'})); +let server,origin,auth,socket,session,send,evaluate,serverErrors='',isolation,fixture,sourceFacts,coldFacts;const fixtures=[]; +const pause=ms=>new Promise(resolve=>setTimeout(resolve,ms)); +async function until(fn,label){for(let i=0;i<600;i++){const result=await fn();if(result)return result;await pause(100);}throw Error('Timed out: '+label);} +function launch(command,args,options={}){const child=spawn(command,args,options);children.push(child);return child;} +async function stop(child){if(!child||child.exitCode!==null||child.signalCode!==null)return;const exited=once(child,'exit');child.kill('SIGTERM');await exited;} +async function start(directory,dsn,cold=false){ + process.env.TULDOK_DATABASE_URL=dsn;serverErrors='';let ready; + server=launch('python3',['-u','tests/test_project_native_clients_http.py','--serve','--root',directory,...(cold?['--cold']:[])],{cwd:root,env:{...process.env,TULDOK_DATABASE_URL:dsn},stdio:['pipe','pipe','pipe']}); + const pending=new Map(),lines=readline.createInterface({input:server.stdout}); + lines.on('line',line=>{let value;try{value=JSON.parse(line);}catch(_){serverErrors+='\nUnexpected stdout: '+line;return;}if(value.origin){ready=value;return;}const wait=pending.get(value.id);if(wait){pending.delete(value.id);value.error?wait.reject(Error(value.error)):wait.resolve(value.result);}}); + server.stderr.on('data',chunk=>serverErrors+=chunk); + await until(()=>{if(server.exitCode!==null)throw Error('Fixture app exited: '+serverErrors);return ready;},'actual fixture app startup'); + const process_=server; + const host={process:process_,ready,async command(action){const id=crypto.randomUUID();return new Promise((resolve,reject)=>{const timeout=setTimeout(()=>{pending.delete(id);reject(Error('Fixture command timed out: '+action));},30000);pending.set(id,{resolve:value=>{clearTimeout(timeout);resolve(value);},reject:error=>{clearTimeout(timeout);reject(error);}});process_.stdin.write(JSON.stringify({id,action})+'\n');});},async stop(){if(process_.exitCode!==null)return;const exited=once(process_,'exit');await host.command('stop');const [code]=await exited;assert.equal(code,0,'Fixture cleanup exit: '+serverErrors);}}; + fixtures.push(host);fixture=host;origin=ready.origin;auth=authenticatedFixture();await auth.register(origin,ready.control,root);return host; +} +async function state(){const response=await auth.fetch(origin+routes.status.path,{headers:{'Tuldok-Operation-Contract':routes.status.header}});const value=await response.json();assert.equal(response.status,200,JSON.stringify(value));return value;} +async function key(key,code=key,modifiers=0){for(const type of ['keyDown','keyUp'])await send('Input.dispatchKeyEvent',{type,key,code,modifiers,windowsVirtualKeyCode:{Enter:13,Tab:9,a:65,' ':32}[key]||0,...(type==='keyDown'&&key==='Enter'?{text:'\r',unmodifiedText:'\r'}:{})});} +async function focus(selector){await until(()=>evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e||e.disabled||!e.getClientRects().length)return false;e.scrollIntoView({block:'center'});e.focus();return document.activeElement===e;})()`),'focus '+selector);} +async function fill(selector,value){await focus(selector);await key('a','KeyA',2);await send('Input.insertText',{text:value});} +async function activate(selector){await focus(selector);await key('Enter');} +async function choose(selector,value){await focus(selector);await evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!Array.from(e.options).some(o=>o.value===${JSON.stringify(value)}))throw Error('Missing option');e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('change',{bubbles:true}));e.dispatchEvent(new Event('input',{bubbles:true}));})()`);} +async function settled(){await until(async()=>{const failure=await evaluate('document.getElementById("project-files-error").textContent');if(failure)throw Error('UI error: '+failure);return evaluate('!document.getElementById("project-files-save").disabled');},'acknowledged UI state');} +async function navigate(){await auth.browser(send,origin);await send('Page.navigate',{url:origin+'/project-files'});await until(()=>evaluate('!!document.querySelector("#project-files-root option")&&!document.getElementById("project-files-new").disabled'),'negotiated UI startup');} +async function capture(width,label){ + await send('Emulation.setDeviceMetricsOverride',{width,height:950,deviceScaleFactor:1,mobile:false});await evaluate('document.getElementById("project-native-title").scrollIntoView({block:"start"})'); + await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); + const value=await evaluate('({viewport:innerWidth,width:document.documentElement.scrollWidth,client:document.documentElement.clientWidth,offenders:Array.from(document.querySelectorAll("body *")).filter(e=>e.getClientRects().length&&e.getBoundingClientRect().right>innerWidth+1).map(e=>({tag:e.tagName,id:e.id,width:e.getBoundingClientRect().width,right:e.getBoundingClientRect().right,text:e.textContent.slice(0,100)})).slice(-20)})'); + bounds.push(value);fs.writeFileSync(path.join(report,label+'-bounds-'+width+'.json'),JSON.stringify(value,null,2)+'\n');fs.writeFileSync(path.join(report,label+'-'+width+'.jpg'),Buffer.from((await send('Page.captureScreenshot',screenshotOptions)).data,'base64')); + assert.ok(value.width<=value.viewport+1,'Responsive overflow: '+JSON.stringify(value)); +} +function snapshot(directory){const files={};function walk(at){for(const item of fs.readdirSync(at,{withFileTypes:true})){if(['.git','.tuldok-local'].includes(item.name))continue;const next=path.join(at,item.name);if(item.isDirectory())walk(next);else if(item.isFile())files[path.relative(directory,next)]=hash(fs.readFileSync(next));else throw Error('Unexpected special fixture file');}}walk(directory);return files;} +function check(value){checks.push(value);console.log('PASS '+value);} +(async()=>{ + assert.ok(process.env.TULDOK_TRAINING_PYTHON,'Current explicitly qualified native interpreter required'); + isolation=JSON.parse(execFileSync('python3',['-c',"import json,os,psycopg;from psycopg.conninfo import conninfo_to_dict;s=conninfo_to_dict(os.environ['TULDOK_DATABASE_URL']);c=psycopg.connect(os.environ['TULDOK_PROJECT_FILES_COLD_DATABASE_URL']);row=c.execute('SELECT current_database(),current_user,has_database_privilege(current_user,%s,%s)',(s['dbname'],'CONNECT')).fetchone();assert row[0]!=s['dbname'] and not row[2];print(json.dumps(dict(database=row[0],role=row[1],source_connect=row[2])));c.close()"],{cwd:root,encoding:'utf8'})); + const sourceHost=await start(path.join(temporary,'source-host'),sourceDsn); + const browser=launch(process.env.BROWSER||'/usr/bin/chromium',['--headless','--no-sandbox','--disable-gpu','--no-first-run','--remote-debugging-port=0','--window-size=1440,950','--user-data-dir='+path.join(temporary,'browser'),'about:blank'],{stdio:'ignore',env:{...process.env,XDG_CONFIG_HOME:temporary,XDG_CACHE_HOME:temporary}}); + const port=await waitForDebugger(browser,path.join(temporary,'browser','DevToolsActivePort')),tabs=await(await fetch('http://127.0.0.1:'+port+'/json')).json(),target=tabs.find(tab=>tab.type==='page'&&tab.url==='about:blank');assert.ok(target); + socket=new WebSocket(target.webSocketDebuggerUrl);await once(socket,'open');session=cdpSession(socket,{onEvent:event=>{if(event.method==='Runtime.exceptionThrown')errors.push(event.params.exceptionDetails);if(event.method==='Network.requestWillBeSent')requests.push({url:event.params.request.url,method:event.params.request.method,body:event.params.request.postData});if(event.method==='Network.responseReceived'&&event.params.response.status>=400)httpErrors.push({url:event.params.response.url,status:event.params.response.status});}});send=session.send; + evaluate=async expression=>{const result=await send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(result.exceptionDetails)throw Error(JSON.stringify(result.exceptionDetails));return result.result.value;}; + await send('Page.enable');await send('Runtime.enable');await send('Network.enable');await navigate(); + await fill('#project-files-directory','alpha');await fill('#project-files-name','Native keyboard CPU fixture Ω');await activate('#project-files-new');await settled(); + const project=(await state()).project.project_id,graph=await fixture.command('seed');assert.equal(graph.records,6);await activate('#project-files-refresh');await settled(); + const nativeRequests=()=>requests.filter(value=>value.url.includes('/execution/native/')); + assert.equal(nativeRequests().length,0);assert.equal((await fixture.command('facts')).go_events.length,0); + await activate('#project-native-graphs');await settled();assert.equal(await evaluate('document.querySelectorAll("#project-native-graph option").length'),1);assert.equal(nativeRequests().length,0); + await focus('#project-native-probe-confirm');await key(' ','Space');await activate('#project-native-preflight'); + await until(()=>evaluate('document.getElementById("project-files-status").textContent.includes("Native validation succeeded")'),'actual current CPU probe acknowledgement');await settled(); + let facts=await fixture.command('facts');assert.equal(facts.go_events.length,1);assert.equal(facts.workspace_runs,0);assert.ok(facts.go_events.every(event=>!event.in_transaction)); + assert.equal(await evaluate('document.getElementById("project-native-submit-fields").disabled'),false); + const before=nativeRequests().length;await activate('#project-native-submit');await evaluate('new Promise(resolve=>requestAnimationFrame(resolve))');assert.equal(nativeRequests().length,before,'Unchecked separate Start consent prevents dispatch'); + await capture(390,'validated');await capture(1440,'validated'); + check('Actual keyboard native preflight runs one current CPU probe; saved metadata loading and unchecked separate Start never dispatch a training Run'); + await fill('#project-native-run-id','9'.repeat(32));await focus('#project-native-retry-confirm');await key(' ','Space');await activate('#project-native-retry'); + await until(()=>evaluate('document.getElementById("project-files-error").textContent'),'typed current-only retry refusal');assert.match(await evaluate('document.getElementById("project-files-error").textContent'),/current local Run|current native Run/i); + facts=await fixture.command('facts');assert.equal(facts.go_events.length,1);assert.equal(facts.workspace_runs,0); + await activate('#project-files-discard');await activate('#project-files-refresh');await settled();await activate('#project-native-graphs');await settled(); + await fixture.command('install_fault');await focus('#project-native-probe-confirm');await key(' ','Space');await activate('#project-native-preflight'); + await until(()=>evaluate('document.getElementById("project-files-error").textContent'),'actual canonical/SQL acknowledgement fault');facts=await fixture.command('facts');assert.deepEqual(facts.fired,[true]);assert.equal(facts.go_events.length,1,'Failed GO intent SQL acknowledgement must send no second GO'); + await fixture.command('clear_fault');await activate('#project-files-refresh');await until(()=>evaluate('!document.getElementById("project-execution-health").disabled'),'recovery health current phase control');await activate('#project-execution-health'); + await until(()=>evaluate('document.querySelectorAll("#project-native-phases button").length===1'),'one original unresolved current phase ID'); + await evaluate(`(()=>{const button=Array.from(document.querySelectorAll('#project-native-phases button')).find(value=>/ · (ready|go_intent|unknown)$/.test(value.textContent));if(!button)throw Error('Missing original unresolved phase');button.dataset.qaSelected='yes';})()`); + await activate('#project-native-phases button[data-qa-selected="yes"]');const selectedPhase=await evaluate('document.getElementById("project-native-phase-id").value');assert.match(selectedPhase,/^[a-f0-9]{32}$/); + await activate('#project-native-reconcile');await until(()=>evaluate('document.getElementById("project-files-status").textContent.includes("does not start or resume")'),'original current phase receipt reconciliation'); + facts=await fixture.command('facts');assert.equal(facts.go_events.length,1);assert.equal(facts.pending,false);assert.equal(facts.available,true);assert.equal(facts.workspace_runs,0); + await activate('#project-files-discard');await activate('#project-files-refresh');await settled();sourceFacts=await fixture.command('facts'); + check('Named retry refuses absent current Run; real deferrable SQL fault after canonical GO intent sends zero GO, then keyboard health phase selection and explicit reconciliation restore availability without execution'); + const source=path.join(sourceHost.ready.external,'alpha'),canonical=snapshot(source),saved=(await state()).project; + await activate('#project-files-close');await until(async()=>(await state()).state==='no_project_open','close before cold clone');await sourceHost.stop(); + const coldHostRoot=path.join(temporary,'cold-host'),external=path.join(coldHostRoot,'external');fs.mkdirSync(external,{recursive:true});const clone=path.join(external,'clone'); + const git=(...args)=>execFileSync('git',args,{env:gitEnv,stdio:['ignore','pipe','pipe']});git('init','-q','--template=',source);git('-C',source,'add','.');git('-C',source,'-c','user.name=Disposable native QA','-c','user.email=qa@example.invalid','-c','commit.gpgsign=false','-c','core.hooksPath=/dev/null','commit','-qm','Inert native probe science and exact failed phase');git('-c','core.hooksPath=/dev/null','clone','-q','--no-hardlinks','--template=',source,clone);if(fs.existsSync(path.join(source,'artifacts')))fs.cpSync(path.join(source,'artifacts'),path.join(clone,'artifacts'),{recursive:true});assert.deepEqual(snapshot(clone),canonical); + fs.rmSync(path.join(temporary,'source-host'),{recursive:true,force:true}); + const beforeCold=nativeRequests().length;await start(coldHostRoot,coldDsn,true);await navigate();await fill('#project-files-directory','clone');await activate('#project-files-open');await settled();assert.deepEqual((await state()).project,saved);assert.equal(nativeRequests().length,beforeCold); + await activate('#project-execution-health');await until(()=>evaluate('document.getElementById("project-execution-summary").textContent.includes("0 original current native phases")'),'cold phases remain historical');assert.equal(await evaluate('document.querySelectorAll("#project-native-phases button").length'),0); + await activate('#project-native-graphs');await settled();assert.equal(await evaluate('document.querySelectorAll("#project-native-graph option").length'),1);coldFacts=await fixture.command('facts');assert.equal(coldFacts.portable_phase_count,sourceFacts.portable_phase_count);assert.ok(coldFacts.portable_phase_count>=2);assert.deepEqual(coldFacts.go_events,[]);assert.deepEqual(coldFacts.forbidden,[]);assert.equal(coldFacts.workspace_runs,0);assert.deepEqual(snapshot(clone),canonical); + await capture(390,'cold');await capture(1440,'cold');assert.deepEqual(errors,[]);assert.equal(httpErrors.length,2,JSON.stringify(httpErrors));assert.ok(httpErrors.some(value=>value.status===409&&value.url.endsWith('/retry')));assert.ok(httpErrors.some(value=>value.status===503&&value.url.endsWith('/preflight')));verifySource();assert.equal(hash(fs.readFileSync(__filename)),testSha); + check('Cold keyboard Open on distinct role denied source CONNECT preserves canonical native facts but no local phases/Run owner; removed original host is not followed and 390/1440 layouts fit'); + fs.writeFileSync(path.join(report,'receipt.json'),JSON.stringify({status:'pass',source_stable:true,checks,material,test_sha256:testSha,isolation,bounds,project_id:project,source_facts:sourceFacts,cold_facts:coldFacts,browser_runtime_errors:errors,expected_http_errors:httpErrors,native_requests:nativeRequests(),fixture:'Actual app handler/PG/Chromium and explicit qualified CPU interpreter; real source/release, one native probe, real deferred SQL ACK fault and no second GO; no model fit or training Run. Test-only stdin fixture commands seed authored data and wrap the actual before_commit hook; native owners unmocked. Cold host forbids process preparation/Runs construction as assertions.'},null,2)+'\n');console.log('Report '+path.join(report,'receipt.json')); +})().catch(async error=>{ + fs.writeFileSync(path.join(report,'failure-receipt.json'),JSON.stringify({status:'failed',error:error.message,checks,material,test_sha256:testSha,isolation,bounds,source_facts:sourceFacts,cold_facts:coldFacts,browser_runtime_errors:errors,http_errors:httpErrors,native_requests:requests.filter(value=>value.url.includes('/execution/native/'))},null,2)+'\n');console.error(error);console.error('Server:',serverErrors);if(evaluate)try{console.error('UI:',await evaluate('document.body.innerText'));}catch(_){}process.exitCode=1; +}).finally(async()=>{ + session?.close();socket?.close();for(const host of fixtures.reverse())try{await host.stop();}catch(error){console.error('Fixture cleanup failed:',error.message);process.exitCode=1;}for(const child of children.reverse())await stop(child);process.env.TULDOK_DATABASE_URL=sourceDsn;fs.rmSync(temporary,{recursive:true,force:true}); +}); diff --git a/tests/browser_pumas_typed_server.py b/tests/browser_pumas_typed_server.py index fc0b6f7e..6d3d46f4 100644 --- a/tests/browser_pumas_typed_server.py +++ b/tests/browser_pumas_typed_server.py @@ -23,8 +23,8 @@ def configure(value):state['content']=value['content'];state['mode']='success' origin='source-derived HTTP fixture; not native Pumas' original=app.make_handler -def make_handler(dataset, observer=None): - class Handler(original(dataset, observer)): +def make_handler(dataset, observer=None, *, transfer_controller=None): + class Handler(original(dataset, observer, transfer_controller=transfer_controller)): def _post(self): if self.path=='/api/test/pumas-control': body=json.loads(self.rfile.read(int(self.headers['Content-Length'])));configure(body);return self.reply({'configured':True}) diff --git a/tests/browser_text_classification_proposals_server.py b/tests/browser_text_classification_proposals_server.py index a98c15a7..85957ff0 100644 --- a/tests/browser_text_classification_proposals_server.py +++ b/tests/browser_text_classification_proposals_server.py @@ -41,8 +41,8 @@ def instrumented_start(self, body): original_handler = app.make_handler -def instrumented_handler(dataset, observer=None): - class Handler(original_handler(dataset, observer)): +def instrumented_handler(dataset, observer=None, *, transfer_controller=None): + class Handler(original_handler(dataset, observer, transfer_controller=transfer_controller)): def _get(self): if self.path == '/api/test/classification-dispatch': with admission_lock: diff --git a/tests/browser_workspace_project_observation.cjs b/tests/browser_workspace_project_observation.cjs index 09aeb615..ac819186 100644 --- a/tests/browser_workspace_project_observation.cjs +++ b/tests/browser_workspace_project_observation.cjs @@ -17,15 +17,25 @@ const html=`Project observation regression

                          - + + `; let snapshotReads=0,recipeReads=0,ordinaryReads=0,holdRecipe=0,heldRecipe=null,holdEvidence=false,heldEvidence=null,mutations=0; +let holdSnapshot=false,heldSnapshot=null,appliedObservations=0; function reply(response,value){response.setHeader('Content-Type','application/json');response.end(JSON.stringify(value));} const server=http.createServer((request,response)=>{ if(request.method!=='GET'){mutations++;response.writeHead(405);response.end();return;} if(request.url==='/'){response.setHeader('Content-Type','text/html');response.end(html);return;} if(['/workspace-service.js','/workspace-external.js','/workspace-project-activity.js','/workspace-projects.js'].includes(request.url)){response.setHeader('Content-Type','text/javascript');response.end(fs.readFileSync(path.join(root,'static',request.url.slice(1))));return;} - if(request.url==='/api/workspace/project-snapshots/'+id){snapshotReads++;reply(response,observation);return;} + if(request.url==='/api/workspace/project-snapshots/'+id){ + snapshotReads++;const json=JSON.stringify(observation),respond=()=>reply(response,JSON.parse(json)); + if(holdSnapshot){holdSnapshot=false;heldSnapshot={json,respond};}else respond();return; + } if(request.url.startsWith('/api/workspace/projects/'+id+'/recipes/')){ recipeHeaders.push(request.headers['tuldok-operation-contract']); if(recipeHeaders.at(-1)!==recipeContract){response.statusCode=409;reply(response,{error:'Expected current recipe inspection contract',code:'unsupported'});return;} @@ -58,7 +68,17 @@ function check(name,value){checks.push({name,passed:value});if(!value)failures.p evaluate=async expression=>{const result=await send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(result.exceptionDetails)throw Error(JSON.stringify(result.exceptionDetails));return result.result.value;}; async function activate(selector){await evaluate(`document.querySelector(${JSON.stringify(selector)}).focus()`);for(const type of ['keyDown','keyUp'])await send('Input.dispatchKeyEvent',{type,key:'Enter',code:'Enter',windowsVirtualKeyCode:13,...(type==='keyDown'?{text:'\r',unmodifiedText:'\r'}:{})});} const text=selector=>evaluate(`document.querySelector(${JSON.stringify(selector)})?.textContent`); - async function observe(next){const before=snapshotReads;observation=next;await until(()=>snapshotReads>before,'Project did not poll');await pause(50);} + async function observe(next){ + assert.equal(heldSnapshot,null);holdSnapshot=true;observation=next;const expected=JSON.stringify(next); + // Request arrival is deliberately separated from response application. Even + // unchanged observations must pass through another real onDetail callback. + await until(()=>heldSnapshot,'Project did not poll the requested observation'); + assert.equal(heldSnapshot.json,expected,'Poll response identifies another observation'); + const before=await evaluate('window.appliedProjectObservation.sequence'); + const pending=heldSnapshot;heldSnapshot=null;pending.respond(); + await until(()=>evaluate(`window.appliedProjectObservation.sequence>${before}&&window.appliedProjectObservation.json===${JSON.stringify(expected)}`),'Project did not apply the exact polled observation'); + appliedObservations++; + } await send('Runtime.enable');await send('Page.navigate',{url:base});await until(()=>evaluate('typeof showProject==="function"')); await evaluate('showProject("runs")');await until(()=>evaluate('!!document.querySelector("[data-project-evidence]")')); await activate('[data-project-evidence]');await until(()=>text('#runs .project-evidence-output pre')); @@ -115,6 +135,6 @@ function check(name,value){checks.push({name,passed:value});if(!value)failures.p await activate('#external-create-close');await evaluate('document.getElementById("source").value="catalog";showProject("pipeline")'); check('Every current bound and ordinary recipe inspection uses the exact version 4 pin',recipeHeaders.length>2&&recipeHeaders.every(value=>value===recipeContract)); assert.equal(mutations,0,'Observation regression must not submit any mutation');assert.deepEqual(errors,[]); - console.log(JSON.stringify({scope:'Actual JavaScript/Chromium lifecycle and focus with synthetic observations; no catalog, execution or scientific qualification',browser:(await send('Browser.getVersion')).product,checks,mutations},null,2)); + console.log(JSON.stringify({scope:'Actual JavaScript/Chromium lifecycle and focus with synthetic observations; no catalog, execution or scientific qualification',browser:(await send('Browser.getVersion')).product,snapshotReads,appliedObservations,checks,mutations},null,2)); assert.deepEqual(failures,[],'Project observation regressions'); -})().catch(error=>{console.error(error);process.exitCode=1;}).finally(async()=>{heldRecipe?.();heldEvidence?.();ws?.close();browser?.kill('SIGTERM');await new Promise(resolve=>server.close(resolve));}); +})().catch(error=>{console.error(error);process.exitCode=1;}).finally(async()=>{heldSnapshot?.respond();heldRecipe?.();heldEvidence?.();ws?.close();browser?.kill('SIGTERM');await new Promise(resolve=>server.close(resolve));}); diff --git a/tests/test_managed_model_workflow.py b/tests/test_managed_model_workflow.py index 5c0355b9..ed7133ea 100644 --- a/tests/test_managed_model_workflow.py +++ b/tests/test_managed_model_workflow.py @@ -80,7 +80,12 @@ def test_trial_runtime_identity_and_budget_include_measured_managed_verification identity = recipe_contract.digest(descriptor) self.assertEqual(composition['material']['runtime']['sha256'], identity) self.assertNotEqual(identity, recipe_contract.digest(preflight['runtime'])) - expected = self.recipe['resources']['wall_seconds'] + recipe_process.TORCH_READY_SECONDS + 2 + recipe_process.TORCH_VERIFY_SECONDS + 15 + self.assertEqual(recipe_process.preparation_seconds(preflight['material']), recipe_process.TORCH_READY_SECONDS) + self.assertEqual(recipe_process.verification_seconds(preflight['material']), recipe_process.TORCH_VERIFY_SECONDS) + # READY preparation, acknowledged-owner GO waiting, and post-GO runtime + # verification are separate bounded phases in the actual custodian. + expected = (self.recipe['resources']['wall_seconds'] + recipe_process.TORCH_READY_SECONDS + 2 + + recipe_process.GO_WAIT_SECONDS + recipe_process.TORCH_VERIFY_SECONDS + 15) self.assertEqual(composition['material']['resource_envelope']['admitted_wall_seconds'], expected) self.assertEqual(composition['material']['model'], self.published['ref']) self.assertEqual(self.dataset.runs.list(), []) diff --git a/tests/test_project_artifacts.py b/tests/test_project_artifacts.py new file mode 100644 index 00000000..d4fd1fe9 --- /dev/null +++ b/tests/test_project_artifacts.py @@ -0,0 +1,368 @@ +"""Exact artifact custody on real files, processes and disposable PostgreSQL.""" +import copy +import errno +import hashlib +import json +import os +from pathlib import Path +import shutil +import stat +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import project_artifacts as artifact_module +from project_artifacts import ProjectArtifacts, ProjectArtifactError, validate_declaration +from project_store import ProjectStore, ProjectStoreError + + +def declaration(raw): + return dict(format='project_artifact_v1', sha256=hashlib.sha256(raw).hexdigest(), bytes=len(raw)) + + +def envelope(value): + return dict(family='project_artifact', id=value['sha256'], document=value) + + +def request(value): + return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest() + + +class _ArtifactFixture: + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='tuldok-artifacts-') + self.addCleanup(self.tmp.cleanup) + self.path = Path(self.tmp.name) + self.project = self.path / 'project' + self.store = ProjectStore.create(self.project, 'Artifact custody', 'new', 'a' * 64) + self.addCleanup(lambda: self.store.close()) + self.artifacts = ProjectArtifacts(self.store) + self.addCleanup(lambda: self.artifacts.close()) + + def payload(self, value): + return self.project / 'artifacts/sha256' / value['sha256'] + + def assert_code(self, code, function, *args, **kwargs): + with self.assertRaises(ProjectStoreError) as caught: + function(*args, **kwargs) + self.assertEqual(caught.exception.code, code) + return caught.exception + + def commit(self, values, key='payloads'): + records = [envelope(value) for value in values] + return self.store.commit(records, self.store.snapshot()['checkpoint'], key, request(records)) + +class ProjectArtifactTests(_ArtifactFixture, unittest.TestCase): + def test_exact_payload_declaration_roundtrip_copy_and_missing_metadata_remain_distinct(self): + raw = b'Unique source\x00annotation\xff\r\n' + value = declaration(raw) + self.assertEqual(self.artifacts.inspect(value)['state'], 'missing') + self.assertEqual(self.artifacts.status(value)['state'], 'missing') + self.assertEqual(self.artifacts.publish_bytes(raw, value), value) + self.assertEqual(self.artifacts.inspect(value)['state'], 'unverified') + self.assertEqual(self.artifacts.status(value), dict(state='verified', expected_sha256=value['sha256'], + expected_bytes=len(raw), observed_sha256=value['sha256'], observed_bytes=len(raw))) + self.assertEqual(self.artifacts.read_blob({k: value[k] for k in ('sha256', 'bytes')}), raw) + checkpoint = self.commit([value]) + self.assertEqual(checkpoint['records'], [envelope(value)]) + self.artifacts.close() + self.store.close() + copied = self.path / 'copied' + shutil.copytree(self.project, copied) + self.project.rename(self.path / 'moved') + with ProjectStore.open(copied) as reopened, ProjectArtifacts(reopened) as owner: + self.assertEqual(reopened.snapshot(), checkpoint) + self.assertEqual(owner.status(value)['state'], 'verified') + (copied / 'artifacts/sha256' / value['sha256']).unlink() + self.assertEqual(owner.status(value)['state'], 'missing') + self.assertEqual(reopened.snapshot(), checkpoint) + self.assert_code('unavailable', owner.read_blob, {k: value[k] for k in ('sha256', 'bytes')}) + + def test_intrinsic_declaration_rejects_policy_paths_future_formats_and_bad_bounds(self): + value = declaration(b'bytes') + for invalid in [dict(value, role='model'), dict(value, path='/origin/secret'), dict(value, bytes=True), + dict(value, bytes=-1), dict(value, bytes=2 ** 63), dict(value, sha256='../escape'), + dict(value, format='project_artifact_vgarbage')]: + with self.subTest(invalid=invalid): + self.assert_code('invalid', validate_declaration, invalid) + self.assert_code('unsupported', validate_declaration, dict(value, format='project_artifact_v2')) + self.assert_code('invalid', self.artifacts.publish_bytes, b'other', value) + self.assert_code('too_large', self.artifacts.publish_bytes, b'bytes', value, maximum_bytes=4) + # Intrinsic identity admits large scientific payload sizes without a + # runtime-specific global cap. Only the consuming owner selects a bound. + large = dict(value, bytes=8 * 1024 ** 3) + self.assertEqual(validate_declaration(large), large) + empty = declaration(b'') + self.artifacts.publish_bytes(b'', empty) + self.assertEqual(self.artifacts.read_blob({k: empty[k] for k in ('sha256', 'bytes')}), b'') + + def test_publish_fd_streams_without_changing_source_offset_and_respects_caller_bound(self): + raw = (b'exact source bytes\x00' * 90_000) + value = declaration(raw) + source = self.path / 'source.bin' + source.write_bytes(raw) + fd = os.open(source, os.O_RDONLY) + self.addCleanup(lambda: os.close(fd)) + os.lseek(fd, 7, os.SEEK_SET) + self.assert_code('too_large', self.artifacts.publish_fd, fd, value, maximum_bytes=len(raw) - 1) + self.artifacts.publish_fd(fd, value, maximum_bytes=len(raw)) + self.assertEqual(os.lseek(fd, 0, os.SEEK_CUR), 7) + self.assertEqual(self.payload(value).read_bytes(), raw) + + def test_captured_read_proof_survives_source_edits_and_returned_fd_is_read_only(self): + raw = b'Original source measurement' + value = declaration(raw) + self.artifacts.publish_bytes(raw, value) + with self.artifacts.open_verified(value) as source: + self.assertEqual(os.fstat(source).st_nlink, 0) + path = self.payload(value) + path.chmod(0o600) + path.write_bytes(b'Changed source measurement!') + self.assertEqual(os.read(source, len(raw) + 1), raw) + with self.assertRaises(OSError): + os.write(source, b'No mutation capability') + self.assertEqual(self.artifacts.status(value)['state'], 'corrupt') + self.assertFalse(list((self.project / '.tuldok-local/artifact-snapshots').iterdir())) + + def test_source_changed_during_capture_is_rejected_before_final_publication(self): + raw = b'a' * (artifact_module.CHUNK_BYTES + 20) + value = declaration(raw) + source = self.path / 'source.bin' + source.write_bytes(raw) + fd = os.open(source, os.O_RDONLY) + self.addCleanup(lambda: os.close(fd)) + pread = artifact_module.os.pread + changed = False + def change_after_read(descriptor, size, offset): + nonlocal changed + result = pread(descriptor, size, offset) + if descriptor == fd and not changed: + changed = True + source.write_bytes(b'b' * len(raw)) + return result + with patch.object(artifact_module.os, 'pread', side_effect=change_after_read): + self.assert_code('stale', self.artifacts.publish_fd, fd, value) + self.assertFalse(self.payload(value).exists()) + self.assertEqual(self.store.snapshot()['records'], []) + + def test_explicit_runtime_resolution_is_not_canonical_and_never_falls_back(self): + raw = b'Externally held unique original' + value = declaration(raw) + external = self.path / 'external' + external.mkdir() + (external / 'nested').mkdir() + source = external / 'nested/input.bin' + source.write_bytes(raw) + fd = os.open(external, artifact_module.DIR_FLAGS) + bindings = {value['sha256']: dict(root_fd=fd, root_path=external, relative='nested/input.bin')} + resolved = ProjectArtifacts(self.store, resolutions=bindings) + os.close(fd) + self.addCleanup(resolved.close) + before = self.store.snapshot() + self.assertEqual(resolved.status(value)['state'], 'verified') + self.assertEqual(resolved.read_blob({k: value[k] for k in ('sha256', 'bytes')}), raw) + self.artifacts.publish_bytes(raw, value) + source.write_bytes(b'Wrong externally selected bytes') + self.assertEqual(resolved.status(value)['state'], 'corrupt') + self.assert_code('invalid', resolved.read_blob, {k: value[k] for k in ('sha256', 'bytes')}) + self.assertEqual(self.store.snapshot(), before) + displaced = external.with_name('displaced') + external.rename(displaced) + victim = self.path / 'victim' + victim.mkdir() + external.symlink_to(victim, target_is_directory=True) + self.assert_code('invalid', resolved.status, value) + self.assertEqual(list(victim.iterdir()), []) + + def test_symlink_special_hardlink_alias_and_unsafe_payload_parent_reject(self): + raw = b'Unique bytes' + value = declaration(raw) + self.artifacts.publish_bytes(raw, value) + path = self.payload(value) + target = self.path / 'target' + target.write_bytes(raw) + for special in ('symlink', 'fifo', 'hardlink'): + with self.subTest(special=special): + path.unlink() + if special == 'symlink': + path.symlink_to(target) + elif special == 'fifo': + os.mkfifo(path) + else: + os.link(target, path) + self.assert_code('invalid', self.artifacts.status, value) + path.unlink() + (self.project / 'artifacts/sha256').rmdir() + (self.project / 'artifacts/sha256').symlink_to(self.path, target_is_directory=True) + self.assert_code('invalid', self.artifacts.publish_bytes, raw, value) + self.assertEqual(target.read_bytes(), raw) + self.assertFalse((self.path / value['sha256']).exists()) + + def test_existing_corrupt_payload_is_never_overwritten(self): + raw = b'Exact original' + value = declaration(raw) + self.artifacts.publish_bytes(raw, value) + self.payload(value).chmod(0o600) + self.payload(value).write_bytes(b'Broken content') + self.assert_code('invalid', self.artifacts.publish_bytes, raw, value) + self.assertEqual(self.payload(value).read_bytes(), b'Broken content') + + def test_payload_fsync_failure_reports_unknown_retains_bytes_and_retry_barriers(self): + raw = b'Publication survives missing reply' + value = declaration(raw) + original = artifact_module.os.fsync + def fail_published_directory(fd): + info = os.fstat(fd) + if stat.S_ISDIR(info.st_mode) and self.payload(value).exists(): + raise OSError(errno.EIO, 'payload parent sync fault') + return original(fd) + with patch.object(artifact_module.os, 'fsync', side_effect=fail_published_directory): + self.assert_code('unknown', self.artifacts.publish_bytes, raw, value) + self.assertEqual(self.store.snapshot()['records'], []) + self.assertEqual(self.payload(value).read_bytes(), raw) + self.artifacts.publish_bytes(raw, value) + self.assertEqual(self.artifacts.status(value)['state'], 'verified') + + def test_no_auto_execution_and_store_capabilities_detect_replaced_root(self): + script = b'raise RuntimeError("Artifact source must never run")\n' + value = declaration(script) + with patch.object(subprocess, 'Popen', side_effect=AssertionError('No artifact execution')): + self.artifacts.publish_bytes(script, value) + self.artifacts.read_blob({k: value[k] for k in ('sha256', 'bytes')}) + with self.store.root_directory() as root: + self.assertTrue(stat.S_ISDIR(os.fstat(root).st_mode)) + moved = self.project.with_name('moved') + self.project.rename(moved) + self.project.mkdir() + self.assert_code('stale', self.artifacts.status, value) + + def test_fixed_family_format_pairs_roundtrip_and_preserve_original_origin_values(self): + records = [dict(family=family, id='a' * 32 + '.' + 'b' * 32, + document=dict(format=format_name, origin_catalog_id='c' * 32, + original_hashed_text=' { exact old text }\r\n')) + for family, format_name in (('workbench_record', 'workbench_record_v1'), + ('model_material', 'retained_model_material_v1'), ('run_history', 'project_run_history_v1'), + ('project_operation', 'project_operation_v1'), ('generation_history', 'project_generation_history_v1'))] + committed = self.store.commit(records, self.store.snapshot()['checkpoint'], 'families', request(records)) + self.store.close() + self.store = ProjectStore.open(self.project) + self.assertEqual(self.store.snapshot(), committed) + bad = dict(family='run_history', id='a', document={'format': 'workbench_record_v1'}) + self.assert_code('unsupported', self.store.commit, [bad], committed['checkpoint'], 'wrong', 'b' * 64) + + def test_real_process_interruptions_keep_published_bytes_without_partial_checkpoint(self): + code = r''' +import hashlib,json,os,sys +import project_artifacts as m +from project_store import ProjectStore +root,window=sys.argv[1:] +store=ProjectStore.open(root) +owner=m.ProjectArtifacts(store) +raw=b'Exact unique bytes across process interruption' +decl={'format':'project_artifact_v1','sha256':hashlib.sha256(raw).hexdigest(),'bytes':len(raw)} +write,rename=m._write,m._rename_noreplace +def kill_write(*args): + write(*args) + if window=='stage':os._exit(76) +def kill_rename(*args): + rename(*args) + if window=='rename':os._exit(76) +m._write=kill_write +m._rename_noreplace=kill_rename +owner.publish_bytes(raw,decl) +if window=='durable':os._exit(76) +records=[{'family':'project_artifact','id':decl['sha256'],'document':decl}] +digest=hashlib.sha256(json.dumps(records,sort_keys=True).encode()).hexdigest() +store.commit(records,store.snapshot()['checkpoint'],'artifact-edit',digest) +if window=='checkpoint':os._exit(76) +raise AssertionError('Fault not reached') +''' + raw = b'Exact unique bytes across process interruption' + value = declaration(raw) + self.artifacts.close() + self.store.close() + for window in ('stage', 'rename', 'durable', 'checkpoint'): + with self.subTest(window=window): + destination = self.path / ('kill-' + window) + shutil.copytree(self.project, destination) + child = subprocess.run([sys.executable, '-c', code, str(destination), window], + cwd=Path(artifact_module.__file__).parent, capture_output=True, text=True, timeout=10) + self.assertEqual(child.returncode, 76, child.stderr) + with ProjectStore.open(destination) as reopened, ProjectArtifacts(reopened) as owner: + snapshot = reopened.snapshot() + self.assertEqual(snapshot['records'], [envelope(value)] if window == 'checkpoint' else []) + self.assertEqual(owner.status(value)['state'], 'missing' if window == 'stage' else 'verified') + replay = reopened.reconcile('artifact-edit', request([envelope(value)])) + self.assertEqual(replay is not None, window == 'checkpoint') + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'), 'Requires disposable local PostgreSQL') +class ArtifactSQLCustodyTests(_ArtifactFixture, unittest.TestCase): + def setUp(self): + super().setUp() + import psycopg + from psycopg import sql + from metadata import Metadata + self.db = Metadata(self.path / 'projection') + def drop_owned_schema(): + self.db.close() + with psycopg.connect(self.db.dsn, autocommit=True) as connection: + connection.execute(sql.SQL('DROP SCHEMA {} CASCADE').format(sql.Identifier(self.db.schema))) + self.addCleanup(drop_owned_schema) + self.db.execute('CREATE TABLE artifact_custody_fixture (id integer PRIMARY KEY)') + + def _publish_through_legacy_rollback_owner(self, raw): + from immutable_assets import publish_file + legacy = self.path / 'legacy-assets' + legacy.mkdir() + folder = legacy / 'owned-attempt' + value = declaration(raw) + with publish_file(self.db, folder, 'source.bin', raw) as source: + descriptor = os.open(source, os.O_RDONLY) + try: + self.artifacts.publish_fd(descriptor, value) + finally: + os.close(descriptor) + snapshot = self.commit([value]) + return folder, value, snapshot + + def test_real_sql_rollback_callbacks_do_not_delete_canonical_payload(self): + raw = b'Unique BYTEA-derived source and annotation observation' + value = declaration(raw) + with self.assertRaisesRegex(RuntimeError, 'abort SQL'): + with self.db.transaction(): + self.db.execute('INSERT INTO artifact_custody_fixture VALUES (1)') + self._publish_through_legacy_rollback_owner(raw) + raise RuntimeError('abort SQL') + self.assertFalse((self.path / 'legacy-assets/owned-attempt').exists()) + self.assertEqual(self.db.execute('SELECT count(*) FROM artifact_custody_fixture').fetchone()[0], 0) + self.assertEqual(self.payload(value).read_bytes(), raw) + self.assertEqual(self.store.snapshot()['records'], [envelope(value)]) + self.assertIsNotNone(self.store.reconcile('payloads', request([envelope(value)]))) + + def test_actual_deferred_sql_commit_failure_retains_checkpoint_and_bytes(self): + self.db.execute("""CREATE FUNCTION artifact_custody_reject_commit() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN RAISE EXCEPTION 'fixture commit rejection' USING ERRCODE='23514'; END $$""") + self.db.execute('''CREATE CONSTRAINT TRIGGER artifact_custody_commit_failure AFTER INSERT ON artifact_custody_fixture + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION artifact_custody_reject_commit()''') + raw = b'Historical model state survives canonical-before-SQL commit' + value = declaration(raw) + with self.assertRaises(Exception): + with self.db.transaction(): + self.db.execute('INSERT INTO artifact_custody_fixture VALUES (1)') + self._publish_through_legacy_rollback_owner(raw) + self.assertFalse((self.path / 'legacy-assets/owned-attempt').exists()) + self.assertEqual(self.db.execute('SELECT count(*) FROM artifact_custody_fixture').fetchone()[0], 0) + self.store.close() + self.store = ProjectStore.open(self.project) + with ProjectArtifacts(self.store) as owner: + self.assertEqual(owner.status(value)['state'], 'verified') + self.assertEqual(self.store.snapshot()['records'], [envelope(value)]) + self.assertIsNotNone(self.store.reconcile('payloads', request([envelope(value)]))) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_project_candidate_clients.py b/tests/test_project_candidate_clients.py new file mode 100644 index 00000000..8729b39d --- /dev/null +++ b/tests/test_project_candidate_clients.py @@ -0,0 +1,58 @@ +"""Current Candidate preparation/recovery transport proof, without a writer.""" +from types import SimpleNamespace +import unittest +from unittest.mock import Mock +import agent_api as api +import project_cli +import project_dispatch +from tuldok_client import ProjectFilesClient +from test_project_files_m4_clients import ready,bound,READ,WRITE,PROJECT,ACTOR,CP,NEXT,KEY,STAMP + +STUDY='a'*32;CANDIDATE='e'*32 +ACTIONS=('execution_study_prepare_package','execution_study_prepare_verifier','execution_study_abandon_preparation','execution_study_reconcile_preparation') +def prepared(): + return dict(state='prepared',candidate_id=CANDIDATE,trial=dict(format='trial_v1',id='b'*32,study_id=STUDY, + material=dict(kind='recipe_material',catalog_id=PROJECT,id=CP,sha256=CP),replicate=0), + graph=dict(id='f'*32,revision=1,nodes=[],edges=[]),model=dict(kind='model',catalog_id=PROJECT,id=ACTOR,revision=1,sha256=CP),runtime_snapshot_sha256=CP) +def abandonment(): + return dict(format='project_candidate_preparation_abandon_v1',origin_catalog_id=PROJECT,study_id=STUDY, + preparation_key=KEY,preparation_operation='study_execution.prepare_package',input_sha256=CP, + candidate=dict(kind='candidate',catalog_id=PROJECT,id=CANDIDATE,sha256=NEXT),actor_id=ACTOR, + reason='Explicit retained scientific reason',observed_checkpoint=CP,occurred_at=STAMP,state='abandoned') +def client(value): + raw=ready(dict(status=200,result=bound(value)),owner=True) + raw._selected.update({'project_files.'+action:1 for action in ACTIONS});return raw +class CandidateClients(unittest.TestCase): + def test_fixed_inputs_consumers_and_no_implicit_grant_or_admission(self): + for action in ACTIONS: + name='project_files.'+action;self.assertIn(name,api.PROJECT_FILES_M3) + self.assertTrue(callable(getattr(ProjectFilesClient(Mock()),action))) + project_cli.parser().parse_args(['--config','/not-read','file-'+action.replace('_','-'),'--arguments','/not-read']) + self.assertEqual(api.contract(name)['authority'],'owner') + common=dict(WRITE,study_id=STUDY,revision=1,candidate_id=CANDIDATE,root='fixture',directory='a'*120,runtime_snapshot_sha256=CP) + api.validate_arguments('project_files.execution_study_prepare_package',common) + for change in ({'directory':'a'*121},{'root':'a'*65},{'approval_id':ACTOR},{'launch':True}): + with self.assertRaises(api.AgentAPIError):api.validate_arguments('project_files.execution_study_prepare_package',{**common,**change}) + args=dict(binding=READ['binding'],expected_checkpoint=CP,study_id=STUDY,preparation_key='k') + api.validate_arguments('project_files.execution_study_reconcile_preparation',args) + with self.assertRaises(api.AgentAPIError):api.validate_arguments('project_files.execution_study_reconcile_preparation',dict(args,request_key=KEY)) + self.assertEqual(api.contract('project_files.execution_study_reconcile_preparation')['retry'],'never') + def test_exact_scientific_identity_and_recovery_reason_correlation(self): + args=dict(WRITE,study_id=STUDY,revision=1,candidate_id=CANDIDATE,root='fixture',directory='package',runtime_snapshot_sha256=CP) + raw=client(prepared());sdk=ProjectFilesClient(raw);sdk.execution_study_prepare_package(**args) + for field,bad in (('candidate_id',ACTOR),('runtime_snapshot_sha256',NEXT)): + raw._request.return_value=dict(status=200,result=bound(dict(prepared(),**{field:bad}))) + with self.assertRaises(api.AgentAPIError):sdk.execution_study_prepare_package(**args) + raw._request.return_value=dict(status=200,result=bound(abandonment())) + sdk.execution_study_abandon_preparation(**dict(WRITE,request_key='new-abandon-key'),study_id=STUDY,preparation_key=KEY,reason=abandonment()['reason']) + raw._request.return_value=dict(status=200,result=bound(dict(abandonment(),reason='different'))) + with self.assertRaises(api.AgentAPIError):sdk.execution_study_abandon_preparation(**dict(WRITE,request_key='new-abandon-key'),study_id=STUDY,preparation_key=KEY,reason=abandonment()['reason']) + def test_dispatch_exact_local_owner_and_reconciliation_has_no_second_key(self): + owner=Mock();projection=SimpleNamespace(session=SimpleNamespace(access=SimpleNamespace(require=Mock())),execution=SimpleNamespace(study_execution=owner)) + args=dict(WRITE,study_id=STUDY,revision=1,candidate_id=CANDIDATE,root='fixture',directory='package',runtime_snapshot_sha256=CP) + project_dispatch.dispatch(projection,'project_files.execution_study_prepare_package',args) + owner.prepare_package.assert_called_once_with(STUDY,{k:v for k,v in args.items() if k not in ('binding','expected_checkpoint','request_key','study_id')},CP,KEY) + args=dict(binding=READ['binding'],expected_checkpoint=CP,study_id=STUDY,preparation_key='k') + project_dispatch.dispatch(projection,'project_files.execution_study_reconcile_preparation',args) + owner.reconcile_preparation.assert_called_once_with(STUDY,{'preparation_key':'k'},CP) +if __name__=='__main__':unittest.main() diff --git a/tests/test_project_control_paths.py b/tests/test_project_control_paths.py new file mode 100644 index 00000000..2e1832a7 --- /dev/null +++ b/tests/test_project_control_paths.py @@ -0,0 +1,121 @@ +"""Anchored physical separation of portable projects and private control state.""" +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from backup_metadata import tree_manifest +from project_store import DIR_FLAGS, ProjectStoreError, assert_separate_project_control +from project_transfer_controller import ProjectTransferController +from project_workspace import ProjectWorkspace +from workbench import WorkbenchError +import test_project_transfer_controller as transfer_fixture + + +class AnchoredControlPaths(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='tuldok-control-separation-') + self.addCleanup(self.temporary.cleanup) + self.parent = Path(self.temporary.name) + self.root = self.parent / 'configured-root'; self.root.mkdir() + self.control = self.parent / 'private-control'; self.control.mkdir() + self.fd = os.open(self.root, DIR_FLAGS) + self.addCleanup(os.close, self.fd) + + def check(self, relative, *, control=None, descriptor=None): + assert_separate_project_control(self.fd if descriptor is None else descriptor, relative, + root_path=self.root, control_path=self.control if control is None else control) + + def test_existing_equality_and_both_ancestries_reject_without_leaking_or_closing_caller_fd(self): + project = self.root / 'portable'; control = project / 'control' + control.mkdir(parents=True); (control / 'child').mkdir() + original = os.fstat(self.fd) + descriptors = len(list(Path('/proc/self/fd').iterdir())) + for _ in range(4): + for relative in ('portable', 'portable/control', 'portable/control/child'): + with self.subTest(relative=relative), self.assertRaises(ProjectStoreError) as error: + self.check(relative, control=control) + self.assertEqual((error.exception.code, error.exception.status), ('conflict', 409)) + self.assertEqual(os.fstat(self.fd).st_ino, original.st_ino) + self.assertEqual(len(list(Path('/proc/self/fd').iterdir())), descriptors) + + def test_absent_leaf_is_never_allocated_and_broad_root_disjoint_siblings_are_allowed(self): + control = self.root / 'control'; control.mkdir() + (self.root / 'control-sibling').mkdir() + duplicate = os.dup(self.fd); self.addCleanup(os.close, duplicate) + self.check('new-project', control=control, descriptor=duplicate) + self.check('control-sibling', control=control, descriptor=duplicate) + self.assertFalse((self.root / 'new-project').exists()) + with self.assertRaises(ProjectStoreError) as error: + self.check('control/forbidden', control=control, descriptor=duplicate) + self.assertEqual(error.exception.code, 'conflict') + self.assertFalse((control / 'forbidden').exists()) + self.assertEqual(os.fstat(duplicate).st_ino, os.fstat(self.fd).st_ino) + + def test_symlink_leaf_intermediate_and_control_alias_are_refused_without_following(self): + (self.root / 'linked').symlink_to(self.control, target_is_directory=True) + for relative in ('linked', 'linked/child'): + with self.subTest(relative=relative), self.assertRaises(ProjectStoreError): + self.check(relative) + alias = self.parent / 'control-alias'; alias.symlink_to(self.control, target_is_directory=True) + with self.assertRaises(ProjectStoreError): self.check('new-project', control=alias) + self.assertFalse((self.control / 'child').exists()) + self.assertFalse((self.root / 'new-project').exists()) + self.assertTrue(os.fstat(self.fd).st_ino) + + def test_replaced_configured_root_is_stale_before_any_target_allocation(self): + original = os.fstat(self.fd).st_ino + self.root.rename(self.parent / 'original-root'); self.root.mkdir() + with self.assertRaises(ProjectStoreError) as error: self.check('new-project') + self.assertEqual(error.exception.code, 'stale') + self.assertEqual(os.fstat(self.fd).st_ino, original) + self.assertFalse((self.root / 'new-project').exists()) + self.assertFalse((self.parent / 'original-root' / 'new-project').exists()) + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'), 'Requires disposable PostgreSQL') +class TransferControlPaths(unittest.TestCase): + def test_destination_inside_control_is_refused_before_source_stop_and_before_extraction(self): + f = transfer_fixture.TransferControllerTests('test_actual_http_source_control_handoff_report_and_extraction') + f.setUp(); self.addCleanup(f.doCleanups) + # Normal startup can configure a broad root or a root at control itself; + # destination validation must reject overlap before irreversible Stop. + f.controller.close(); f.workspace.close() + control = f.root / 'separate-control' + f.workspace = ProjectWorkspace(control, {'output': f.publication, 'inside': control}) + f.schemas.add(f.workspace.db.schema) + f.controller = ProjectTransferController(f.dataset, f.workspace) + f.control_token = f.workspace.access.exchange(f.workspace.access._local_owner_token())['token'] + live = f.preview() + original = tree_manifest(f.dataset.path) + with patch('runs.Runs', side_effect=AssertionError('No Run owner construction')): + with patch.object(f.dataset, 'stop_for_project_extraction', side_effect=AssertionError('Destination must be checked before Stop')) as stop: + with self.assertRaises((ProjectStoreError, WorkbenchError)) as denied: + f.controller.stop(live['binding'], live['report']['report_sha256'], 'output', 'backup', 'inside', 'forbidden') + self.assertEqual(denied.exception.code, 'conflict') + stop.assert_not_called() + self.assertEqual(f.controller.phase, 'source_live') + self.assertFalse(f.dataset._extraction_stopping) + self.assertFalse((f.publication / 'backup').exists()) + self.assertFalse((control / 'forbidden').exists()) + self.assertEqual(tree_manifest(f.dataset.path), original) + f.stop() # Disjoint sibling backup/project is a valid explicit Stop. + with f.workspace.access.bind(f.control_token): + ready = f.preview() + stopped = tree_manifest(f.dataset.path) + with patch.object(f.controller._transfer, 'extract', side_effect=AssertionError('No publication before destination check')) as extract: + with self.assertRaises((ProjectStoreError, WorkbenchError)) as denied: + f.controller.extract(ready['binding'], ready['report']['report_sha256'], 'inside', 'forbidden', + 'Must not publish', 'invalid-control-destination') + self.assertEqual(denied.exception.code, 'conflict') + extract.assert_not_called() + self.assertEqual(f.controller.phase, 'source_stopped') + self.assertFalse((control / 'forbidden').exists()) + self.assertFalse((control / '.project-projections').exists()) + self.assertFalse((f.publication / 'extracted').exists()) + self.assertEqual(tree_manifest(f.dataset.path), stopped) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_project_custody.py b/tests/test_project_custody.py new file mode 100644 index 00000000..2802f40e --- /dev/null +++ b/tests/test_project_custody.py @@ -0,0 +1,364 @@ +"""Private original-host physical custody facts, never portable authority.""" +import copy +import hashlib +import json +import os +from pathlib import Path +import shutil +import stat +import subprocess +import sys +import tempfile +import time +import unittest +from unittest.mock import patch +import uuid + +from project_custody import ProjectCustody, validate +from project_store import ProjectStore +from workbench import WorkbenchError + + +class ProjectCustodyTests(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory(prefix='tuldok-local-custody-');self.root=Path(self.tmp.name) + self.addCleanup(self.tmp.cleanup) + self.control=self.root/'control';self.control.mkdir(mode=0o700) + self.store=ProjectStore.create(self.root/'project','Neutral custody','new-custody','a'*64) + self.addCleanup(self.store.close) + self.host=uuid.uuid4().hex;self.projection=uuid.uuid4().hex + self.manager=ProjectCustody(self.control,self.host) + self.journal=self.manager.for_projection(self.store,self.projection) + self.run=dict(id=uuid.uuid4().hex,admission_id=uuid.uuid4().hex,request_sha256='b'*64, + spec={'authored':'bounded specification'},custody_phase='prepared',custody_identity=None) + self.helper=dict(pid=123456,start_ticks='987654',boot_id='aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') + + def fresh(self):return ProjectCustody(self.control,self.host).for_projection(self.store,self.projection) + + def test_inert_index_then_exact_private_physical_binding_survives_reread(self): + self.assertFalse((self.control/'.project-custody').exists()) + self.assertEqual(self.manager.pending(self.store),[]) + self.journal.observe_run(self.run) + file=self.control/'.project-custody'/self.journal.name + self.assertEqual(stat.S_IMODE(file.stat().st_mode),0o600) + pending=self.manager.pending(self.store) + self.assertEqual(pending,[self.journal.binding]) + self.assertEqual(self.fresh().snapshot(),self.journal.snapshot()) + self.assertNotIn('token',file.read_text()) + self.assertEqual(self.fresh().snapshot()['runs'][0]['phase'],'admitted') + + def test_exact_attempted_selection_is_correlated_and_other_checkout_refused(self): + self.journal.observe_run(self.run);before=self.store.snapshot() + record=dict(family='project_artifact',id='c'*64,document=dict(format='project_artifact_v1',sha256='c'*64,bytes=3)) + self.journal.expect_publication(before,[record],'local-publication','d'*64) + selected=self.store.commit([record],before['checkpoint'],'local-publication','d'*64) + self.fresh().validate_selection(selected) + other=dict(family='project_artifact',id='e'*64,document=dict(format='project_artifact_v1',sha256='e'*64,bytes=4)) + foreign=self.store.commit([record,other],selected['checkpoint'],'unrelated-checkout','f'*64) + with self.assertRaises(WorkbenchError):self.fresh().validate_selection(foreign) + + def test_portable_copy_and_other_host_cannot_inherit_original_custody(self): + self.journal.observe_run(self.run) + self.assertEqual(ProjectCustody(self.control,uuid.uuid4().hex).pending(self.store),[]) + clone=self.root/'copy';shutil.copytree(self.root/'project',clone) + with ProjectStore.open(clone) as copied: + self.assertEqual(copied.snapshot()['project_id'],self.store.snapshot()['project_id']) + self.assertEqual(self.manager.pending(copied),[]) + with self.assertRaises(WorkbenchError):self.manager.for_projection(copied,self.projection) + + def test_replacing_canonical_identity_at_same_root_retains_original_custody(self): + self.journal.observe_run(self.run);binding=self.journal.binding + physical=(self.root/'project').stat().st_ino + self.store.close() + with ProjectStore.create(self.root/'replacement','Different valid project','replace-project','c'*64) as other: + replacement=other.snapshot()['project_id'] + shutil.rmtree(self.root/'project'/'.tuldok') + shutil.copytree(self.root/'replacement'/'.tuldok',self.root/'project'/'.tuldok') + with ProjectStore.open(self.root/'project') as selected: + self.assertEqual(selected.snapshot()['project_id'],replacement) + self.assertEqual((self.root/'project').stat().st_ino,physical) + self.assertNotEqual(replacement,binding['project_id']) + self.assertEqual(self.manager.pending(selected),[binding]) + with self.assertRaises(WorkbenchError):self.journal.validate_selection(selected.snapshot()) + + def test_explicit_refresh_preserves_exact_original_pending_marker(self): + self.journal.observe_run(self.run);stale=self.fresh() + self.journal.launch_intent(self.run['id']) + self.assertEqual(stale.snapshot()['runs'][0]['phase'],'admitted') + observed=stale.refresh() + self.assertEqual(observed,self.journal.snapshot()) + self.assertEqual(observed['runs'][0]['phase'],'launch_intent') + self.assertTrue(stale.has_pending) + + def test_launch_identity_and_unknown_provider_remain_explicit_fences(self): + self.journal.observe_run(self.run);self.journal.launch_intent(self.run['id']) + self.journal.launched(self.run['id'],self.helper) + saved=self.fresh().snapshot()['runs'][0] + self.assertEqual(saved['custody_identity']['helper'],self.helper) + changed=copy.deepcopy(self.run);changed.update(custody_phase='barrier_ready',custody_identity=saved['custody_identity']) + self.journal.observe_run(changed) + changed['custody_phase']='go_intent';self.journal.observe_run(changed) + self.assertEqual(self.fresh().snapshot()['runs'][0]['phase'],'go_intent') + producer=uuid.uuid4().hex;admission=uuid.uuid4().hex + self.journal.register_producer('generation_history',producer,admission,'e'*64) + self.journal.producer_effect_intent('generation_history',producer) + self.journal.producer_outcome('generation_history',producer,'unknown',True) + value=self.fresh().snapshot()['producers'][0] + self.assertTrue(value['worker_drained']);self.assertEqual(value['provider_outcome'],'unknown') + self.assertTrue(self.fresh().has_pending) + + def test_only_two_file_writer_kinds_have_exact_retry_and_confirmation(self): + self.journal.runtime_started('runtime-capture','a'*64,'b'*64) + self.journal.package_started('package-prepare','c'*64,'d'*64) + self.assertTrue(self.fresh().has_pending) + with self.assertRaises(WorkbenchError):self.journal.runtime_started('runtime-capture','e'*64,'b'*64) + self.journal.runtime_confirmed('runtime-capture') + self.assertTrue(self.fresh().has_pending) + self.journal.package_confirmed('package-prepare') + self.assertEqual(self.manager.pending(self.store),[]) + + def test_unsafe_or_unclassified_local_files_fail_closed(self): + self.journal.observe_run(self.run);file=self.control/'.project-custody'/self.journal.name + file.chmod(0o644) + with self.assertRaises(WorkbenchError):self.manager.pending(self.store) + file.chmod(0o600);raw=file.read_bytes();value=json.loads(raw);value['token']='unclassified' + file.write_text(json.dumps(value)) + with self.assertRaises(WorkbenchError):self.manager.pending(self.store) + file.write_bytes(raw);file.unlink();file.symlink_to(self.root/'untrusted') + with self.assertRaises(WorkbenchError):self.manager.pending(self.store) + value=self.journal.snapshot();value['runs'][0]['arbitrary_resume']=True + with self.assertRaises(WorkbenchError):validate(value) + + def test_stale_local_reader_cannot_erase_another_current_admission(self): + stale=self.fresh();self.journal.observe_run(self.run) + other=copy.deepcopy(self.run);other['id']=uuid.uuid4().hex + with self.assertRaises(WorkbenchError) as failure:stale.observe_run(other) + self.assertEqual(failure.exception.code,'outcome_unknown') + self.assertEqual([item['run_id'] for item in self.fresh().snapshot()['runs']],[self.run['id']]) + + def test_directory_fsync_ack_loss_retains_pending_original_custody(self): + self.journal.observe_run(self.run);original=os.fsync + def fsync(descriptor): + if stat.S_ISDIR(os.fstat(descriptor).st_mode):raise OSError('Injected directory fsync acknowledgement loss') + return original(descriptor) + with patch('os.fsync',side_effect=fsync),self.assertRaises(WorkbenchError) as failure: + self.journal.launch_intent(self.run['id']) + self.assertEqual(failure.exception.code,'outcome_unknown') + self.assertEqual(self.fresh().snapshot()['runs'][0]['phase'],'launch_intent') + self.assertTrue(self.manager.pending(self.store)) + with self.assertRaises(WorkbenchError):self.journal.launched(self.run['id'],self.helper) + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'),'Requires disposable PostgreSQL') +class OriginalHostIdentityFenceTests(unittest.TestCase): + def test_cold_workspace_detects_different_uuid_at_original_physical_root(self): + import psycopg + from psycopg import sql + from auth_helpers import ExplicitOwnerFixture + from metadata import connection_info + import project_workspace + with tempfile.TemporaryDirectory(prefix='project-custody-identity-') as temporary: + root=Path(temporary);external=root/'external';external.mkdir() + workspace=project_workspace.ProjectWorkspace(root/'control',{'fixture':external}) + schemas=[workspace.db.schema] + try: + owner=ExplicitOwnerFixture.bootstrap(workspace) + with ProjectStore.create(external/'project','Original identity','original-project','a'*64) as original: + binding=workspace.custody.for_projection(original,uuid.uuid4().hex) + binding.observe_run(dict(id=uuid.uuid4().hex,admission_id=uuid.uuid4().hex, + request_sha256='b'*64,spec={'authored':'unresolved physical custody index fixture'}, + custody_phase='prepared',custody_identity=None)) + original_id=original.snapshot()['project_id'];physical=(external/'project').stat().st_ino + with ProjectStore.create(external/'replacement','Replacement identity','replacement-project','c'*64) as replacement: + replacement_id=replacement.snapshot()['project_id'] + shutil.rmtree(external/'project'/'.tuldok') + shutil.copytree(external/'replacement'/'.tuldok',external/'project'/'.tuldok') + with owner.bound(workspace),patch('runs.Runs',side_effect=AssertionError('Cold Open constructed Runs')), \ + patch('subprocess.Popen',side_effect=AssertionError('Cold Open launched a process')): + state=workspace.status() + opened=workspace.dispatch('project_files.open',dict(binding=state['binding'],expected_checkpoint=None, + request_key='open-replaced-project',root='fixture',directory='project')) + self.assertEqual(opened['state'],'recovery_required') + self.assertEqual((external/'project').stat().st_ino,physical) + self.assertEqual(opened['project']['project_id'],replacement_id) + projection=workspace._projection;schemas.append(projection.db.schema) + self.assertFalse(projection.session.available);self.assertIsNone(projection.execution._runs) + self.assertEqual(projection.pending_original,[binding.binding]) + self.assertEqual(projection.pending_original[0]['project_id'],original_id) + with self.assertRaises(WorkbenchError) as denied: + workspace.dispatch('project_files.data_import_text',dict(binding=opened['binding'], + expected_checkpoint=opened['project']['checkpoint'],request_key='deny-replaced-project-edit', + text='No new authority',name='Blocked edit',groups=['owned'],parents=[],rights='Authored fixture')) + self.assertEqual(denied.exception.code,'projection_unavailable') + finally: + workspace.close() + with psycopg.connect(connection_info(),autocommit=True) as connection: + for schema in schemas: + connection.execute(sql.SQL('DROP SCHEMA IF EXISTS {} CASCADE').format(sql.Identifier(schema))) + + +CRASH_CHILD = r''' +import json,os,sys +from pathlib import Path +from project_workspace import ProjectWorkspace +import recipe_process +config=json.loads(Path(sys.argv[1]).read_text()) +workspace=ProjectWorkspace(config['control'],{'fixture':config['source']}) +with workspace.access.bind(Path(config['token_file']).read_text().strip()): + state=workspace.status() + workspace.dispatch('project_files.open',dict(binding=state['binding'],expected_checkpoint=None, + request_key='child-cold-open',root='fixture',directory='alpha')) + projection=workspace._projection;execution=projection.execution + projection.source_adapter.register(dict(root='fixture',manifest_path='project.json'), + projection.session.snapshot['checkpoint'],'child-current-source') + preflight=execution.preflight_external(config['graph_id'],config['graph_revision']) + body=dict(graph_id=config['graph_id'],graph_revision=config['graph_revision'],expected_digest=preflight['material_sha256']) + def crash(identity,phase): + print(json.dumps(dict(run_id=identity,projection_id=projection.db.projection_id,phase=phase,body=body)),flush=True) + os._exit(74) + if config['phase']=='started': + original=recipe_process.Handle.poll + def poll(handle): + events=original(handle) + if any(item['type']=='started' for item in events):crash(handle.spec['attempt_id'],'started') + return events + recipe_process.Handle.poll=poll + else: + original=projection.db._project_outcome + def outcome(result): + original(result) + if result=='committed': + for row in projection.db.execute('SELECT document FROM workspace_runs'): + run=json.loads(row['document']) + phase='admitted' if run['state']=='queued' else run['custody_phase'] + if phase==config['phase']:crash(run['id'],phase) + projection.db._project_outcome=outcome + execution.submit_external(body,projection.session.snapshot['checkpoint'],'original-crash-submit') + import time + time.sleep(40) + raise SystemExit('The requested real crash cut point was not reached') +''' + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'),'Requires disposable PostgreSQL') +class OriginalHostCrashTests(unittest.TestCase): + def setUp(self): + import test_project_files_m2_http as m2 + m2.ProjectFilesM2HTTP.setUp(self) + + def receipt(self): + import test_project_files_m2_http as m2 + m2.ProjectFilesM2HTTP.receipt(self) + + def cleanup_owned(self): + import test_project_files_http as m1 + m1.ProjectFilesHTTP.cleanup_owned(self) + + def select_host(self,*args,**kwargs): + import test_project_files_http as m1 + return m1.ProjectFilesHTTP.select_host(self,*args,**kwargs) + + def request(self,*args,**kwargs): + import test_project_files_m2_http as m2 + return m2.ProjectFilesM2HTTP.request(self,*args,**kwargs) + + def state(self): + import test_project_files_http as m1 + return m1.ProjectFilesHTTP.state(self) + + def envelope(self,*args,**kwargs): + import test_project_files_http as m1 + return m1.ProjectFilesHTTP.envelope(self,*args,**kwargs) + + def action(self,*args,**kwargs): + import test_project_files_http as m1 + return m1.ProjectFilesHTTP.action(self,*args,**kwargs) + + def create(self):return self.action('new',root='fixture',directory='alpha',name='Original host neutral custody') + + def arguments(self,*args,**kwargs): + import test_project_files_m2_http as m2 + return m2.ProjectFilesM2HTTP.arguments(self,*args,**kwargs) + + def transport(self,*args,**kwargs): + import test_project_files_m2_http as m2 + return m2.ProjectFilesM2HTTP.transport(self,*args,**kwargs) + + def call(self,*args,**kwargs): + import test_project_files_m2_http as m2 + return m2.ProjectFilesM2HTTP.call(self,*args,**kwargs) + + def crash_and_reconcile(self,phase): + from test_project_files_material_http import neutral_source + import recipe_process + import project_workspace + manifest,manifest_raw,definition_raw,source,exact_input=neutral_source(self.external) + self.create() + self.call('source_register',root='fixture',manifest_path='project.json') + graph=self.call('graph_create_external',name='Original-host crash fixture',project_id=manifest['id'], + recipe_id='neutral',expected_sha256=hashlib.sha256(definition_raw).hexdigest(), + inputs={'sample':{'reference_id':'sample','expected_sha256':hashlib.sha256(exact_input).hexdigest()}}, + parameters={'mode':'sleep'}) + self.action('save');self.action('close') + token_file=self.path/'child-owner.token';token_file.write_text(self.owner.token);token_file.chmod(0o600) + config=self.path/'child-config.json' + config.write_text(json.dumps(dict(control=str(self.workspace.path),source=str(self.external), + token_file=str(token_file),graph_id=graph['id'],graph_revision=graph['revision'],phase=phase))) + config.chmod(0o600) + child=subprocess.run([sys.executable,'-c',CRASH_CHILD,str(config)],capture_output=True,text=True,timeout=50) + self.assertEqual(child.returncode,74,child.stderr+child.stdout) + observed=json.loads(child.stdout.strip().splitlines()[-1]);self.assertEqual(observed['phase'],phase) + # A restarted original host reads the private binding. Open constructs + # only inert owners and leaves all current execution unavailable. + with patch('runs.Runs',side_effect=AssertionError('Open constructed a Run owner')), \ + patch('subprocess.Popen',side_effect=AssertionError('Open launched a process')): + opened=self.action('open',root='fixture',directory='alpha') + self.assertEqual(opened['state'],'recovery_required') + self.assertIsNone(self.workspace._projection.execution._runs) + pending=self.workspace.custody.pending(self.workspace._store) + self.assertEqual(len(pending),1);self.assertEqual(pending[0]['projection_id'],observed['projection_id']) + state=self.state() + with patch.object(project_workspace,'M3_ENABLED',True), \ + patch('subprocess.Popen',side_effect=AssertionError('Reconciliation spawned a process')), \ + patch('recipe_process.Handle.go',side_effect=AssertionError('Reconciliation replayed GO')), \ + patch('runs.Runs._start',side_effect=AssertionError('Reconciliation started a queue')): + arguments=dict(binding=state['binding'],expected_checkpoint=state['project']['checkpoint']) + if phase=='admitted': + # Explicit owner-only adapter uses AgentClient's actual HTTP, + # negotiated pin and request/response identity correlation. + from agent_api import AgentClient,ProjectFilesOwnerClient + client=ProjectFilesOwnerClient(self.origin,self.owner.token,self.workspace.db.dataset_id,timeout=60) + self.assertIsInstance(client,AgentClient) + self.assertEqual(client.selected_contracts()['project_files.execution_reconcile_current'],1) + result=client.call('project_files.execution_reconcile_current',arguments) + status,response=result['status'],result['result'] + else: + status,response=self.transport('execution_reconcile_current',arguments) + self.assertEqual(status,200,response) + self.assertEqual(response['binding'],state['binding']) + value=response['value'];self.assertTrue(value['available']) + self.assertEqual(value['run_ids'],[observed['run_id']]) + current=self.workspace._projection + self.assertEqual(current.db.projection_id,observed['projection_id']) + self.assertEqual(self.workspace.custody.pending(self.workspace._store),[]) + document=next(item['document'] for item in current.session.snapshot['records'] + if item['family']=='run_history' and item['document']['run']['id']==observed['run_id']) + self.assertIn(document['run']['state'],('cancelled','interrupted')) + if phase!='admitted': + edge=next(item for item in document['payloads'] if item['name']=='custody-receipt.json') + receipt=recipe_process.validate_receipt(current.artifacts.read_blob(edge['blob']),document['run']['custody_identity']) + self.assertTrue(receipt['drained']) + self.assertEqual(receipt['go_received'],phase=='started') + before=current.db.execute('SELECT count(*) FROM workspace_runs').fetchone()[0] + with self.owner.bound(self.workspace): + replay=current.execution.submit_external(observed['body'],current.session.snapshot['checkpoint'],'original-crash-submit') + self.assertEqual(replay['run_id'],observed['run_id']) + self.assertEqual(current.db.execute('SELECT count(*) FROM workspace_runs').fetchone()[0],before) + self.action('save');self.action('close') + + def test_original_host_crash_after_admission_ack_before_queue_wake(self):self.crash_and_reconcile('admitted') + def test_original_host_crash_after_go_intent_before_go(self):self.crash_and_reconcile('go_intent') + def test_original_host_crash_after_actual_recipe_started(self):self.crash_and_reconcile('started') + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_project_data.py b/tests/test_project_data.py new file mode 100644 index 00000000..bc8df37f --- /dev/null +++ b/tests/test_project_data.py @@ -0,0 +1,458 @@ +"""Workbench project closure through real PostgreSQL, files and shared sessions.""" +import base64 +import copy +import hashlib +import io +import json +from pathlib import Path +import tempfile +import unittest +import uuid +from unittest.mock import patch + +from PIL import Image +import psycopg +from psycopg import sql + +from access import Access, AccessError +from metadata import Metadata +from project_artifacts import ProjectArtifacts +from project_data import ProjectData, decode_data_records, validate_operation_result +from project_session import ProjectSession, operation_records +from project_store import ProjectStore +from workbench import WorkbenchError + + +def validate(records): + decode_data_records(records) + for receipt in operation_records(records).values(): + if not validate_operation_result(receipt['operation'], receipt['result'], records): + raise ValueError('Unexpected fixture operation') + + +class ProjectDataTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(); self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.host = self.metadata('host') + self.access = Access(self.host, self.host.path) + self.token = self.access.exchange(self.access._local_owner_token())['token'] + self.enterContext(self.access.bind(self.token)) + self.store = ProjectStore.create(self.root/'project','Data fixture','new','a'*64) + self.addCleanup(self.store.close) + self.db = self.metadata('projection') + self.artifacts = ProjectArtifacts(self.store); self.addCleanup(self.artifacts.close) + self.session = ProjectSession(self.store,self.db,self.access,self.artifacts,validate_records=validate) + self.data = ProjectData(self.session) + self.data.restore() + self.ordinal=0 + + def metadata(self, name): + db=Metadata(self.root/name) + def drop(): + with psycopg.connect(db.dsn,autocommit=True) as connection: + connection.execute(sql.SQL('DROP SCHEMA {} CASCADE').format(sql.Identifier(db.schema))) + self.addCleanup(drop);self.addCleanup(db.close) + return db + + def mutate(self, method, *args, key=None): + self.ordinal+=1 + return method(*args,self.session.snapshot['checkpoint'],key or 'op-'+str(self.ordinal)) + + def text(self, source='Hello 😀 cafe\u0301\r\n'): + return self.mutate(self.data.import_text,dict(text=source,name='Authored text',groups=['fixture'],rights='Owned fixture')) + + def annotate(self,row,task='text_classification',annotation=None): + return self.mutate(self.data.annotate,row['id'],dict(revision=row['revision'],source_revision=row['source_revision'],task=task, + annotation=annotation or {'label':'greeting'},groups=row['groups'],review='human_reviewed')) + + def image(self,color='red',groups=None): + target=io.BytesIO();Image.new('RGB',(8,6),color).save(target,'PNG') + raw=target.getvalue() + row=self.mutate(self.data.import_image,dict(image=base64.b64encode(raw).decode(),name='Small image',groups=groups or ['image-session'],rights='Owned image')) + return row,raw + + def cold(self): + snapshot=self.store.snapshot() + fresh=self.metadata('cold-'+uuid.uuid4().hex) + session=ProjectSession(self.store,fresh,self.access,self.artifacts,validate_records=validate) + adapter=ProjectData(session) + adapter.restore() + self.assertEqual(self.store.snapshot(),snapshot) + return adapter,session,fresh + + def test_text_annotation_rights_history_retry_and_cold_projection(self): + row=self.text();reviewed=self.annotate(row) + corrected=self.mutate(self.data.correct_rights_note,row['id'],dict(revision=2,source_revision=1,note='Corrected author rights'),key='rights') + self.assertEqual(corrected['record']['revision'],3) + original=self.store.snapshot() + replay=self.data.correct_rights_note(row['id'],dict(revision=2,source_revision=1,note='Corrected author rights'),'0'*64,'rights') + self.assertEqual(replay,corrected) + self.assertEqual(self.store.snapshot(),original) + other,session,db=self.cold() + self.assertEqual(other.get(row['id']),corrected['record']) + self.assertEqual(other.history(row['id']),self.data.history(row['id'])) + self.assertEqual(db.execute('SELECT original_text FROM workbench_records').fetchone()[0],'Hello 😀 cafe\u0301\r\n') + self.assertNotIn('rights_note_correction',json.loads(db.execute('SELECT provenance_json FROM workbench_records').fetchone()[0])) + + def test_responses_preferences_and_deleted_judgment_keep_exact_captures(self): + parent=self.text() + answers=[] + for completion in (' First exact\r\nanswer ','Second 😀 answer'): + result=self.mutate(self.data.save_response,dict(id=uuid.uuid4().hex,prompt_id=parent['id'],revision=0,parent_revision=1, + source_revision=1,completion=completion,review='draft')) + answers.append(result['response']) + request=dict(id=uuid.uuid4().hex,prompt_id=parent['id'],parent_revision=1,source_revision=1,left_id=answers[0]['id'],left_revision=1, + right_id=answers[1]['id'],right_revision=1,revision=0,outcome='left',rationale='Specific authored reason',review='human_reviewed') + judgment=self.mutate(self.data.save_preference,request)['judgment'] + bindings={k:judgment[k] for k in ('id','prompt_id','parent_revision','source_revision','left_id','left_revision','right_id','right_revision','revision')} + self.mutate(self.data.delete_preference,bindings) + other,_,_=self.cold() + history=other.preference_history(judgment['id'])['history'] + self.assertEqual(len(history),2) + self.assertEqual(history[0]['left']['completion'],' First exact\r\nanswer ') + self.assertTrue(history[-1]['judgment']['deleted']) + self.assertEqual(history[0]['parent'],parent) + + def test_saved_fixed_dynamic_intent_and_tombstones_survive(self): + row=self.text() + selection=self.mutate(self.data.create_selection,dict(name='Chosen revisions',items=[{k:row[k] for k in ('id','revision','source_revision')}])) + search=self.mutate(self.data.create_search,dict(name='Entered criteria',criteria=dict(q='Hello',kind='text',review='',sort='newest',task='',label='',group='',rights=''))) + self.mutate(self.data.rename_search,search['id'],dict(revision=1,name='Renamed criteria')) + self.mutate(self.data.delete_selection,selection['id'],dict(revision=1)) + self.mutate(self.data.delete_search,search['id'],dict(revision=2)) + docs={r['family']:r['document'] for r in self.store.snapshot()['records'] if r['family'].startswith('saved_')} + self.assertTrue(docs['saved_selection']['deleted']);self.assertTrue(docs['saved_search']['deleted']) + self.assertEqual(docs['saved_search']['history'][0]['criteria']['q'],'Hello') + other,_,db=self.cold() + self.assertEqual(other.list_searches(),{'searches':[]}) + self.assertEqual(other.list_selections(),{'selections':[]}) + + def test_image_exact_original_oriented_source_revisions_and_annotation(self): + row,raw=self.image() + row=self.annotate(row,'image_classification',{'label':'red'}) + body=dict(revision=1,book_id='',session_id='image-session',split='train',annotation=dict(book_present=False,crop_suitable=False,corners=[],corner_reference='image')) + saved=self.mutate(self.data.save_image_source,row['id'],body) + self.assertEqual(saved['revision'],2) + other,_,db=self.cold() + actual=other.get(row['id']) + self.assertEqual(actual['source_revision'],2) + self.assertEqual(actual['annotation'],{'label':'red'}) + self.assertEqual((db.path/'images'/row['id']/'source').read_bytes(),raw) + self.assertFalse((db.path/'images'/row['id']/'thumb.jpg').exists()) + + def test_native_mesh_spatial_audio_and_scientific_sources_roundtrip(self): + import meshes,pointclouds,volume_assets + from test_meshes import fixture as mesh_fixture + from test_spatial_storage import source + from test_audio_data import body as audio_body,segment + from test_scientific_fields import fixture as science_fixture,receipt + rows=[] + for kind,prepared in [('mesh',meshes.prepare(*mesh_fixture())),('pointcloud',pointclouds.prepare(*source('pointcloud').values())), + ('volume',volume_assets.prepare(*source('volume').values()))]: + row=self.mutate(self.data.import_bundle,kind,prepared,dict(groups=['native'],rights='Native fixture')) + rows.append(self.annotate(row,{'mesh':'mesh_geometry','pointcloud':'pointcloud_geometry','volume':'volume_grid'}[kind],{'note':'Inspected whole source'})) + audio=self.mutate(self.data.import_audio,audio_body(groups=['audio-fixture'])) + rows.append(self.annotate(audio,'audio_timed',{'segments':[segment()]})) + raw=science_fixture();science=self.mutate(self.data.import_scientific,raw,dict(groups=['science-fixture'],rights='Authored numerical source'),receipt(raw)) + rows.append(self.annotate(science,'field_sequence_fields',{'note':'Inspected exact floating fields'})) + expected={r['id']:self.data.workbench.asset(r['id'])[0] for r in rows} + other,_,db=self.cold() + for row in rows: + with self.subTest(kind=row['kind']): + self.assertEqual(other.get(row['id']),row) + self.assertEqual(other.workbench.asset(row['id'])[0],expected[row['id']]) + self.assertEqual(db.execute('SELECT count(*) FROM workbench_mesh_assets').fetchone()[0],0) + + def test_missing_payload_keeps_metadata_history_and_blocks_consumption(self): + from test_audio_data import body as audio_body + row=self.mutate(self.data.import_audio,audio_body(groups=['audio-fixture'])) + doc=next(r['document'] for r in self.store.snapshot()['records'] if r['family']=='workbench_record') + declaration=next(r['document'] for r in self.store.snapshot()['records'] if r['family']=='project_artifact') + payload=self.store.path/'artifacts'/'sha256'/declaration['sha256'] + payload.unlink() + other,_,_=self.cold() + actual=other.get(row['id']) + self.assertEqual(actual['audio'],doc['current']['audio']) + self.assertFalse(actual['source_available']) + self.assertEqual(other.history(row['id']),[row]) + with self.assertRaises(ValueError) as error:other.workbench.asset(row['id']) + self.assertEqual(error.exception.code,'unavailable') + + def test_agent_draft_scopes_owner_review_and_receipt_authorization(self): + agent=self.access.issue(dict(label='Data fixture',scopes=['inspect','edit'])) + with self.access.bind(agent['token']): + row=self.text() + before=self.store.snapshot() + with self.assertRaises(AccessError):self.annotate(row) + self.assertEqual(self.store.snapshot(),before) + token=self.access.issue(dict(label='Inspect only',scopes=['inspect']))['token'] + with self.access.bind(token),self.assertRaises(AccessError): + self.data.import_text(dict(text='Hello 😀 cafe\u0301\r\n',name='Authored text',groups=['fixture'],rights='Owned fixture'),'0'*64,'op-1') + self.assertNotIn(agent['token'],json.dumps(self.store.snapshot())) + + def test_current_job_data_access_requires_exact_local_admission_and_canonical_window(self): + runtime=Access(self.db,self.db.path) + self.session.runtime_access=runtime + owner=self.access.identity() + self.assertEqual(self.data._authority.identity()['actor_id'],owner['actor_id']) + # Trusted execution creates a local admission; it is never reconstructed + # from any project record. These rows belong only to this private fixture. + admission,job=uuid.uuid4().hex,uuid.uuid4().hex + actor=self.access.require('edit','fixture.job-data') + self.db.execute('INSERT INTO access_admissions VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)', + (admission,self.db.dataset_id,actor.actor_id,actor.credential_id,'synthetic',job,'a'*64, + json.dumps({'dataset.add_generated':[job],'workbench.import_asset':[None]}),0)) + before=self.store.snapshot() + with runtime._job('synthetic',job,admission,'a'*64): + self.assertEqual(self.data._authority.identity()['mode'],'job') + self.assertEqual(self.data._authority.require_job('synthetic',job,admission,'a'*64).job_id,job) + with self.assertRaises(WorkbenchError): + self.data.workbench.import_asset(dict(kind='text',text='Unpublished direct job source',groups=['fixture'],rights='Owned')) + self.data._authority.facts=[] + try: + self.assertEqual(self.data._authority.require('edit','dataset.add_generated',job).job_id,job) + with self.assertRaises(AccessError):self.data._authority.require('edit','dataset.add_generated','wrong-job') + with self.assertRaises(AccessError):self.data._authority.require_job('synthetic','wrong-job',admission,'a'*64) + with self.assertRaises(AccessError):self.data._authority.require('approve-review','dataset.add_generated',job,owner_only=True) + self.db.execute('DELETE FROM access_admissions WHERE id=%s',(admission,)) + with self.assertRaises(AccessError):self.data._authority.identity() + with self.assertRaises(AccessError):self.data._authority.require('edit','dataset.add_generated',job) + finally:self.data._authority.facts=None + self.assertEqual(self.store.snapshot(),before) + self.assertEqual(self.db.execute('SELECT count(*) FROM workbench_records').fetchone()[0],0) + + def test_foreign_job_context_and_missing_runtime_bridge_never_assume_host_owner(self): + local=Access(self.db,self.db.path) + actor=self.access.require('edit','fixture.job-data') + admission,job=uuid.uuid4().hex,uuid.uuid4().hex + def insert(db): + db.execute('INSERT INTO access_admissions VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)', + (admission,db.dataset_id,actor.actor_id,actor.credential_id,'synthetic',job,'a'*64, + json.dumps({'workbench.import_asset':[None]}),0)) + insert(self.db) + with local._job('synthetic',job,admission,'a'*64): + with self.assertRaises(AccessError):self.data._authority.identity() + self.data._authority.facts=[] + try: + with self.assertRaises(AccessError):self.data.workbench.import_asset(dict(kind='text',text='Missing runtime bridge',groups=['fixture'],rights='Owned')) + finally:self.data._authority.facts=None + foreign_db=self.metadata('foreign-job');foreign=Access(foreign_db,foreign_db.path);insert(foreign_db) + self.session.runtime_access=local + with foreign._job('synthetic',job,admission,'a'*64): + self.data._authority.facts=[] + try: + with self.assertRaises(AccessError):self.data._authority.identity() + with self.assertRaises(AccessError):self.data.workbench.import_asset(dict(kind='text',text='Foreign continuation',groups=['fixture'],rights='Owned')) + finally:self.data._authority.facts=None + insert(self.host) + with self.access._job('synthetic',job,admission,'a'*64): + self.data._authority.facts=[] + try: + with self.assertRaises(AccessError):self.data.workbench.import_asset(dict(kind='text',text='Host catalog job is foreign here',groups=['fixture'],rights='Owned')) + finally:self.data._authority.facts=None + self.assertEqual(self.db.execute('SELECT count(*) FROM workbench_records').fetchone()[0],0) + self.assertEqual(self.data._authority.identity()['actor_id'],actor.actor_id) + + def test_runtime_capture_window_requires_outer_transaction_and_closes_at_outcome(self): + self.session.runtime_access=Access(self.db,self.db.path) + with self.assertRaises(WorkbenchError),self.data.runtime_mutation('data.runtime_grounded_text','window'): + self.fail('An outer SQL transaction is required') + before=self.session.snapshot['records'] + with self.db.transaction(force_rollback=True): + with self.data.runtime_mutation('data.runtime_grounded_text','window'): + row=self.data.workbench.import_asset(dict(kind='text',text='Transaction-scoped authored capture',groups=['fixture'],rights='Owned')) + # The window remains available to the actual before-COMMIT hook. + records=self.data.capture_runtime_changes(before,operation='data.runtime_grounded_text',key='window') + self.assertEqual(records[0]['id'],row['id']) + self.assertEqual(records[0]['document']['events'][0]['operation'],'data.runtime_grounded_text') + self.assertEqual(self.data.capture_runtime_changes(before,operation='data.runtime_grounded_text',key='window'),records) + with self.assertRaises(WorkbenchError):self.data.workbench.import_asset(dict(kind='text',text='No write after capture',groups=['fixture'],rights='Owned')) + self.assertIsNone(self.data._authority.facts) + self.assertEqual(self.db.execute('SELECT count(*) FROM workbench_records').fetchone()[0],0) + self.assertEqual(self.session.snapshot['records'],before) + with self.db.transaction(): + with self.assertRaises(WorkbenchError),self.data.runtime_mutation('unsupported-window','bad'): + self.fail('Only finite owner windows are admitted') + + def test_bad_closed_shapes_and_broken_capture_refuse_before_restore_sql(self): + row=self.text() + snapshot=self.store.snapshot();bad=copy.deepcopy(snapshot) + document=next(r['document'] for r in bad['records'] if r['family']=='workbench_record') + document['source']['original_text']='Altered source' + with self.assertRaises(WorkbenchError):decode_data_records(bad['records']) + self.assertEqual(self.data.get(row['id']),row) + from project_data import _origin + source=dict(kind='text',original_text='Hello 😀 cafe\u0301\r\n') + acquisition=dict(format='tuldok_assets_jsonl_v1',request_id=uuid.uuid4().hex,row_sha256='a'*64, + declared=dict(manifest_name='assets.jsonl',row_number=1)) + origin=dict(row['provenance'],acquisition=acquisition) + _origin(origin,row,source,self.session.snapshot['records']) + origin['acquisition']['declared']['token_sha256']='b'*64 + with self.assertRaises(WorkbenchError):_origin(origin,row,source,self.session.snapshot['records']) + malformed=copy.deepcopy(snapshot) + document=next(r['document'] for r in malformed['records'] if r['family']=='workbench_record') + document['audit'][0]['details']['nested']={'token_sha256':'b'*64} + with self.assertRaises(WorkbenchError):decode_data_records(malformed['records']) + + def test_release_freeze_preserves_exact_zip_manifest_and_cold_bytes(self): + row=self.annotate(self.text()) + body=dict(items=[{k:row[k] for k in ('id','revision','source_revision')}],ratios=dict(train=100,validation=0,test=0),seed=42) + release=self.mutate(self.data.freeze_release,body,key='freeze') + raw=(self.data.releases.path/(release['id']+'.zip')).read_bytes() + self.assertEqual(hashlib.sha256(raw).hexdigest(),release['id']) + other,_,_=self.cold() + self.assertEqual((other.releases.path/(release['id']+'.zip')).read_bytes(),raw) + replay=self.data.freeze_release(body,'0'*64,'freeze') + self.assertEqual(replay,release) + + def test_actual_sql_commit_failure_retains_unique_payload_and_reconstructs(self): + self.db.execute("CREATE FUNCTION fixture_refuse_commit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'deferred fixture' USING ERRCODE='23514'; END $$") + self.db.execute('CREATE CONSTRAINT TRIGGER fixture_commit_failure AFTER INSERT ON workbench_records DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION fixture_refuse_commit()') + with self.assertRaises(WorkbenchError) as error:self.image() + self.assertEqual(error.exception.code,'projection_unavailable') + records=self.store.snapshot()['records'] + artifacts=[r['document'] for r in records if r['family']=='project_artifact'] + self.assertTrue(artifacts) + for declaration in artifacts:self.assertEqual(self.artifacts.status(declaration)['state'],'verified') + other,_,_=self.cold() + self.assertEqual(other.query()['total'],1) + + def test_image_peer_split_capture_saved_binding_and_deletion_preserve_artifacts(self): + from project_producers import ProjectProducers + first,_=self.image('red',['shared-session']);second,_=self.image('blue',['shared-session']) + first=self.annotate(first,'image_detection',{'boxes':[dict(label='fixture',x=1,y=1,width=3,height=2)]}) + source_body=dict(revision=1,book_id='',session_id='shared-session',split='train', + annotation=dict(book_present=False,crop_suitable=False,corners=[],corner_reference='image')) + self.mutate(self.data.save_image_source,first['id'],source_body) + peer=self.data.get(second['id']) + self.assertEqual((peer['source_revision'],peer['source_split']),(2,'train')) + selection=self.mutate(self.data.create_selection,dict(name='Intermediate source revision',items=[{k:peer[k] for k in ('id','revision','source_revision')}])) + self.mutate(self.data.save_image_source,second['id'],dict(source_body,revision=2)) + producers=ProjectProducers(self.session);producers.restore(self.session.snapshot) + self.data=ProjectData(self.session,generation_entries=producers.generation_jobs) + deleted=self.mutate(self.data.delete_image_source,first['id'],dict(revision=2)) + self.assertFalse(deleted['record']['source_available']) + self.assertFalse((self.db.path/'images'/first['id']).exists()) + document=next(r['document'] for r in self.store.snapshot()['records'] if r['family']=='workbench_record' and r['id']==first['id']) + self.assertTrue(document['source']['deleted']) + self.assertEqual(len(document['source']['revisions']),2) + for edge in (document['source']['original'],document['source']['oriented']): + declaration=next(r['document'] for r in self.store.snapshot()['records'] if r['family']=='project_artifact' and r['id']==edge['artifact']['sha256']) + self.assertEqual(self.artifacts.status(declaration)['state'],'verified') + other,_,_=self.cold() + self.assertFalse(other.get(first['id'])['source_available']) + self.assertEqual(other.load_selection(selection['id'])['members'][0]['status'],'stale') + + def test_legacy_m1_import_and_annotation_lost_replies_reconcile_after_cutover(self): + from project_text import ProjectText + legacy=ProjectText(self.store,self.db,self.access);legacy.restore() + body=dict(kind='text',text='M1 exactsource',name='Legacy original',groups=['legacy'],rights='Owned') + initial=legacy.checkpoint + old=legacy.import_text(body,initial,'legacy-import') + annotation=dict(revision=1,source_revision=1,task='text_classification',annotation={'label':'oldlabel'},groups=['legacy'],review='human_reviewed') + prior=legacy.checkpoint + reviewed=legacy.annotate(old['id'],annotation,prior,'legacy-annotate') + self.session.adopt(self.store.snapshot());self.data=ProjectData(self.session) + self.text('A new M2 source') + checkpoint=self.session.snapshot['checkpoint'] + replay=self.data.import_text(body,initial,'legacy-import') + self.assertEqual(replay['id'],old['id']);self.assertEqual(replay['revision'],1) + self.assertEqual(self.data.annotate(old['id'],annotation,prior,'legacy-annotate')['revision'],2) + with self.assertRaises(WorkbenchError) as error: + self.data.import_text(dict(body,text='different'),initial,'legacy-import') + self.assertEqual(error.exception.code,'conflict') + self.assertEqual(self.session.snapshot['checkpoint'],checkpoint) + with self.assertRaises(WorkbenchError) as error: + self.mutate(self.data.correct_rights_note,old['id'],dict(revision=2,source_revision=1,note='No implicit conversion')) + self.assertEqual(error.exception.code,'unsupported') + self.assertEqual(self.data.query()['total'],2) + new_annotation=dict(annotation,revision=2,annotation={'label':'M2 retained café'}) + current=self.mutate(self.data.annotate,old['id'],new_annotation,key='new-legacy-annotation') + self.assertEqual(current['revision'],3) + self.assertEqual(next(r['family'] for r in self.session.snapshot['records'] if r['id']==old['id']),'workbench_text') + replay=self.data.annotate(old['id'],new_annotation,'0'*64,'new-legacy-annotation') + self.assertEqual(replay,current) + other,_,_=self.cold() + self.assertEqual(other.get(old['id']),current) + document=next(r['document'] for r in self.session.snapshot['records'] if r['id']==old['id']) + self.assertEqual(other.history(old['id']),list(reversed(document['history']))) + + def test_sequence_uses_its_existing_owner_and_exact_bundle(self): + from test_sequences import fixture + from sequence_assets import SequenceAssets + files={name:base64.b64encode(raw).decode() for name,raw in zip(('run.json','frames.jsonl'),fixture())} + prepared=SequenceAssets.prepare_project_files(files) + with self.assertRaises(WorkbenchError):SequenceAssets.prepare_project_files(dict(files,unexpected='')) + with self.assertRaises(WorkbenchError):SequenceAssets.prepare_project_files(dict(files,**{'run.json':'not base64'})) + row=self.mutate(self.data.import_bundle,'sequence',prepared,dict(groups=['sequence-fixture'],rights='Source-derived authored fixture')) + other,_,_=self.cold() + self.assertEqual(other.get(row['id']),row) + self.assertEqual(other.workbench.asset(row['id'])[0],prepared['bundle']) + + def test_forged_well_shaped_receipt_result_is_not_a_retained_revision(self): + row=self.text();forged=copy.deepcopy(row);forged['name']='Valid text but never authored' + with self.assertRaisesRegex(WorkbenchError,'retained'): + validate_operation_result('data.import_text',forged,self.store.snapshot()['records']) + + def test_corrupt_payload_and_metadata_refuse_reconstruction_before_sql(self): + from test_audio_data import body as audio_body + row=self.mutate(self.data.import_audio,audio_body(groups=['audio-fixture'])) + snapshot=self.store.snapshot();modified=copy.deepcopy(snapshot['records']) + document=next(r['document'] for r in modified if r['family']=='workbench_record') + document['source']['metadata']['sample_rate_hz']=0 + with self.assertRaises(WorkbenchError):decode_data_records(modified) + declaration=next(r['document'] for r in snapshot['records'] if r['family']=='project_artifact') + payload=self.store.path/'artifacts'/'sha256'/declaration['sha256'] + payload.chmod(0o600) + payload.write_bytes(b'changed') + fresh=self.metadata('invalid-cold');session=ProjectSession(self.store,fresh,self.access,self.artifacts,validate_records=validate) + adapter=ProjectData(session) + with self.assertRaises(WorkbenchError):adapter.restore() + self.assertEqual(fresh.execute('SELECT count(*) FROM workbench_records').fetchone()[0],0) + + def test_release_variants_and_freeze_only_agent_keep_exact_owner_outputs(self): + # Exercise the persisted variant decoder, not just legacy release code. + labeled=[] + for index in range(6): + row=self.mutate(self.data.import_text,dict(text=f'Independent class source {index}',groups=[f'class-fixture-{index}'],rights='Authored source')) + labeled.append(self.annotate(row,annotation={'label':'greeting' if index%2 else 'other'})) + def freeze(body): + preview=self.data.preview_release(body) + self.assertTrue(preview['eligible'],preview.get('blockers')) + return self.mutate(self.data.freeze_release,dict(body,preview_token=preview['preview_token'])) + ratio=dict(train=100,validation=0,test=0) + request=dict(format='text_classification_v1',items=[{k:r[k] for k in ('id','revision','source_revision')} for r in labeled],ratios=dict(train=34,validation=33,test=33),seed=42) + for seed in range(100): + request['seed']=seed + if self.data.preview_release(request)['eligible']:break + agent=self.access.issue(dict(label='Freeze only',scopes=['inspect','freeze'])) + with self.access.bind(agent['token']): + frozen=freeze(request) + self.assertEqual(frozen['records'],6) + parent=self.text('Prompt for exact archived answers') + answers=[] + for text in ('Chosen answer','Other answer'): + answers.append(self.mutate(self.data.save_response,dict(id=uuid.uuid4().hex,prompt_id=parent['id'],revision=0,parent_revision=1, + source_revision=1,completion=text,review='human_reviewed'))['response']) + response_ref=dict(id=answers[0]['id'],revision=1,prompt_id=parent['id'],parent_revision=1,source_revision=1) + instruction=freeze(dict(format='text_instruction_v1',items=[response_ref],ratios=ratio,seed=42)) + judgment=self.mutate(self.data.save_preference,dict(id=uuid.uuid4().hex,prompt_id=parent['id'],parent_revision=1,source_revision=1, + left_id=answers[0]['id'],left_revision=1,right_id=answers[1]['id'],right_revision=1,revision=0,outcome='left',rationale='Explicit selected answer',review='human_reviewed'))['judgment'] + ref={k:judgment[k] for k in ('id','prompt_id','parent_revision','source_revision','left_id','left_revision','right_id','right_revision','revision')} + preference=freeze(dict(format='text_preference_v1',items=[ref],ratios=ratio,seed=42)) + captions=[] + for index,color in enumerate(('red','blue','green')): + image,_=self.image(color,[f'caption-session-{index}']) + captions.append(self.annotate(image,'image_caption',{'caption':f'A small {color} fixture'})) + caption_request=dict(format='image_caption_v1',items=[{k:r[k] for k in ('id','revision','source_revision')} for r in captions],ratios=dict(train=34,validation=33,test=33),seed=42) + for seed in range(100): + caption_request['seed']=seed + if self.data.preview_release(caption_request)['eligible']:break + caption_release=freeze(caption_request) + other,_,_=self.cold() + for release in (frozen,instruction,preference,caption_release): + self.assertEqual((other.releases.path/(release['id']+'.zip')).read_bytes(),(self.data.releases.path/(release['id']+'.zip')).read_bytes()) + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_project_data_fork.py b/tests/test_project_data_fork.py new file mode 100644 index 00000000..a63da59f --- /dev/null +++ b/tests/test_project_data_fork.py @@ -0,0 +1,123 @@ +"""Pure independent data construction, checked with real owner/PG fixtures.""" +import copy +import uuid +import unittest + +from project_data import decode_data_records +from project_data_fork import fork_records, validate_records +from project_fork import ARCHIVE_FAMILIES, _digest, _input, active_records +from workbench import WorkbenchError +import test_project_data as data_fixture + + +class DataForkTests(unittest.TestCase): + def setUp(self): + self.fixture=data_fixture.ProjectDataTests('test_text_annotation_rights_history_retry_and_cold_projection') + self.fixture.setUp();self.addCleanup(self.fixture.doCleanups) + + def fork(self, records=None, source=None): + f=self.fixture; snap=f.session.snapshot + records=copy.deepcopy(records if records is not None else snap['records']) + source=source or snap['project_id'];destination=uuid.uuid4().hex + made=fork_records(records,source,snap['checkpoint'],destination, + id_factory=lambda *key:uuid.uuid4().hex,created_at='2026-10-22T12:00:00+00:00') + origin=dict(project_id=source,checkpoint=snap['checkpoint']) + doc=dict(format='project_fork_v1',source=origin,destination_project_id=destination, + request_key='fork-fixture',directory='fork-fixture',name='Derived fixture', + request_sha256=_digest(_input(origin,'fork-fixture','Derived fixture','fork-fixture')), + mapping=made['mapping'],archived=sorted([dict(family=r['family'],id=r['id']) for r in records if r['family'] in ARCHIVE_FAMILIES],key=lambda x:(x['family'],x['id'])), + artifacts=dict(copied=sorted(r['id'] for r in records if r['family']=='project_artifact'),missing_regenerable=[])) + combined=records+made['records']+[dict(family='project_fork',id=destination,document=doc)] + decode_data_records(combined);validate_records(combined) + return made,combined,destination + + def test_all_modality_sources_keep_exact_bytes_metadata_and_fresh_drafts(self): + f=self.fixture + text=f.annotate(f.text());image,_=f.image();f.annotate(image,'image_detection',{'boxes':[dict(label='fixture',x=1,y=1,width=2,height=2)]}) + import meshes,pointclouds,volume_assets + from test_meshes import fixture as mesh_source + from test_spatial_storage import source + from sequence_assets import SequenceAssets + from test_sequences import fixture as sequence_source + import base64 + for kind,prepared in [('mesh',meshes.prepare(*mesh_source())),('pointcloud',pointclouds.prepare(*source('pointcloud').values())),('volume',volume_assets.prepare(*source('volume').values())), + ('sequence',SequenceAssets.prepare_project_files({name:base64.b64encode(raw).decode() for name,raw in zip(('run.json','frames.jsonl'),sequence_source())}))]: + f.mutate(f.data.import_bundle,kind,prepared,dict(groups=['fork-native'],rights='Owned native fixture')) + from test_audio_data import body + f.mutate(f.data.import_audio,body(groups=['fork-audio'])) + from test_scientific_fields import fixture as scientific_source,receipt + raw=scientific_source();f.mutate(f.data.import_scientific,raw,dict(groups=['fork-fields'],rights='Owned fields'),receipt(raw)) + original=f.session.snapshot['records'];unchanged=copy.deepcopy(original) + made,combined,destination=self.fork() + self.assertEqual(original,unchanged) + self.assertEqual({r['document']['current']['kind'] for r in made['records']},{'text','image','sequence','mesh','pointcloud','volume','audio','field_sequence'}) + self.assertTrue(all(r['document']['current']['review']=='draft' for r in made['records'])) + self.assertTrue(all(r['document']['current']['revision']==1 and r['document']['current']['source_revision']==1 for r in made['records'])) + self.assertTrue(all(not r['document']['audit'] and not r['document']['events'] for r in made['records'])) + self.assertEqual(len(active_records(combined,project_id=destination)),len(made['records'])+sum(r['family'] not in ARCHIVE_FAMILIES for r in original)+1) + with self.assertRaises(WorkbenchError):decode_data_records(made['records']+[r for r in original if r['family']=='project_artifact']) + + def test_response_versions_preferences_and_saved_intent_have_exact_new_bindings(self): + f=self.fixture;parent=f.text();responses=[] + for value in ('Original chosen answer','Other original answer'): + responses.append(f.mutate(f.data.save_response,dict(id=uuid.uuid4().hex,prompt_id=parent['id'],revision=0,parent_revision=1,source_revision=1,completion=value,review='human_reviewed'))['response']) + judgment=f.mutate(f.data.save_preference,dict(id=uuid.uuid4().hex,prompt_id=parent['id'],parent_revision=1,source_revision=1,left_id=responses[0]['id'],left_revision=1,right_id=responses[1]['id'],right_revision=1,revision=0,outcome='left',rationale='Original comparison',review='human_reviewed'))['judgment'] + f.mutate(f.data.save_response,dict(id=responses[0]['id'],prompt_id=parent['id'],revision=1,parent_revision=1,source_revision=1,completion='Later different answer',review='human_reviewed')) + f.mutate(f.data.create_selection,dict(name='Original fixed membership',items=[dict(id=parent['id'],revision=1,source_revision=1)])) + search=f.mutate(f.data.create_search,dict(name='Retired criteria',criteria=dict(q='',kind='text',review='',sort='newest',task='',label='',group='',rights=''))) + f.mutate(f.data.delete_search,search['id'],dict(revision=1)) + made,combined,_=self.fork() + answers=[r['document']['current'] for r in made['records'] if r['family']=='workbench_response'] + self.assertEqual({r['completion'] for r in answers},{'Original chosen answer','Other original answer','Later different answer'}) + self.assertEqual(sum(m['source_id']==responses[0]['id'] for m in made['mapping']),2) + pref=next(r['document'] for r in made['records'] if r['family']=='workbench_preference') + self.assertEqual(pref['history'][0]['left']['completion'],'Original chosen answer') + self.assertEqual(pref['current']['review'],'draft') + self.assertFalse(any(r['family']=='saved_search' for r in made['records'])) + forged=copy.deepcopy(combined) + document=next(r['document'] for r in forged if r['family']=='workbench_preference' and r['id']==pref['current']['id']) + document['history'][0]['left']['completion']='Forged different comparison' + with self.assertRaises(WorkbenchError):decode_data_records(forged) + + def test_deleted_sources_remain_archived_and_live_child_keeps_exact_ancestry(self): + f=self.fixture + from project_producers import ProjectProducers + image,_=f.image();parent=f.text('Independent authored parent') + child=f.mutate(f.data.import_text,dict(text='Derived from deleted image',name='Derived text',groups=['ancestry'],parents=[image['id'],parent['id']],rights='Owned derived text')) + producers=ProjectProducers(f.session);producers.restore(f.session.snapshot) + from project_data import ProjectData + f.data=ProjectData(f.session,generation_entries=producers.generation_jobs) + f.mutate(f.data.delete_image_source,image['id'],dict(revision=1)) + made,combined,_=self.fork() + self.assertFalse(any(m['source_id']==image['id'] for m in made['mapping'])) + fresh=next(r['document']['current'] for r in made['records'] if r['document']['current']['text']==child['text']) + self.assertIn(image['id'],fresh['parents']) + self.assertNotIn(parent['id'],fresh['parents']) + self.assertTrue(any(r['id']==image['id'] for r in combined)) + + def test_legacy_text_explicit_conversion_and_forged_origin_refuse(self): + f=self.fixture + from project_text import ProjectText + legacy=ProjectText(f.store,f.db,f.access);legacy.restore() + row=legacy.import_text(dict(text='M1 fork source',name='Original legacy',groups=['legacy'],rights='Owned'),legacy.checkpoint,'legacy-fork-source') + f.session.adopt(f.store.snapshot()) + original=f.session.snapshot['records'];made,combined,_=self.fork() + self.assertEqual(made['records'][0]['family'],'workbench_record') + self.assertEqual(made['mapping'][0]['family'],'workbench_text') + self.assertEqual(combined[0],original[0]) + forged=copy.deepcopy(combined) + record=next(r for r in forged if r['id']==made['records'][0]['id']) + for value in [record['document']['origin'],record['document']['current']['provenance'],record['document']['history'][0]['provenance']]:value['origin']['checkpoint']='f'*64 + with self.assertRaises(WorkbenchError):decode_data_records(forged) + + def test_nested_fork_has_fresh_ids_and_exact_prior_archives(self): + f=self.fixture;f.text() + first,records,identity=self.fork() + old=copy.deepcopy(records) + second,combined,_=self.fork(records,identity) + self.assertTrue(set(r['id'] for r in first['records']).isdisjoint(r['id'] for r in second['records'])) + self.assertEqual(combined[:len(old)],old) + self.assertEqual(second['records'][0]['document']['origin']['origin']['project_id'],identity) + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_project_dispatch.py b/tests/test_project_dispatch.py new file mode 100644 index 00000000..f55de4cb --- /dev/null +++ b/tests/test_project_dispatch.py @@ -0,0 +1,333 @@ +"""Fixed consumer composition evidence, separate from canonical owner I/O tests.""" +import copy +from types import SimpleNamespace +import unittest +from unittest.mock import Mock + +import agent_api +from pipeline import decode_graph +from project_dispatch import SUPPORTED_OPERATIONS, dispatch +from project_material import ProjectMaterial +from workbench import WorkbenchError + +PROJECT, RECORD, ORIGIN = 'a' * 32, 'b' * 32, 'c' * 32 +CHECKPOINT, SHA = 'd' * 64, 'e' * 64 +BOUND = dict(binding='f' * 32, expected_checkpoint=CHECKPOINT, request_key='dispatch-fixture-1') +READ = dict(binding='f' * 32, checkpoint=CHECKPOINT) + + +class Authority: + def __init__(self, deny_owner=False): + self.calls = [] + self.deny_owner = deny_owner + + def require(self, *args, **kwargs): + self.calls.append((args, kwargs)) + if kwargs.get('owner_only') and self.deny_owner: + raise WorkbenchError('Current owner authority is required.', 'forbidden', 403) + return SimpleNamespace(actor_id=PROJECT) + + +def projection(): + access = Authority() + session = SimpleNamespace(access=access, check=Mock(), snapshot={'project_id': PROJECT, 'records': []}) + return SimpleNamespace(session=session, data=Mock(), materials=Mock(), history=Mock(), + context=SimpleNamespace(models=Mock(), pipelines=Mock()), artifacts=Mock(), + producers=Mock(), source_adapter=Mock(), runtime_manifest=Mock(), disclose=Mock(), resolve_artifact=Mock(), execution=SimpleNamespace(study_execution=Mock(), producer_execution=Mock(), health=Mock(), preflight_external=Mock(), approve_external=Mock(), submit_external=Mock(), cancel_run=Mock(), reconcile_current=Mock())) + + +class Dispatch(unittest.TestCase): + def test_supported_operations_match_the_finite_public_contract(self): + self.assertEqual(SUPPORTED_OPERATIONS, agent_api.PROJECT_FILES_M2 | agent_api.PROJECT_FILES_M3 | (agent_api.PROJECT_FILES_M4 - {'project_files.save_as'})) + with self.assertRaises(WorkbenchError) as failure: + dispatch(object(), 'project_files.runs_execute', {}) + self.assertEqual(failure.exception.code, 'unsupported') + + def test_current_execution_dispatch_is_finite_and_returns_native_values(self): + selected = projection() + selected.execution.preflight_external.return_value = {'material_sha256': SHA} + arguments = dict(READ, graph_id=RECORD, revision=3) + agent_api.validate_arguments('project_files.execution_preflight_external', arguments) + self.assertEqual(dispatch(selected, 'project_files.execution_preflight_external', arguments), {'material_sha256': SHA}) + selected.execution.preflight_external.assert_called_once_with(RECORD, 3) + arguments = dict(BOUND, family='text_proposal_history', producer_id=RECORD, + document={'revision': 2, 'decision': 'apply_draft', 'labels': ['stable']}) + agent_api.validate_arguments('project_files.execution_producer_decide', arguments) + dispatch(selected, 'project_files.execution_producer_decide', arguments) + selected.execution.producer_execution.decide.assert_called_once_with('text_proposal_history', RECORD, + arguments['document'], CHECKPOINT, BOUND['request_key']) + arguments = dict(binding=READ['binding'], expected_checkpoint=CHECKPOINT) + agent_api.validate_arguments('project_files.execution_reconcile_current', arguments) + dispatch(selected, 'project_files.execution_reconcile_current', arguments) + selected.execution.reconcile_current.assert_called_once_with() + self.assertEqual(agent_api.contract('project_files.execution_reconcile_current')['retry'], 'never') + with self.assertRaises(agent_api.AgentAPIError): + agent_api.validate_arguments('project_files.execution_reconcile_current', dict(arguments, request_key='invented')) + + def test_missing_current_adapters_refuse_before_any_effect(self): + selected = projection() + selected.execution.study_execution = None + with self.assertRaises(WorkbenchError) as failure: + dispatch(selected, 'project_files.execution_study_stop', dict(BOUND, study_id=RECORD, revision=2)) + self.assertEqual((failure.exception.code, failure.exception.status), ('unavailable', 409)) + selected.execution.producer_execution = None + with self.assertRaises(WorkbenchError) as failure: + dispatch(selected, 'project_files.execution_producer_cancel', dict(BOUND, family='grounded_history', producer_id=RECORD)) + self.assertEqual(failure.exception.code, 'unavailable') + selected.execution = None + with self.assertRaises(WorkbenchError) as failure: + dispatch(selected, 'project_files.execution_health', READ) + self.assertEqual(failure.exception.code, 'unavailable') + + def test_native_producer_grammars_refuse_wrong_family_documents(self): + base = dict(BOUND, family='caption_proposal_history', document=dict(request_id=RECORD, + source_id=PROJECT, revision=2, source_revision=1, server_url='http://127.0.0.1:12345', + model='current-model', instruction='Describe the captured image', seed=1)) + agent_api.validate_arguments('project_files.execution_producer_start', base) + typed = copy.deepcopy(base); typed['document'].update(protocol='pumas_typed_v1', profile=None, seed=None) + agent_api.validate_arguments('project_files.execution_producer_start', typed) + for change in ({'family': 'generation_history'}, {'family': 'arbitrary_callable'}, + {'document': dict(base['document'], credential='not-accepted')}): + with self.assertRaises(agent_api.AgentAPIError): + agent_api.validate_arguments('project_files.execution_producer_start', dict(base, **change)) + with self.assertRaises(agent_api.AgentAPIError): + agent_api.validate_arguments('project_files.execution_submit_external', dict(BOUND, + graph_id=RECORD, graph_revision=1, expected_digest=SHA, request_key='short')) + + def test_metadata_calls_preserve_native_body_and_canonical_arguments(self): + selected = projection() + selected.data.correct_rights_note.return_value = {'changed': True} + arguments = dict(BOUND, record_id=RECORD, revision=3, source_revision=1, note='Unique authored rights note') + agent_api.validate_arguments('project_files.data_rights', arguments) + self.assertEqual(dispatch(selected, 'project_files.data_rights', arguments), {'changed': True}) + selected.data.correct_rights_note.assert_called_once_with(RECORD, + {'revision': 3, 'source_revision': 1, 'note': 'Unique authored rights note'}, CHECKPOINT, BOUND['request_key']) + selected.history.prepare_trial_from_material.return_value = {'id': RECORD} + ref = dict(kind='recipe_material', catalog_id=ORIGIN, id=SHA, sha256=SHA) + arguments = dict(BOUND, study_id=RECORD, revision=2, material=ref, replicate=0) + dispatch(selected, 'project_files.trial_prepare', arguments) + selected.history.prepare_trial_from_material.assert_called_once_with(RECORD, + {'revision': 2, 'material': ref, 'replicate': 0}, CHECKPOINT, BOUND['request_key']) + + def test_external_graph_creation_uses_canonical_material_boundary(self): + selected = projection() + body = {'name': 'New external scientific recipe', 'project_id': RECORD, + 'recipe_id': 'owned_recipe', 'expected_sha256': SHA, 'inputs': {}, 'parameters': {}} + arguments = dict(BOUND, **body) + agent_api.validate_arguments('project_files.graph_create_external', arguments) + selected.materials.mutate_definition.side_effect = lambda family, operation, target, value, cp, key, invoke: invoke() + selected.context.pipelines.create_external.return_value = {'id': RECORD, 'revision': 1} + result = dispatch(selected, 'project_files.graph_create_external', arguments) + self.assertEqual(result, {'id': RECORD, 'revision': 1}) + selected.context.pipelines.create_external.assert_called_once_with(body) + captured = selected.materials.mutate_definition.call_args.args + self.assertEqual(captured[:6], ('pipeline_graph', 'graphs.create_external', None, + body, CHECKPOINT, BOUND['request_key'])) + + def test_project_graph_inputs_reuse_current_domain_grammar_without_legacy_changes(self): + from contextlib import nullcontext + import threading + from pipeline import Pipelines + for project, domain in (('graph_create', 'graphs.create'), + ('graph_create_external', 'graphs.create_external'), + ('graph_save', 'graphs.save')): + expected = copy.deepcopy(agent_api.operation(domain)['inputSchema']) + expected['properties'].update(copy.deepcopy(agent_api._PROJECT_FILES_MUTATION)) + expected['required'] = list(dict.fromkeys(expected['required'] + list(agent_api._PROJECT_FILES_MUTATION))) + self.assertEqual(agent_api.operation('project_files.' + project)['inputSchema'], expected) + # The real metadata owner authors each model graph without a worker or + # runtime. Only its SQL transaction sink is substituted in this grammar test. + owner = object.__new__(Pipelines) + owner.lock = threading.RLock() + owner.access = Mock() + owner.db = SimpleNamespace(transaction=lambda: nullcontext(), execute=Mock()) + for recipe in ('image_classification_model_v2', 'text_classification_model_v2', 'audio_classification_model_v2'): + body = {'name': 'Current typed model graph', 'release_id': SHA, 'recipe': recipe} + graph = owner.create(body) + agent_api.validate_arguments('project_files.graph_create', dict(BOUND, **body)) + agent_api.validate_arguments('project_files.graph_save', dict(BOUND, + graph_id=graph['id'], revision=graph['revision'], document=graph)) + with self.assertRaises(agent_api.AgentAPIError): + agent_api.validate_arguments('graphs.create', body, version=1) + self.assertEqual(agent_api.REGISTRY_DIGEST, agent_api._LEGACY_DIGEST) + + def test_history_pages_are_bounded_and_keep_exact_owner_snapshots(self): + selected = projection() + snapshots = [{'id': RECORD, 'revision': revision, 'text': '\U0001f9ea' * 200000} + for revision in range(6, 0, -1)] + selected.data.history.return_value = snapshots + arguments = dict(READ, record_id=RECORD, offset=1, limit=4) + value = dispatch(selected, 'project_files.data_history', arguments) + self.assertEqual(value, {'items': snapshots[1:5], 'total': 6, 'offset': 1, 'limit': 4}) + agent_api.validate_output('project_files.data_history', {'status': 200, 'result': { + 'binding': READ['binding'], 'project_id': PROJECT, 'checkpoint': CHECKPOINT, 'value': value}}) + self.assertLess(len(agent_api.encoded(value, ensure_ascii=False)), agent_api.RESPONSE_LIMIT) + with self.assertRaises(WorkbenchError): + dispatch(selected, 'project_files.data_history', dict(arguments, limit=5)) + + def test_study_summary_pages_membership_without_losing_full_charge_totals(self): + selected = projection() + trial_ids = [format(index, '032x') for index in range(10000)] + original = {'study': {'format': 'study_v1', 'id': RECORD, 'name': 'Scientific intent', + 'revision': 3, 'trial_ids': trial_ids}, + 'reservations': [], 'charged': {'attempt_slots': 10000}, + 'revision_history_complete': True, + 'page': {'offset': 40, 'limit': 20, 'total': 0, 'study_revision': 3, 'next_offset': None}} + selected.history.study.return_value = original + result = dispatch(selected, 'project_files.study', dict(READ, study_id=RECORD, + origin_catalog_id=ORIGIN, offset=40, limit=20, revision=3)) + self.assertNotIn('trial_ids', result['study']) + self.assertEqual(result['study']['source_format'], 'study_v1') + self.assertEqual(result['study']['trial_count'], 10000) + self.assertEqual(result['trials'], {'items': [{'id': identity} for identity in trial_ids[40:60]], + 'total': 10000, 'offset': 40, 'limit': 20}) + self.assertEqual(result['charged'], {'attempt_slots': 10000}) + self.assertEqual(original['study']['trial_ids'], trial_ids) + + def test_material_replay_still_checks_current_reconciliation_authority(self): + selected = projection() + selected.session.access = Authority(deny_owner=True) + selected.session.mutate = Mock(return_value={'ref': {'sha256': SHA}}) + selected.materials = ProjectMaterial(selected.session) + arguments = dict(BOUND, model_id=RECORD, revision=1, reconcile_capture=True) + with self.assertRaises(WorkbenchError) as failure: + dispatch(selected, 'project_files.model_publish', arguments) + self.assertEqual(failure.exception.code, 'forbidden') + selected.session.mutate.assert_not_called() + selected.context.models.publish.assert_not_called() + # Ordinary publication retains its actual edit-agent policy and may + # reconcile an original durable result without invoking the publisher. + result = dispatch(selected, 'project_files.model_publish', dict(arguments, reconcile_capture=False)) + self.assertEqual(result, {'ref': {'sha256': SHA}}) + selected.session.mutate.assert_called_once() + selected.context.models.publish.assert_not_called() + + def test_current_owner_root_choice_and_local_disclosure_use_their_owners(self): + selected = projection() + selected.session.access = Authority(deny_owner=True) + with self.assertRaises(WorkbenchError): + dispatch(selected, 'project_files.source_register', dict(BOUND, root='fixture', manifest_path='owned/manifest.json')) + selected.source_adapter.register.assert_not_called() + selected = projection() + dispatch(selected, 'project_files.expose_holdout', dict(BOUND, run_id=RECORD, origin_catalog_id=ORIGIN, reason='Review held-out diagnostics')) + selected.disclose.assert_called_once_with(RECORD, {'origin_catalog_id': ORIGIN, 'reason': 'Review held-out diagnostics'}, CHECKPOINT, BOUND['request_key']) + selected.history.record_disclosure.assert_not_called() + + def test_explicit_preparation_keeps_runtime_and_sequence_semantics_with_owners(self): + selected = projection() + files = {'owned-input': 'original opaque bytes'} + prepared = {'bundle': b'validated native source'} + selected.data.workbench.sequences.prepare_project_files.return_value = prepared + arguments = dict(BOUND, files=files, groups=['fixture'], rights='Authored') + dispatch(selected, 'project_files.data_import_sequence', arguments) + selected.data.workbench.sequences.prepare_project_files.assert_called_once_with(files) + selected.data.import_bundle.assert_called_once_with('sequence', prepared, + {'files': files, 'groups': ['fixture'], 'rights': 'Authored'}, CHECKPOINT, BOUND['request_key']) + ref = {'kind': 'model', 'catalog_id': PROJECT, 'id': RECORD, 'revision': 1, 'sha256': SHA} + selected.context.pipelines.get.return_value = decode_graph({ + 'schema_version': 1, 'mode': 'real', 'recipe': 'external_recipe', 'recipe_version': 1, + 'id': RECORD, 'revision': 1, 'name': 'External preparation fixture', 'layout': {}, + 'nodes': [ + {'id': 'input', 'type': 'input', 'definition_version': 1, 'parameters': { + 'project_id': ORIGIN, 'recipe_id': 'neutral', 'expected_sha256': SHA, 'inputs': {}}}, + {'id': 'execute', 'type': 'execute', 'definition_version': 1, 'parameters': {}}, + {'id': 'output', 'type': 'output', 'definition_version': 1, 'parameters': {}}], + 'edges': [{'from': 'input', 'output': 'data', 'to': 'execute', 'input': 'data'}, + {'from': 'execute', 'output': 'artifacts', 'to': 'output', 'input': 'artifacts'}]}) + dispatch(selected, 'project_files.recipe_prepare', dict(BOUND, graph_id=RECORD, revision=1, model=ref)) + selected.context.pipelines.get.assert_called_once_with(RECORD) + selected.materials.prepare_recipe.assert_called_once_with(selected.context.pipelines, RECORD, 1, ref, + CHECKPOINT, BOUND['request_key'], builtin=False, reconcile_capture=False, runtime_manifest=selected.runtime_manifest) + + def test_producer_inspection_preserves_origin_and_entry_pagination(self): + selected = projection() + args = dict(READ, family='generation_history', origin_catalog_id=ORIGIN, producer_id=RECORD, + entries_offset=40, entries_limit=20) + agent_api.validate_arguments('project_files.producer', args) + dispatch(selected, 'project_files.producer', args) + selected.producers.get.assert_called_once_with('generation_history', RECORD, + origin_catalog_id=ORIGIN, entries_offset=40, entries_limit=20) + selected.producers.capture.assert_not_called() + + def test_run_summary_keeps_counts_and_exact_document_without_unbounded_collections(self): + selected = projection() + large = [{'state': 'observed', 'message': 'unique observation'}] * 100000 + selected.history.run.return_value = { + 'origin_catalog_id': ORIGIN, 'historical': True, 'execution_available': False, + 'evidence': 'unverified', 'run': {'schema_version': 1, 'id': RECORD, 'state': 'completed', + 'events': large, 'material': {'captured': 'large exact original document'}}, + 'payloads': [], 'disclosures': [], 'document': {'sha256': SHA, 'bytes': 20000000}, + 'page': {'offset': 40, 'limit': 20, 'total': 0, 'next_offset': None, + 'disclosures_total': 0, 'disclosures_next_offset': None}} + result = dispatch(selected, 'project_files.run', dict(READ, origin_catalog_id=ORIGIN, + run_id=RECORD, offset=40, limit=20)) + self.assertEqual(result['run']['state'], 'completed') + self.assertEqual(result['run']['collection_counts']['events'], 100000) + self.assertNotIn('events', result['run']) + self.assertNotIn('material', result['run']) + self.assertEqual(result['document'], {'sha256': SHA, 'bytes': 20000000}) + self.assertLess(len(agent_api.encoded(result)), 4096) + selected.history.run.assert_called_once_with(RECORD, origin_catalog_id=ORIGIN, offset=40, limit=20) + + def test_document_segments_delegate_exact_hash_and_current_origin(self): + selected = projection() + arguments = dict(READ, run_id=RECORD, origin_catalog_id=ORIGIN, expected_sha256=SHA, + offset=262144, limit=262144) + agent_api.validate_arguments('project_files.run_document_segment', arguments) + dispatch(selected, 'project_files.run_document_segment', arguments) + selected.history.run_document_segment.assert_called_once_with(RECORD, origin_catalog_id=ORIGIN, + expected_sha256=SHA, offset=262144, limit=262144) + # Source listing is a current binding-aware owner read, not manifest + # inference in a client or a generic walk of archived source records. + reference = dict(origin_catalog_id=ORIGIN, project_id=RECORD, manifest_sha256=SHA) + dispatch(selected, 'project_files.source_references', dict(READ, **reference, offset=40, limit=20)) + selected.source_adapter.references.assert_called_once_with(reference, offset=40, limit=20) + + def test_artifact_resolution_requires_current_owner_before_the_custody_seam(self): + selected = projection() + selected.session.access = Authority(deny_owner=True) + arguments = dict(BOUND, sha256=SHA, root='fixture', path='observations/unique.bin') + agent_api.validate_arguments('project_files.artifact_resolve', arguments) + with self.assertRaises(WorkbenchError): + dispatch(selected, 'project_files.artifact_resolve', arguments) + selected.resolve_artifact.assert_not_called() + selected.session.access = Authority() + dispatch(selected, 'project_files.artifact_resolve', arguments) + selected.resolve_artifact.assert_called_once_with( + {'sha256': SHA, 'root': 'fixture', 'path': 'observations/unique.bin'}, CHECKPOINT, BOUND['request_key']) + self.assertEqual(agent_api.contract('project_files.artifact_resolve')['authority'], 'owner') + + def test_artifact_inventory_never_opens_or_invents_payload_bytes(self): + selected = projection() + declaration = dict(format='project_artifact_v1', sha256=SHA, bytes=17) + selected.session.snapshot['records'] = [{'family': 'project_artifact', 'id': SHA, 'document': declaration}] + selected.artifacts.inspect.return_value = {'state': 'missing', 'expected_bytes': 17} + value = dispatch(selected, 'project_files.artifacts', READ) + self.assertEqual(value['items'][0]['availability']['state'], 'missing') + self.assertEqual(value['items'][0]['bytes'], 17) + selected.artifacts.read_blob.assert_not_called() + selected.artifacts.publish_bytes.assert_not_called() + with self.assertRaises(WorkbenchError) as failure: + dispatch(selected, 'project_files.artifact_status', dict(READ, sha256='0' * 64)) + self.assertEqual(failure.exception.status, 404) + + +class BooleanQueries(unittest.TestCase): + def test_sdk_and_http_select_canonical_boolean_queries(self): + name = 'project_files.candidate' + selection = (name, 1, agent_api.contract(name)['sha256']) + for value in (False, True): + arguments = dict(READ, candidate_id=RECORD, origin_catalog_id=ORIGIN, verify=value) + method, target, body = agent_api.project_request(name, arguments) + self.assertIn('verify=' + str(value).lower(), target) + self.assertEqual(agent_api.validate_selected(selection, method, target, body), arguments) + with self.assertRaises(agent_api.AgentAPIError): + agent_api.validate_arguments(name, dict(arguments, verify=str(value).lower())) + for invalid in ('True', 'False', '1', 'yes', ''): + target = '/api/workspace/project-files/candidates/' + RECORD + '?binding=' + READ['binding'] + '&checkpoint=' + CHECKPOINT + '&origin_catalog_id=' + ORIGIN + '&verify=' + invalid + with self.assertRaises(agent_api.AgentAPIError): + agent_api.validate_selected(selection, 'GET', target, None) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_project_execution.py b/tests/test_project_execution.py new file mode 100644 index 00000000..bab34f04 --- /dev/null +++ b/tests/test_project_execution.py @@ -0,0 +1,569 @@ +"""Current host authority and real neutral project Run custody. + +Only disposable PostgreSQL catalogs and the existing authored generic fixture +belong here. Actual host execution cases require a lead-coordinated source freeze. +""" +import copy +from contextlib import contextmanager +import hashlib +import json +import os +from pathlib import Path +from types import SimpleNamespace +import tempfile +import threading +import time +import unittest +from unittest.mock import patch +import uuid + +from access import Access, AccessError, ActorContext, _CURRENT, current_context +from metadata import Metadata, connection_info +from project_execution import ProjectExecution, validate_operation_result +from workbench import WorkbenchError + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'), 'Requires disposable PostgreSQL') +class ProjectAccessBridgeTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='tuldok-project-execution-') + self.root = Path(self.temporary.name) + self.addCleanup(self.temporary.cleanup) + self.databases = [] + self.addCleanup(self.cleanup) + self.host = self.metadata('host') + self.host_access = Access(self.host, self.host.path) + session = self.host_access.exchange(self.host_access._local_owner_token(initial=True)) + self.token, self.owner = session['token'], session['identity'] + self.local = self.metadata('projection', projection=True) + self.bridge = Access(self.local, self.local.path, authority=self.host_access) + + def metadata(self, name, projection=False): + kwargs = dict(dataset_id=uuid.uuid4().hex, projection_id=uuid.uuid4().hex) if projection else {} + db = Metadata(self.root/name, **kwargs) + self.databases.append(db) + return db + + def cleanup(self): + import psycopg + from psycopg import sql + for db in reversed(self.databases):db.close() + with psycopg.connect(connection_info(), autocommit=True) as connection: + for db in self.databases: + connection.execute(sql.SQL('DROP SCHEMA IF EXISTS {} CASCADE').format(sql.Identifier(db.schema))) + + def assert_no_credentials(self): + for table in ('access_actors','access_credentials'): + self.assertEqual(self.local.execute('SELECT count(*) FROM '+table).fetchone()[0], 0) + + def test_current_host_identity_is_retagged_without_minting_or_copying(self): + self.assertIsNone(current_context()) + with self.host_access.bind(self.token): + original = current_context() + context = self.bridge.require('execute', 'runs.submit', owner_only=True) + self.assertEqual(context.catalog_id, self.local.dataset_id) + self.assertEqual(context.actor_id, original.actor_id) + self.assertEqual(current_context(), original) + self.assertEqual(self.bridge.identity()['catalog_id'], self.local.dataset_id) + calls = (lambda: self.bridge._local_owner_token(), + lambda: self.bridge.exchange(self.token), + lambda: self.bridge.issue({'label':'No copied identity','scopes':['inspect']}), + lambda: self.bridge.revoke(original.credential_id), + lambda: self.bridge.logout(), lambda: self.bridge._resolve(self.token), + lambda: self.bridge._mint(original.actor_id,'bearer',['inspect'],600)) + for call in calls: + with self.assertRaises(AccessError):call() + with self.assertRaises(AccessError): + with self.bridge.bind(self.token):pass + self.assert_no_credentials() + + def test_wrong_host_forged_logical_credential_and_host_system_refuse(self): + other_db = self.metadata('other-host') + other = Access(other_db, other_db.path) + token = other.exchange(other._local_owner_token())['token'] + with other.bind(token), self.assertRaises(AccessError): + self.bridge.require('inspect','runs.list') + marker = _CURRENT.set(ActorContext(self.local.dataset_id,self.owner['actor_id'],self.owner['credential_id'])) + try: + with self.assertRaises(AccessError):self.bridge.require('inspect','runs.list') + finally:_CURRENT.reset(marker) + with self.host_access._reconcile(), self.assertRaises(AccessError): + self.bridge.require('inspect','runs.list') + with self.bridge._reconcile(): + self.bridge.require('cancel','runs.reconcile') + with self.assertRaises(AccessError):self.bridge.require('execute','runs.submit') + self.assert_no_credentials() + + def test_live_scopes_revocation_and_expiry_are_checked_on_every_use(self): + with self.host_access.bind(self.token): + agent = self.host_access.issue({'label':'Current agent','scopes':['inspect','edit','execute']}) + with self.host_access.bind(agent['token']): + self.bridge.require('edit','record.edit') + self.bridge.require_external_submission() + with self.assertRaises(AccessError):self.bridge.require('execute','runs.submit') + with self.assertRaises(AccessError):self.bridge.grant_execution('a'*64,agent['identity']['actor_id']) + with self.host_access.bind(self.token):self.host_access.revoke(agent['identity']['credential_id']) + with self.assertRaises(AccessError):self.bridge.identity() + with self.host_access.bind(self.token): + expired = self.host_access.issue({'label':'Expires during request','scopes':['inspect']}) + with self.host_access.bind(expired['token']): + self.host.execute('UPDATE access_credentials SET expires_at=0 WHERE id=%s',(expired['identity']['credential_id'],)) + with self.assertRaises(AccessError):self.bridge._context() + self.assert_no_credentials() + + def test_current_grant_and_exact_local_job_survive_revocation_without_new_authority(self): + request = hashlib.sha256(b'authored exact admission').hexdigest();run_id=uuid.uuid4().hex + with self.host_access.bind(self.token): + agent = self.host_access.issue({'label':'Bounded external submitter','scopes':['inspect','execute']}) + grant = self.bridge.grant_execution('b'*64,agent['identity']['actor_id']) + self.assertEqual(grant['catalog_id'], self.local.dataset_id) + with self.host_access.bind(agent['token']), self.local.transaction(): + admission = self.bridge._consume_external_run(grant['id'],'b'*64,run_id,request) + with self.host_access.bind(self.token):self.host_access.revoke(agent['identity']['credential_id']) + with self.bridge._job('runs',run_id,admission,request): + self.bridge.require('edit','runs.update',run_id) + self.bridge.require_job('runs',run_id,admission,request) + for action,target in (('runs.update',uuid.uuid4().hex),('runs.cancel',run_id)): + with self.assertRaises(AccessError):self.bridge.require('edit',action,target) + with self.assertRaises(AccessError):self.bridge.require('execute','runs.submit') + with self.assertRaises(AccessError): + with self.bridge._job('runs',run_id,uuid.uuid4().hex,request):pass + fresh = self.metadata('cold-projection',projection=True) + cold = Access(fresh,fresh.path,authority=self.host_access) + with self.assertRaises(AccessError): + with cold._job('runs',run_id,admission,request):pass + self.assert_no_credentials() + + def test_unbound_threads_get_no_owner_or_job_default(self): + errors=[] + def worker(): + try:self.bridge.require('inspect','runs.list') + except AccessError as error:errors.append(error.code) + with self.host_access.bind(self.token): + thread=threading.Thread(target=worker);thread.start();thread.join(5) + self.assertEqual(errors,['unauthenticated']) + + def test_logical_identity_collision_keeps_bridge_inert_and_execution_unavailable(self): + db=Metadata(self.root/'same-logical-project',dataset_id=self.host.dataset_id,projection_id=uuid.uuid4().hex) + self.databases.append(db) + bridge=Access(db,db.path,authority=self.host_access) + with self.host_access.bind(self.token): + # Ordinary project metadata still uses the current host Access. + self.host_access.require('edit','project_files.data_edit') + with self.assertRaises(AccessError) as failure:bridge.require_external_submission() + self.assertEqual(failure.exception.code,'unavailable') + with self.assertRaises(AccessError):bridge.grant_execution('a'*64,self.owner['actor_id']) + for table in ('access_actors','access_credentials','access_admissions','access_execution_grants'): + self.assertEqual(db.execute('SELECT count(*) FROM '+table).fetchone()[0],0) + + +class ExecutionObservationTests(unittest.TestCase): + def test_run_receipt_binds_exact_retained_identity_and_rejects_live_fields(self): + value=dict(format='project_run_admission_v1',origin_catalog_id='a'*32,run_id='b'*32,request_sha256='c'*64) + record=dict(family='run_history',id='a'*32+'.'+'b'*32,document={'run':dict(dataset_id='a'*32,id='b'*32,request_sha256='c'*64)}) + self.assertTrue(validate_operation_result('runs.submit_external',value,[record])) + for changed in (dict(value,request_sha256='d'*64),dict(value,approval_id='e'*32),dict(value,token='secret')): + with self.assertRaises(WorkbenchError):validate_operation_result('runs.submit_external',changed,[record]) + self.assertFalse(validate_operation_result('other.operation',{})) + + def test_failed_custody_ack_fences_even_when_sql_rolls_back(self): + execution=ProjectExecution.__new__(ProjectExecution) + execution.session=SimpleNamespace(available=True) + owner=SimpleNamespace(_fence_coordinator=lambda:None) + execution._runs=owner + with patch.object(owner,'_fence_coordinator') as fence: + with self.assertRaises(WorkbenchError): + with execution._journal_guard(): + raise WorkbenchError('Filesystem acknowledgement was lost.','outcome_unknown',409) + self.assertFalse(execution.session.available);fence.assert_called_once() + + +class RunHostSourceTests(unittest.TestCase): + def setUp(self): + from runs import Runs + from project_artifacts import ProjectArtifacts + from project_store import ProjectStore + self.tmp=tempfile.TemporaryDirectory(prefix='project-run-host-source-');self.addCleanup(self.tmp.cleanup) + self.root=Path(self.tmp.name);self.host=self.root/'host';self.host.mkdir() + self.source=self.host/'helper.py';self.raw=b'# exact authored host helper\n';self.source.write_bytes(self.raw) + self.sha=hashlib.sha256(self.raw).hexdigest() + self.store=ProjectStore.create(self.root/'project','Host bytes','new-host-source','a'*64) + self.addCleanup(self.store.close) + self.artifacts=ProjectArtifacts(self.store);self.addCleanup(self.artifacts.close) + self.records=[] + self.owner=Runs.__new__(Runs) + self.owner.dataset=SimpleNamespace(access=None,db=None,lock=threading.RLock()) + self.owner.project_execution=SimpleNamespace(session=SimpleNamespace(snapshot={'records':self.records})) + self.run=dict(id='a'*32,dataset_id='b'*32,state='queued',custody_phase='prepared',custody_identity=None, + material=dict(recipe={'authored':'qualified neutral helper'},host_files={'helper.py':self.sha})) + self.file_patch=patch('recipe_contract.__file__',str(self.host/'recipe_contract.py'));self.file_patch.start() + self.addCleanup(self.file_patch.stop) + self.identity_patch=patch('recipe_contract.recipe_host_identity',return_value={'helper.py':self.sha}) + self.identity=self.identity_patch.start();self.addCleanup(self.identity_patch.stop) + + def capture(self):return self.owner._capture_project_host_sources(self.run,self.artifacts) + + def retain(self,edges): + self.records.append(dict(family='run_history',id=self.run['dataset_id']+'.'+self.run['id'], + document=dict(origin_catalog_id=self.run['dataset_id'],run=copy.deepcopy(self.run),payloads=edges))) + + def test_exact_source_is_durable_and_later_updates_use_retained_bytes(self): + edges=self.capture();self.assertEqual(edges[0]['name'],'host/helper.py') + self.assertEqual(self.artifacts.read_blob(edges[0]['blob']),self.raw) + self.retain(edges);self.run.update(state='interrupted',custody_phase='drained') + self.source.write_bytes(b'# newer unqualified helper\n') + self.identity.side_effect=AssertionError('Later observation consulted the current host') + self.assertEqual(self.capture(),edges) + self.assertEqual(self.artifacts.read_blob(edges[0]['blob']),self.raw) + + def test_hash_race_cannot_publish_source_under_admitted_identity(self): + from project_artifacts import ProjectArtifactError + publish=self.artifacts.publish_fd + def changed(fd,declaration,**kwargs): + self.source.write_bytes(b'# source changed after identity measurement\n') + return publish(fd,declaration,**kwargs) + with patch.object(self.artifacts,'publish_fd',side_effect=changed),self.assertRaises(ProjectArtifactError): + self.capture() + self.assertEqual(self.store.snapshot()['records'],[]) + + def test_historical_missing_or_contradictory_host_cannot_be_retrofitted(self): + self.retain([]) + with self.assertRaises(WorkbenchError) as error:self.capture() + self.assertEqual(error.exception.code,'unavailable');self.identity.assert_not_called() + self.records.clear();edges=self.capture();self.retain(edges) + self.records[0]['document']['payloads'][0]['blob']['sha256']='f'*64 + with self.assertRaises(WorkbenchError):self.capture() + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'),'Requires disposable PostgreSQL') +class FileWriterReconciliationTests(unittest.TestCase): + def setUp(self): + from auth_helpers import ExplicitOwnerFixture + from project_custody import ProjectCustody + from project_projection import ProjectProjection + from project_store import ProjectStore + self.tmp=tempfile.TemporaryDirectory(prefix='project-file-reconciliation-');self.root=Path(self.tmp.name) + self.databases=[];self.projections=[];self.addCleanup(self.cleanup) + host_db=self.metadata('host');self.host=SimpleNamespace(db=host_db,access=Access(host_db,host_db.path)) + self.owner=ExplicitOwnerFixture.bootstrap(self.host) + self.store=ProjectStore.create(self.root/'project','Current file custody','file-fixture','a'*64) + control=self.root/'control';control.mkdir() + self.manager=ProjectCustody(control,host_db.dataset_id) + db=self.metadata('projection',self.store.snapshot()['project_id'],uuid.uuid4().hex) + self.journal=self.manager.for_projection(self.store,db.projection_id) + self.projection=ProjectProjection(self.store,db,self.host.access,{},custody=self.journal) + self.projections.append(self.projection);self.execution=self.projection.execution;self.session=self.projection.session + self.enterContext(self.owner.bound(self.host)) + self.key='original-file-writer';self.input_sha='b'*64;self.content_sha='c'*64 + self.enterContext(patch('runs.Runs',side_effect=AssertionError('File recovery constructed Runs'))) + self.enterContext(patch('subprocess.Popen',side_effect=AssertionError('File recovery launched a process'))) + + def metadata(self,name,logical=None,physical=None): + kwargs={} if logical is None else dict(dataset_id=logical,projection_id=physical) + db=Metadata(self.root/name,**kwargs);self.databases.append(db);return db + + def cleanup(self): + import psycopg + from psycopg import sql + for projection in reversed(self.projections): + if projection.sources is not None:projection.sources.close() + projection.artifacts.close() + for db in reversed(self.databases):db.close() + if hasattr(self,'store'):self.store.close() + with psycopg.connect(connection_info(),autocommit=True) as connection: + for schema in {db.schema for db in self.databases}: + connection.execute(sql.SQL('DROP SCHEMA IF EXISTS {} CASCADE').format(sql.Identifier(schema))) + self.tmp.cleanup() + + def start(self,kind): + context=(self.execution.runtime_effect if kind=='runtime' else self.execution.package_effect) + with context(self.key,self.input_sha,self.content_sha) as effect:effect.publication_started() + + def decision(self,name): + from test_project_history import policy + return self.projection.history.create_study(dict(name=name,limits=dict(attempt_slots=1, + admitted_wall_seconds=100,retained_bytes=10000,peak_staging_bytes=10000),policy=policy()), + self.session.snapshot['checkpoint'],uuid.uuid4().hex) + + def test_package_guard_preserves_partial_bytes_until_acknowledged_owner_decision(self): + self.start('package');partial=self.root/'partial-package';partial.write_bytes(b'Preserve exact partial bytes') + with self.execution.package_reconciliation(self.key,self.input_sha,self.content_sha,self.session.snapshot['checkpoint']): + self.assertTrue(self.journal.has_pending) + self.assertTrue(self.journal.has_pending) + with self.execution.package_reconciliation(self.key,self.input_sha,self.content_sha,self.session.snapshot['checkpoint']) as effect: + self.decision('Acknowledged controlled owner decision') + effect.confirmed() + self.assertFalse(self.journal.has_pending);self.assertEqual(partial.read_bytes(),b'Preserve exact partial bytes') + self.assertIsNone(self.execution._package_active);self.assertIsNone(self.execution._runs) + + def test_runtime_prepare_recovers_exact_unknown_canonical_and_reconstructs_before_retry(self): + from project_store import ProjectStoreError + self.start('runtime');before=self.session.snapshot;original=self.store.commit + def lost(*args,**kwargs): + original(*args,**kwargs) + raise ProjectStoreError('Actual canonical selection lost its acknowledgement.','unknown',409) + with patch.object(self.store,'commit',side_effect=lost),self.assertRaises(ProjectStoreError): + self.decision('Canonical science selected before SQL rollback') + self.assertFalse(self.session.available) + observed=self.execution.runtime_pending(self.key) + self.assertEqual(observed,dict(request_key=self.key,input_sha256=self.input_sha,manifest_sha256=self.content_sha)) + recovered=self.execution.prepare_runtime_reconciliation(self.key,self.input_sha,self.content_sha,before['checkpoint']) + self.assertTrue(self.session.available);self.assertNotEqual(recovered['checkpoint'],before['checkpoint']) + self.assertIsNone(self.execution._runtime_active);self.assertTrue(self.journal.has_pending) + self.assertEqual(self.projection.db.execute('SELECT count(*) FROM model_studies').fetchone()[0],1) + with self.execution.runtime_effect(self.key,self.input_sha,self.content_sha) as effect: + effect.publication_started();self.decision('Explicit normal runtime retry acknowledged');effect.confirmed() + self.assertFalse(self.journal.has_pending) + + def test_original_retained_projection_can_restore_but_foreign_physical_owner_cannot(self): + from project_projection import ProjectProjection + self.start('runtime');old=self.projection;identity=old.db.projection_id + old.artifacts.close();old.db.close() + db=Metadata(old.db.path,create=False,dataset_id=self.store.snapshot()['project_id'],projection_id=identity) + self.databases.append(db) + journal=self.manager.for_projection(self.store,identity) + retained=ProjectProjection(self.store,db,self.host.access,{},custody=journal,restore=False) + self.projections.append(retained);self.projection=retained;self.execution=retained.execution;self.session=retained.session + self.assertFalse(self.session.available) + restored=self.execution.prepare_runtime_reconciliation(self.key,self.input_sha,self.content_sha,self.session.snapshot['checkpoint']) + self.assertEqual(restored,self.session.snapshot);self.assertTrue(self.session.available);self.assertTrue(journal.has_pending) + foreign_db=self.metadata('foreign',db.dataset_id,uuid.uuid4().hex) + foreign=ProjectProjection(self.store,foreign_db,self.host.access,{}, + custody=self.manager.for_projection(self.store,foreign_db.projection_id)) + self.projections.append(foreign) + with self.assertRaises(WorkbenchError) as denied: + foreign.execution.prepare_runtime_reconciliation(self.key,self.input_sha,self.content_sha,foreign.session.snapshot['checkpoint']) + self.assertEqual(denied.exception.code,'unavailable');self.assertTrue(journal.has_pending) + + def test_wrong_tuple_active_writer_and_unrelated_checkout_never_clear_or_adopt(self): + self.start('package');checkpoint=self.session.snapshot['checkpoint'] + with patch.object(self.store,'recover_checkpoint',side_effect=AssertionError('Rejected tuple touched selection')): + for content in ('d'*64,): + with self.assertRaises(WorkbenchError): + with self.execution.package_reconciliation(self.key,self.input_sha,content,checkpoint):pass + self.execution._package_active=self.key + try: + with self.assertRaises(WorkbenchError): + with self.execution.package_reconciliation(self.key,self.input_sha,self.content_sha,checkpoint):pass + finally:self.execution._package_active=None + self.store.commit([],checkpoint,'unrelated-checkout','e'*64) + with self.assertRaises(WorkbenchError) as rejected: + with self.execution.package_reconciliation(self.key,self.input_sha,self.content_sha,checkpoint):pass + self.assertEqual(rejected.exception.code,'stale_project');self.assertFalse(self.session.available) + self.assertEqual(self.session.snapshot['checkpoint'],checkpoint);self.assertTrue(self.journal.has_pending) + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'), 'Requires disposable PostgreSQL') +class ProjectRunCustodyTests(unittest.TestCase): + """Real generic external recipe, trusted custodian, no provider or model fit.""" + def setUp(self): + from project_store import ProjectStore + from project_projection import ProjectProjection + from test_project_files_material_http import neutral_source + self.temporary=tempfile.TemporaryDirectory(prefix='tuldok-current-project-run-') + self.root=Path(self.temporary.name);self.addCleanup(self.temporary.cleanup) + self.databases=[];self.projections=[];self.stores=[];self.addCleanup(self.cleanup) + self.source_root=self.root/'source';self.source_root.mkdir() + self.manifest,self.manifest_raw,self.definition_raw,self.source,self.input=neutral_source(self.source_root) + self.host=self.metadata('host');self.access=Access(self.host,self.host.path) + self.token=self.access.exchange(self.access._local_owner_token())['token'] + self.enterContext(self.access.bind(self.token)) + self.store=ProjectStore.create(self.root/'project','Neutral current custody','new-custody','a'*64) + self.stores.append(self.store) + self.db=self.metadata('projection',self.store.snapshot()['project_id']) + self.projection=ProjectProjection(self.store,self.db,self.access,{'fixture':self.source_root}) + self.projections.append(self.projection);self.execution=self.projection.execution + self.session=self.projection.session + registration=self.projection.source_adapter.register(dict(root='fixture',manifest_path='project.json'),self.checkpoint(),'source-register') + self.assertEqual(registration['origin_catalog_id'],self.db.dataset_id) + body=dict(name='Neutral custody',project_id=self.manifest['id'],recipe_id='neutral', + expected_sha256=hashlib.sha256(self.definition_raw).hexdigest(),inputs={ + 'sample':dict(reference_id='sample',expected_sha256=hashlib.sha256(self.input).hexdigest())}) + self.graph=self.projection.materials.mutate_definition('pipeline_graph','graphs.create_external',None,body, + self.checkpoint(),'graph-create',lambda:self.projection.pipelines.create_external(body)) + self.preflight=self.execution.preflight_external(self.graph['id'],self.graph['revision']) + + def metadata(self,name,catalog_id=None): + args=dict(dataset_id=catalog_id,projection_id=uuid.uuid4().hex) if catalog_id is not None else {} + db=Metadata(self.root/(name+'-'+uuid.uuid4().hex),**args);self.databases.append(db) + return db + + def cleanup(self): + import psycopg + from psycopg import sql + for projection in reversed(self.projections): + execution=getattr(projection,'execution',None) + if execution is not None and execution._runs is not None: + with execution.runtime_access._reconcile():execution._runs.close() + # This explicit fixture teardown follows actual worker drain even + # when a fault intentionally keeps the public lifecycle fenced. + if projection.sources is not None:projection.sources.close() + projection.artifacts.close() + for db in reversed(self.databases):db.close() + for store in reversed(self.stores):store.close() + with psycopg.connect(connection_info(),autocommit=True) as connection: + for db in self.databases: + connection.execute(sql.SQL('DROP SCHEMA IF EXISTS {} CASCADE').format(sql.Identifier(db.schema))) + + def checkpoint(self):return self.session.snapshot['checkpoint'] + def body(self):return dict(graph_id=self.graph['id'],graph_revision=self.graph['revision'],expected_digest=self.preflight['material_sha256']) + def record(self,identity): + return next(item['document'] for item in self.store.snapshot()['records'] + if item['family']=='run_history' and item['document']['run']['id']==identity) + def await_drained(self,identity,timeout=30): + deadline=time.monotonic()+timeout + while time.monotonic()>'state'='completed'"): + document=self.record(row['id']) + self.assertEqual(document['run']['state'],'completed') + for edge in document['payloads']: + self.assertEqual(self.projection.artifacts.status(dict(format='project_artifact_v1',**edge['blob']))['state'],'verified') + observed.append('canonical-terminal-before-sql-outcome') + original_outcome(kind) + with patch.object(recipe_process.Handle,'go',go),patch.object(self.db,'_project_outcome',outcome): + admitted=self.execution.submit_external(self.body(),self.checkpoint(),'real-neutral-submit') + completed=self.await_drained(admitted['run_id']) + self.assertEqual(completed['state'],'completed',completed.get('error')) + self.assertEqual(observed,['canonical-go-intent','canonical-terminal-before-sql-outcome']) + self.assertEqual(self.execution.submit_external(self.body(),'0'*64,'real-neutral-submit'),admitted) + retained=self.record(admitted['run_id']) + self.assertEqual(retained['run']['state'],'completed') + self.assertEqual(retained['run']['request_sha256'],admitted['request_sha256']) + edges={item['name']:item for item in retained['payloads']} + self.assertEqual(self.projection.artifacts.read_blob(edges['input/sample']['blob']),self.input) + self.assertEqual(self.projection.artifacts.read_blob(edges['source/recipe.py']['blob']),self.source) + output=self.projection.artifacts.read_blob(edges['result.txt']['blob']) + self.assertIn(self.input,output) + receipt=recipe_process.validate_receipt(self.projection.artifacts.read_blob(edges['custody-receipt.json']['blob']),completed['custody_identity']) + self.assertTrue(receipt['drained']);self.assertEqual(receipt['outcome'],'completed') + self.execution.reconcile_current() + self.execution.assert_quiescent() + # Fresh projection consumes only portable facts, never source SQL + # admissions/grants, saved PID handles, queues or operation replay. + cold_db=self.metadata('cold-projection',self.db.dataset_id) + with patch('runs.Runs',side_effect=AssertionError('Cold Open constructed Runs')), \ + patch('subprocess.Popen',side_effect=AssertionError('Cold Open launched a process')): + cold=ProjectProjection(self.store,cold_db,self.access,{}) + self.projections.append(cold) + for table in ('workspace_runs','access_admissions','access_execution_grants','access_execution_consumptions'): + self.assertEqual(cold_db.execute('SELECT count(*) FROM '+table).fetchone()[0],0) + self.assertIsNone(cold.execution._runs) + self.assertEqual(cold.execution.health()['active_count'],0) + with self.assertRaises(WorkbenchError):cold.execution.submit_external(self.body(),cold.session.snapshot['checkpoint'],'real-neutral-submit') + self.assertIsNone(cold.execution._runs) + + def test_current_approval_receipt_cannot_reissue_after_cold_projection(self): + from project_projection import ProjectProjection + actor=self.access.identity()['actor_id'];body=dict(self.body(),consumer_actor_id=actor) + current=self.execution.approve_external(body,self.checkpoint(),'local-only-approval') + self.assertTrue(current['current_local']) + self.assertEqual(current,self.execution.approve_external(body,'0'*64,'local-only-approval')) + canonical=next(item['document']['result'] for item in self.store.snapshot()['records'] + if item['family']=='project_operation' and item['document']['request_key']=='local-only-approval') + self.assertEqual(canonical['format'],'project_execution_approval_v1') + self.assertNotIn('grant',canonical);self.assertNotIn('approver_credential_id',canonical) + cold_db=self.metadata('cold-approval',self.db.dataset_id) + cold=ProjectProjection(self.store,cold_db,self.access,{}) + self.projections.append(cold) + with patch('runs.Runs',side_effect=AssertionError('Historical approval constructed Runs')): + with self.assertRaises(WorkbenchError):cold.execution.approve_external(body,cold.session.snapshot['checkpoint'],'local-only-approval') + self.assertEqual(cold_db.execute('SELECT count(*) FROM access_execution_grants').fetchone()[0],0) + + def test_lost_go_commit_ack_never_sends_go_and_requires_exact_current_reconcile(self): + import recipe_process + import psycopg + original_transaction=psycopg.Connection.transaction;injected=[];original_go=recipe_process.Handle.go + go_calls=[] + @contextmanager + def transaction(connection,*args,**kwargs): + with original_transaction(connection,*args,**kwargs):yield + if connection is self.db.connection and not injected: + rows=connection.execute("SELECT document FROM workspace_runs WHERE document::jsonb->>'custody_phase'='go_intent'").fetchall() + if rows: + injected.append('lost-after-actual-go-intent-commit') + raise OSError('Injected lost SQL acknowledgement after canonical intent') + def go(handle,expected): + go_calls.append(True) + return original_go(handle,expected) + with patch.object(psycopg.Connection,'transaction',transaction),patch.object(recipe_process.Handle,'go',go): + admitted=self.execution.submit_external(self.body(),self.checkpoint(),'go-ack-fault') + deadline=time.monotonic()+30 + while time.monotonic()= 400) + envelope = response.structuredContent + self.assertEqual(envelope['status'], expected_status, envelope) + self.assertEqual(json.loads(response.content[0].text), envelope) + return envelope['result'] + + current = await tool('project_files.status', {}) + selected = {'binding': current['binding'], 'checkpoint': current['project']['checkpoint']} + found = await tool('project_files.query', dict(selected, limit=1)) + self.assertEqual([item['id'] for item in found['items']], [record['id']]) + actual = await tool('project_files.get', dict(selected, record_id=record['id'], history_limit=4)) + self.assertEqual(actual['record'], expected['record']) + self.assertEqual(actual['history'], expected['history']) + mcp_annotation = dict(annotation, revision=actual['record']['revision'], + annotation={'note': 'Actual MCP retained note café 🧪'}) + saved = await tool('project_files.annotate', dict(self.envelope(current), + record_id=record['id'], annotation=mcp_annotation)) + self.assertEqual(saved['record']['annotation'], mcp_annotation['annotation']) + after_edit = await tool('project_files.get', dict(binding=saved['binding'], + checkpoint=saved['checkpoint'], record_id=record['id'], history_limit=4)) + self.assertEqual(len(after_edit['history']), 3) + self.assertTrue(any(item['annotation'] == annotation['annotation'] + for item in after_edit['history'])) + before_revocation = canonical_bytes(self.external / 'adapter') + with self.owner.bound(self.workspace): + self.workspace.access.revoke(credential['identity']['credential_id']) + refusal = await tool('project_files.status', {}, 401) + self.assertEqual(refusal['code'], 'unauthenticated') + self.assertEqual(agent.call('project_files.status', {})['status'], 401) + self.assertEqual(canonical_bytes(self.external / 'adapter'), before_revocation) + errors.seek(0) + self.assertNotIn(credential['token'], errors.read()) + + anyio.run(exercise) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_project_files_m2_http.py b/tests/test_project_files_m2_http.py new file mode 100644 index 00000000..1234fc13 --- /dev/null +++ b/tests/test_project_files_m2_http.py @@ -0,0 +1,331 @@ +"""Real M2 public acceptance using disposable PostgreSQL and authored fixtures. + +No training, runtime preflight, provider or user catalog belongs to this suite. +Run only after the lead enables M2 and freezes the complete working source. +""" +import base64 +from datetime import timedelta +import hashlib +import http.client +import importlib.util +import io +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import time +import unittest +from urllib.error import HTTPError +from urllib.request import Request, urlopen +import uuid + +from PIL import Image +import psycopg + +import agent_api +import project_workspace +import test_project_files_http as m1 + +ROOT = Path(__file__).resolve().parents[1] +NOTE = 'M2 retained rationale café 山 — exact authored scientific note.' +RIGHTS = 'Owned synthetic source; corrected by M2 acceptance café 山.' +SOURCE_FILES = ('app.py','agent_api.py','access.py','project_workspace.py','project_dispatch.py', + 'project_projection.py','project_data.py','project_sources.py','project_material.py', + 'project_history.py','project_producers.py','project_session.py','project_store.py', + 'static/project-files.js','static/project-files.html','tuldok_client.py','project_cli.py','mcp_stdio.py') + + +def subject_hashes(): + return {name:hashlib.sha256((ROOT/name).read_bytes()).hexdigest() for name in SOURCE_FILES} + + +def authored_source(root): + from qualify_scientific_fields import neutral_bundle + raw=neutral_bundle();(root/'observed-fields.zip').write_bytes(raw) + manifest={'schema_version':1,'id':uuid.uuid4().hex,'name':'Authored observed fields café 山', + 'data':[{'id':'fields','label':'Exact authored scientific array','path':'observed-fields.zip','sha256':hashlib.sha256(raw).hexdigest()}], + 'recipes':[],'evidence':[],'requirements':[]} + (root/'source-project.json').write_text(json.dumps(manifest,indent=3,ensure_ascii=False)+'\n') + return manifest,raw + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'), 'Requires disposable real local PostgreSQL') +class ProjectFilesM2HTTP(unittest.TestCase): + cleanup_owned=m1.ProjectFilesHTTP.cleanup_owned + select_host=m1.ProjectFilesHTTP.select_host + request=m1.ProjectFilesHTTP.request + state=m1.ProjectFilesHTTP.state + envelope=m1.ProjectFilesHTTP.envelope + action=m1.ProjectFilesHTTP.action + create=m1.ProjectFilesHTTP.create + + def setUp(self): + self.assertTrue(project_workspace.M2_ENABLED,'Lead must enable and freeze M2 before acceptance execution') + self.subject=subject_hashes();self.started=time.monotonic();self.operations=[] + m1.ProjectFilesHTTP.setUp(self) + self.addCleanup(self.receipt) + + def receipt(self): + directory=Path(os.environ.get('TULDOK_QA_OUTPUT_ROOT','/workspace/scratch/tuldok-save-load-qa/artifacts'))/'m2-http' + directory.mkdir(parents=True,exist_ok=True) + current=subject_hashes() + (directory/(self._testMethodName+'-'+uuid.uuid4().hex+'.json')).write_text(json.dumps({ + 'test':self.id(),'seconds':round(time.monotonic()-self.started,3),'operations':self.operations, + 'source_sha256':self.subject,'end_sha256':current,'source_stable':current==self.subject, + 'fixture':'Disposable authored synthetic inputs; no runtime execution or scientific validity claim.'},indent=2)+'\n') + self.assertEqual(current,self.subject,'Product source changed during actual acceptance') + + def arguments(self, action, **fields): + op=agent_api.operation('project_files.'+action) + state=self.state() + bound=(self.envelope(state) if op['side_effects'] else + {'binding':state['binding'],'checkpoint':state['project']['checkpoint']}) + return dict(bound,**fields) + + def transport(self, action, arguments, *, token=None): + name='project_files.'+action + method,target,body=agent_api.project_request(name,arguments) + headers={'Authorization':'Bearer '+(token or self.owner.token),'Content-Type':'application/json', + 'Origin':self.origin,'Tuldok-Operation-Contract':agent_api.selected_header(name)} + request=Request(self.origin+target,data=None if body is None else json.dumps(body,ensure_ascii=False).encode(), + headers=headers,method=method) + try:response=urlopen(request,timeout=30) + except HTTPError as error:response=error + with response:status,value=response.status,json.load(response) + self.operations.append({'operation':name,'status':status}) + return status,value + + def call(self, action, *, token=None, **fields): + status,envelope=self.transport(action,self.arguments(action,**fields),token=token) + self.assertEqual(status,200,envelope) + self.assertEqual(envelope['binding'],self.state()['binding']) + return envelope['value'] + + def data(self, record_id):return self.call('data_get',record_id=record_id) + + def image(self): + out=io.BytesIO();Image.new('RGB',(20,12),'#3355aa').save(out,'PNG') + raw=out.getvalue() + row=self.call('data_import_image',image=base64.b64encode(raw).decode(),name='Authored blue image café 山', + groups=['m2-image'],rights='Owned synthetic pixels') + return row,raw + + def retain_authored_history(self): + # Import is M5. Author this explicitly synthetic archived observation + # through the real closed codec; public readers/disclosure stay real. + from test_project_history import legacy_run + from project_history import capture_run,declarations + projection=self.workspace._projection + run=legacy_run(projection.project_id) + run['source_records']=[{'id':'frozen-example','kind':'text','split':'test', + 'text':'PRIVATE-HOLDOUT-M2-SENTINEL','target':'PRIVATE-TARGET-M2-SENTINEL'}] + run['evidence_context']={'fixture':'PRIVATE-CONTEXT-M2-SENTINEL'} + record=capture_run(run,payloads=[]) + before=projection.session.snapshot + records=before['records']+[record,*declarations(record)] + projection.validate_records(records) + snapshot=projection.store.commit(records,before['checkpoint'],uuid.uuid4().hex,'e'*64) + projection.session.adopt(snapshot) + return run,record + + def config(self,name,token): + secret=self.path/(name+'.token');secret.write_text(token+'\n');secret.chmod(0o600) + config=self.path/(name+'.json');config.write_text(json.dumps({'endpoint':self.origin, + 'catalog_id':self.owner.catalog_id,'token_file':str(secret),'timeout':30}));config.chmod(0o600) + return config + + def test_public_mixed_authoring_git_clone_and_cold_reconstruction(self): + self.create();image,raw=self.image() + image=self.call('data_annotate',record_id=image['id'],revision=image['revision'],source_revision=image['source_revision'], + task='image_caption',annotation={'caption':NOTE},groups=['m2-image','m2-review'],review='human_reviewed') + corrected=self.call('data_rights',record_id=image['id'],revision=image['revision'],source_revision=image['source_revision'],note=RIGHTS) + image=corrected['record'];self.assertEqual(image['provenance']['rights_note_correction']['note'],RIGHTS) + history=self.call('data_history',record_id=image['id'],limit=4) + self.assertTrue(any(row['annotation']=={'caption':NOTE} for row in history['items'])) + selection=self.call('selection_create',document={'name':'Frozen image revision 山', + 'items':[{key:image[key] for key in ('id','revision','source_revision')}]}) + search=self.call('search_create',document={'name':'Entered exact source search 山', + 'criteria':{'q':'café','kind':'image','review':'','sort':'newest','task':'','label':'','group':'','rights':''}}) + search=self.call('search_rename',search_id=search['id'],revision=1,name='Renamed saved search 山') + definitions=self.call('definitions') + template=next(item['model_template'] for item in definitions['recipes'] if item.get('model_template')) + model=self.call('model_create',**dict(template,name='Retained safe model draft 山')) + model=self.call('model_save',model_id=model['id'],revision=model['revision'],document=dict(model,name='Revised model intent 山')) + material=self.call('model_publish',model_id=model['id'],revision=model['revision']) + self.assertEqual(self.call('model_material',ref=material['ref'],verify=True),material) + manifest,science=authored_source(self.external) + registered=self.call('source_register',root='fixture',manifest_path='source-project.json') + snapshotref={key:registered[key] for key in ('origin_catalog_id','project_id','manifest_sha256')} + references=self.call('source_references',**snapshotref,limit=1) + self.assertEqual(references['items'][0]['sha256'],hashlib.sha256(science).hexdigest()) + field=self.call('data_import_scientific',snapshot=snapshotref,reference_id='fields',expected_sha256=hashlib.sha256(science).hexdigest(), + document={'name':'Observed field sequence café 山','groups':['m2-science'],'rights':'Owned authored arrays'}) + self.assertEqual(field['kind'],'field_sequence') + field=self.call('data_annotate',record_id=field['id'],revision=field['revision'],source_revision=field['source_revision'], + task=field['task'],annotation={'note':NOTE},groups=field['groups'],review='human_reviewed') + policy={'format':'selection_policy_v1','metric_definition_sha256':'b'*64,'checkpoint_rule':'explicit', + 'replicate_reduce':'individual','required_capabilities':[],'numerical_acceptance':'diagnostic','tie_break':'trial_id'} + study=self.call('study_create',name='Scientific intent only 山',limits={'attempt_slots':3,'admitted_wall_seconds':100, + 'retained_bytes':1000000,'peak_staging_bytes':1000000},policy=policy) + study=self.call('study_save',study_id=study['id'],revision=study['revision'],name='Revised scientific intent 山',limits=study['limits'],policy=study['policy']) + origin=self.state()['project']['project_id'] + study_view=self.call('study',study_id=study['id'],origin_catalog_id=origin,limit=1) + self.assertEqual(study_view['study']['revision'],2) + self.assertTrue(all(value==0 for value in study_view['charged'].values())) + run,archived=self.retain_authored_history() + visible=self.call('run',run_id=run['id'],origin_catalog_id=origin,limit=1) + self.assertNotIn('PRIVATE-',json.dumps(visible)) + descriptor=visible['document'] + document_arguments=self.arguments('run_document_segment',run_id=run['id'],origin_catalog_id=origin, + expected_sha256=descriptor['sha256'],offset=0,limit=262144) + self.assertEqual(self.transport('run_document_segment',document_arguments)[0],403) + with self.owner.bound(self.workspace): + inspector=self.workspace.access.issue({'label':'M2 inspect only','scopes':['inspect']}) + before_denial=m1.canonical_bytes(self.external/'alpha') + self.assertEqual(self.transport('expose_holdout',self.arguments('expose_holdout',run_id=run['id'],origin_catalog_id=origin, + reason='No scope should deny this request'),token=inspector['token'])[0],403) + self.assertEqual(m1.canonical_bytes(self.external/'alpha'),before_denial) + self.call('expose_holdout',run_id=run['id'],origin_catalog_id=origin,reason='Inspect this authored synthetic holdout on this installation.') + segment=self.call('run_document_segment',run_id=run['id'],origin_catalog_id=origin, + expected_sha256=descriptor['sha256'],offset=0,limit=262144) + exact_document=base64.b64decode(segment['base64']) + self.assertIn(b'PRIVATE-HOLDOUT-M2-SENTINEL',exact_document) + self.assertEqual(hashlib.sha256(exact_document).hexdigest(),descriptor['sha256']) + # Missing retained material is a real filesystem negative; restore exact + # fixture bytes before Git/cold proof so no missing data is concealed. + blob=material['material']['source_closure'][0]['blob'] + payload=self.external/'alpha'/'artifacts'/'sha256'/blob['sha256'];saved=payload.read_bytes();payload.unlink() + (self.external/'retained-model-source.bin').write_bytes(saved) + try: + self.assertEqual(self.call('artifact_status',sha256=blob['sha256'])['state'],'missing') + status,error=self.transport('model_material',self.arguments('model_material',ref=material['ref'],verify=True)) + self.assertEqual(status,404,error) + self.assertEqual(self.call('model_material',ref=material['ref']),material) + self.call('artifact_resolve',sha256=blob['sha256'],root='fixture',path='retained-model-source.bin') + self.assertEqual(self.call('artifact_status',sha256=blob['sha256'])['state'],'verified') + self.assertEqual(payload.read_bytes(),saved) + finally: + if not payload.exists():payload.write_bytes(saved);payload.chmod(0o400) + expected={'image':self.data(image['id']),'image_history':self.call('data_history',record_id=image['id'],limit=4), + 'field':self.data(field['id']),'selection':self.call('selection_get',selection_id=selection['id']), + 'searches':self.call('searches'),'model':self.call('model_get',model_id=model['id']), + 'material':self.call('model_material',ref=material['ref'],verify=True),'study':study_view} + self.action('save');source_state=self.state();self.action('close') + source=self.external/'alpha';(source/'scripts').mkdir() + (source/'scripts'/'sentinel.py').write_text("from pathlib import Path\nPath(__file__).with_name('EXECUTED').write_text('bad')\n") + before=m1.canonical_bytes(source) + forbidden=(self.owner.token.encode(),hashlib.sha256(self.owner.token.encode()).hexdigest().encode(),os.environ['TULDOK_DATABASE_URL'].encode()) + for secret in forbidden:self.assertFalse(any(secret in raw for raw in before.values()),'Actual current credential or database binding leaked') + git_env=dict(os.environ,GIT_CONFIG_GLOBAL='/dev/null',GIT_CONFIG_NOSYSTEM='1',GIT_TERMINAL_PROMPT='0') + def git(*args):subprocess.run(['git',*args],check=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE,env=git_env,timeout=30) + git('init','-q','--template=',str(source));git('-C',str(source),'add','.') + git('-C',str(source),'-c','user.name=Disposable QA','-c','user.email=qa@example.invalid','-c','commit.gpgsign=false', + '-c','core.hooksPath=/dev/null','commit','-qm','Authored M2 acceptance fixture') + coldroot=self.path/'cold-external';coldroot.mkdir();clone=coldroot/'clone' + git('-c','core.hooksPath=/dev/null','clone','-q','--no-hardlinks','--template=',str(source),str(clone)) + # Binary payloads are deliberately untracked. Transfer that owned + # sidecar explicitly; Git alone proves the authored text inventory. + shutil.copytree(source/'artifacts',clone/'artifacts') + self.assertEqual(m1.canonical_bytes(clone),before) + cold_dsn=os.environ['TULDOK_PROJECT_FILES_COLD_DATABASE_URL'] + source_db=psycopg.conninfo.conninfo_to_dict(os.environ['TULDOK_DATABASE_URL'])['dbname'] + with psycopg.connect(cold_dsn) as connection: + database,allowed=connection.execute('SELECT current_database(),has_database_privilege(current_user,%s,\'CONNECT\')',(source_db,)).fetchone() + self.assertNotEqual(database,source_db);self.assertFalse(allowed) + self.select_host(self.path/'cold-control',{'fixture':coldroot},cold_dsn) + self.action('open',root='fixture',directory='clone');self.assertEqual(self.state()['project'],source_state['project']) + self.assertEqual(self.data(image['id']),expected['image']);self.assertEqual(self.data(field['id']),expected['field']) + self.assertEqual(self.call('data_history',record_id=image['id'],limit=4),expected['image_history']) + self.assertEqual(self.call('selection_get',selection_id=selection['id']),expected['selection']) + self.assertEqual(self.call('searches'),expected['searches']);self.assertEqual(self.call('model_get',model_id=model['id']),expected['model']) + self.assertEqual(self.call('model_material',ref=material['ref'],verify=True),expected['material']) + self.assertEqual(self.call('study',study_id=study['id'],origin_catalog_id=origin,limit=1),expected['study']) + cold_run=self.call('run',run_id=run['id'],origin_catalog_id=origin,limit=1) + self.assertNotIn('PRIVATE-',json.dumps(cold_run)) + self.assertEqual(cold_run['document'],descriptor) + self.assertEqual(self.transport('run_document_segment',self.arguments('run_document_segment',run_id=run['id'],origin_catalog_id=origin, + expected_sha256=descriptor['sha256'],offset=0,limit=262144))[0],403,'Historical disclosure is no current authority') + status,error=self.transport('source_references',self.arguments('source_references',**snapshotref)) + self.assertEqual(status,409,error) # A historical path is no current source grant. + self.assertFalse((clone/'scripts'/'EXECUTED').exists()) + self.action('save');self.assertEqual(m1.canonical_bytes(clone),before) + + def test_unpinned_scientific_reference_uses_current_observed_hash(self): + self.create();manifest,raw=authored_source(self.external) + manifest['data'][0].pop('sha256') + (self.external/'source-project.json').write_text(json.dumps(manifest,ensure_ascii=False)) + registered=self.call('source_register',root='fixture',manifest_path='source-project.json') + reference={key:registered[key] for key in ('origin_catalog_id','project_id','manifest_sha256')} + item=self.call('source_references',**reference)['items'][0] + self.assertNotIn('sha256',item);self.assertEqual(item['integrity'],'unverified') + self.assertEqual(item['observed_sha256'],hashlib.sha256(raw).hexdigest()) + row=self.call('data_import_scientific',snapshot=reference,reference_id=item['id'],expected_sha256=item['observed_sha256'], + document={'name':'Unpinned but explicitly measured source','groups':['m2-unpinned'],'rights':'Owned authored data'}) + self.assertEqual(row['source_sha256'],item['observed_sha256']) + + @unittest.skipUnless(importlib.util.find_spec('mcp'),'Requires pinned real MCP optional dependencies') + def test_real_sdk_cli_mcp_roles_lost_reply_and_stale_binding(self): + import anyio + from mcp import ClientSession,StdioServerParameters + from mcp.client.stdio import stdio_client + from tuldok_client import ProjectFilesClient + owner=agent_api.ProjectFilesOwnerClient(self.origin,self.owner.token,self.owner.catalog_id,timeout=30) + owner.bootstrap();owner_files=ProjectFilesClient(owner) + opened=owner_files.new(root='fixture',directory='adapters',name='M2 actual adapters 山',**self.envelope()) + with self.owner.bound(self.workspace): + credential=self.workspace.access.issue({'label':'M2 transport editor','scopes':['inspect','edit']}) + client=agent_api.AgentClient(self.origin,credential['token'],self.owner.catalog_id,timeout=30);client.bootstrap() + files=ProjectFilesClient(client) + imported=files.data_import_text(**self.arguments('data_import_text',text='M2 SDK authored café 山',name='Adapter source',groups=['m2-adapter'],rights='Owned fixture')) + record=imported['value'] + denied=self.arguments('data_annotate',record_id=record['id'],revision=record['revision'],source_revision=record['source_revision'], + task='text_corpus',annotation={'note':NOTE},groups=record['groups'],review='human_reviewed') + before=m1.canonical_bytes(self.external/'adapters') + self.assertEqual(client.call('project_files.data_annotate',denied)['status'],403) + self.assertEqual(client.call('project_files.study_create',self.arguments('study_create',name='Denied owner study', + limits={'attempt_slots':1,'admitted_wall_seconds':1,'retained_bytes':1,'peak_staging_bytes':1}, + policy={'format':'selection_policy_v1','metric_definition_sha256':'b'*64,'checkpoint_rule':'explicit','replicate_reduce':'individual', + 'required_capabilities':[],'numerical_acceptance':'diagnostic','tie_break':'trial_id'}))['status'],403) + self.assertEqual(m1.canonical_bytes(self.external/'adapters'),before) + config=self.config('actual-agent',credential['token']);args=self.path/'cli-arguments.json' + arguments=dict(denied,review='draft');args.write_text(json.dumps(arguments,ensure_ascii=False)) + environment={key:value for key,value in os.environ.items() if not key.startswith(('TULDOK_','PG'))} + result=subprocess.run([sys.executable,str(ROOT/'project_cli.py'),'--config',str(config),'file-data-annotate','--arguments',str(args)], + cwd=ROOT,env=environment,capture_output=True,text=True,timeout=45) + self.assertEqual(result.returncode,0,result.stderr);self.assertNotIn(credential['token'],result.stdout+result.stderr) + self.assertEqual(json.loads(result.stdout)['value']['annotation'],{'note':NOTE}) + async def exercise(): + parameters=StdioServerParameters(command=sys.executable,args=[str(ROOT/'mcp_stdio.py'),'--config',str(config)],cwd=str(ROOT),env=environment) + with (self.path/'m2-mcp.stderr').open('w+') as errors: + async with stdio_client(parameters,errlog=errors) as (reader,writer): + async with ClientSession(reader,writer,read_timeout_seconds=timedelta(seconds=20)) as session: + await session.initialize() + names={tool.name for tool in (await session.list_tools()).tools} + self.assertTrue({'project_files_data_get','project_files_data_rights','project_files_studies'}<=names) + request=self.arguments('data_get',record_id=record['id']) + observed=await session.call_tool('project_files_data_get',request) + self.assertFalse(observed.isError);self.assertEqual(observed.structuredContent['status'],200) + current=observed.structuredContent['result']['value'] + rights=self.arguments('data_rights',record_id=record['id'],revision=current['revision'],source_revision=current['source_revision'],note=RIGHTS) + saved=await session.call_tool('project_files_data_rights',rights) + self.assertFalse(saved.isError);self.assertEqual(saved.structuredContent['result']['value']['record']['provenance']['rights_note_correction']['note'],RIGHTS) + anyio.run(exercise) + # Discard the actual successful response body, then reconcile with the + # exact same request key; no mutation retry is hidden by an adapter. + arguments=self.arguments('data_import_text',text='Lost response exact source 山',name='Lost reply',groups=['m2-lost'],rights='Owned fixture') + method,target,body=agent_api.project_request('project_files.data_import_text',arguments) + connection=http.client.HTTPConnection('127.0.0.1',self.hosts[-1][1].server_port,timeout=30) + connection.request(method,target,json.dumps(body).encode(),{'Authorization':'Bearer '+self.owner.token, + 'Content-Type':'application/json','Tuldok-Operation-Contract':agent_api.selected_header('project_files.data_import_text')}) + response=connection.getresponse();self.assertEqual(response.status,200);connection.close() + committed=m1.canonical_bytes(self.external/'adapters') + status,replayed=self.transport('data_import_text',arguments);self.assertEqual(status,200,replayed) + self.assertEqual(m1.canonical_bytes(self.external/'adapters'),committed) + old=self.state();self.action('close');self.action('open',root='fixture',directory='adapters') + status,error=self.transport('data_get',{'binding':old['binding'],'checkpoint':old['project']['checkpoint'],'record_id':record['id']}) + self.assertEqual(status,409,error);self.assertEqual(m1.canonical_bytes(self.external/'adapters'),committed) + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_project_files_m4_clients.py b/tests/test_project_files_m4_clients.py new file mode 100644 index 00000000..adf2cc56 --- /dev/null +++ b/tests/test_project_files_m4_clients.py @@ -0,0 +1,227 @@ +"""M4 fixed projections and client fault boundaries. + +Controlled replies establish transport refusal and exact byte custody, not disk +publication or execution. The loopback case exercises the real HTTP JSON reader. +""" +import copy +import hashlib +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +from pathlib import Path +import re +import threading +from types import SimpleNamespace +import unittest +from unittest.mock import Mock + +import agent_api as api +import project_cli +import project_dispatch +from tuldok_client import ProjectFilesClient + +CATALOG, BINDING, PROJECT, ACTOR, FORK = (ch*32 for ch in 'abcdf') +CP, NEXT = 'e'*64, 'f'*64 +KEY = 'm4-reviewed-request-0001' +STAMP = '2026-10-22T12:00:00+00:00' +ROOT = Path(__file__).resolve().parents[1] +READ = dict(binding=BINDING, checkpoint=CP) +WRITE = dict(binding=BINDING, expected_checkpoint=CP, request_key=KEY) +ACTIONS = ['new','open','save','close','query','get','import_text','annotate'] + + +def identity(kind='script', path='scripts/inert.py', project=PROJECT): + return hashlib.sha256((project+'\0'+kind+'\0'+path).encode()).hexdigest() + + +def version(text, revision=1): + raw = text.encode('utf-8') + return dict(revision=revision, sha256=hashlib.sha256(raw).hexdigest(), bytes=len(raw), + text=text, adopted_at=STAMP, actor_id=ACTOR) + + +def selected(text='print("inert")\n', revision=1): + return dict(id=identity(), project_id=PROJECT, kind='script', path='scripts/inert.py', + version=version(text, revision)) + + +def bound(value, checkpoint=CP): + return dict(binding=BINDING, project_id=PROJECT, checkpoint=checkpoint, value=value) + + +def ready(reply, *, owner=False): + client = (api.ProjectFilesOwnerClient if owner else api.AgentClient)( + 'http://127.0.0.1:12345', 'fixture-only-m4-transport-token', CATALOG) + client._ready = True + client._selected = {name: 1 for name in api.PROJECT_FILES_M4} + client._request = Mock(return_value=copy.deepcopy(reply)) + return client + + +class M4Clients(unittest.TestCase): + def test_capability_profiles_fit_unchanged_budget_and_negotiate_only_intersections(self): + native = api.capabilities(CATALOG) + project = api.capabilities(CATALOG, profile='project') + self.assertLessEqual(len(api.encoded(native)), 1048576) + self.assertLessEqual(len(api.encoded(project)), 1048576) + self.assertTrue(all(not op['id'].startswith('project_files.') for op in native['operations'])) + self.assertTrue(all(op['id'].startswith('project_files.') or op['id'] in api.PROJECT_TRANSFER_OPERATIONS or op['id'] in ('context.get','identity.get') for op in project['operations'])) + self.assertTrue(api.PROJECT_FILES_M4 <= set(api.negotiate(project, CATALOG))) + self.assertFalse(api.PROJECT_FILES_M4 & set(api.negotiate(native, CATALOG))) + with self.assertRaises(api.AgentAPIError): api.capabilities(CATALOG, profile='arbitrary') + self.assertEqual(api.REGISTRY_DIGEST, api._LEGACY_DIGEST) + + def test_ten_operations_have_fixed_consumers_and_closed_inputs(self): + self.assertEqual(len(api.PROJECT_FILES_M4), 10) + self.assertEqual(project_dispatch.SUPPORTED_OPERATIONS & api.PROJECT_FILES_M4, + api.PROJECT_FILES_M4 - {'project_files.save_as'}) + client = ProjectFilesClient(Mock()) + parser = project_cli.parser() + for operation in api.PROJECT_FILES_M4: + action = operation.split('.')[1] + self.assertTrue(callable(getattr(client, action))) + if action == 'save_as': + args = ['--binding',BINDING,'--expected-checkpoint',CP,'--request-key',KEY, + '--root','fixture','--directory','independent','--name','Independent'] + else: args = ['--arguments','/not-read'] + self.assertEqual(parser.parse_args(['--config','/not-read','file-'+action.replace('_','-'),*args]).command, + 'file-'+action.replace('_','-')) + with self.assertRaises(api.AgentAPIError): + api.validate_arguments('project_files.file_adopt', dict(WRITE, kind='script',path='scripts/inert.py', + expected_revision=0,expected_sha256=CP,execute=True)) + self.assertEqual(api.contract('project_files.save_as')['authority'],'owner') + self.assertEqual(api.contract('project_files.file_adopt')['authority'],'agent') + source=(ROOT/'static/project-files.js').read_text() + pins=json.loads(re.search(r'PROJECT_FILES_CONTRACTS_START \*/\s*(.*?)\s*/\* PROJECT_FILES_CONTRACTS_END',source,re.S)[1]) + for name in api.PROJECT_FILES_M4: self.assertEqual(pins[name],api.contract(name)['sha256']) + + def test_selected_file_checks_exact_utf8_revision_identity_and_empty_text(self): + arguments=dict(READ,file_id=identity(),revision=2) + for text in ('', '\0 exact\r\nΩ 🌸', '🌸'*262144): + value=bound(selected(text,2));raw=ready(dict(status=200,result=value)) + self.assertEqual(ProjectFilesClient(raw).file_get(**arguments),value) + value=bound(selected('exact',2));raw=ready(dict(status=200,result=value)) + for change in ({'text':'different'},{'bytes':4},{'sha256':CP},{'revision':3}): + corrupt=copy.deepcopy(value);corrupt['value']['version'].update(change) + raw._request.return_value=dict(status=200,result=corrupt) + with self.assertRaises(api.AgentAPIError): ProjectFilesClient(raw).file_get(**arguments) + for change in ({'id':CP},{'project_id':ACTOR},{'path':'scripts/another.py'}): + corrupt=copy.deepcopy(value);corrupt['value'].update(change) + raw._request.return_value=dict(status=200,result=corrupt) + with self.assertRaises(api.AgentAPIError): ProjectFilesClient(raw).file_get(**arguments) + + def test_nul_exception_is_only_exact_successful_file_text_leaf_over_real_http(self): + body=bound(selected('before\0after')) + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + raw=api.encoded(body);self.send_response(200);self.send_header('Content-Type','application/json') + self.send_header('Content-Length',str(len(raw)));self.end_headers();self.wfile.write(raw) + def log_message(self,*_): pass + server=ThreadingHTTPServer(('127.0.0.1',0),Handler) + thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() + self.addCleanup(server.server_close);self.addCleanup(thread.join,5);self.addCleanup(server.shutdown) + client=api.AgentClient(f'http://127.0.0.1:{server.server_port}','fixture-only-m4-token',CATALOG) + client._ready=True;client._selected={'project_files.file_get':1} + self.assertEqual(ProjectFilesClient(client).file_get(**READ,file_id=identity()),body) + with self.assertRaises(api.AgentAPIError): api.decode(api.encoded(body)) + for field in ('path','kind'): + invalid=copy.deepcopy(body);invalid['value'][field]+='\0' + with self.assertRaises(api.AgentAPIError): + api.validate_output('project_files.file_get',dict(status=200,result=invalid)) + with self.assertRaises(api.AgentAPIError): + api.validate_output('project_files.file_get',dict(status=503,result={'error':'bad\0','code':'unavailable'})) + with self.assertRaises(api.AgentAPIError): + api.validate_arguments('project_files.file_adopt',dict(WRITE,kind='script',path='scripts/a\0',expected_revision=0,expected_sha256=CP)) + invalid=copy.deepcopy(body) + invalid['value']['version']['text']='bad\ud800' + with self.assertRaises(api.AgentAPIError): + api.validate_output('project_files.file_get',dict(status=200,result=invalid)) + + def test_adoption_history_and_nested_mapping_refuse_wrong_selection(self): + meta=version('exact');meta.pop('text') + adopted=dict(id=identity(),project_id=PROJECT,kind='script',path='scripts/inert.py', + **{k:v for k,v in meta.items() if k!='actor_id'},changed=True) + arguments=dict(WRITE,kind='script',path='scripts/inert.py',expected_revision=0,expected_sha256=meta['sha256']) + raw=ready(dict(status=200,result=bound(adopted,NEXT))) + self.assertEqual(ProjectFilesClient(raw).file_adopt(**arguments)['value'],adopted) + for change in ({'sha256':CP},{'revision':2},{'changed':False}): + raw._request.return_value=dict(status=200,result=bound(dict(adopted,**change),NEXT)) + with self.assertRaises(api.AgentAPIError):ProjectFilesClient(raw).file_adopt(**arguments) + history=dict(id=identity(),items=[meta],offset=0,limit=40,total=1) + raw._request.return_value=dict(status=200,result=bound(history)) + self.assertEqual(ProjectFilesClient(raw).file_history(**READ,file_id=identity())['value'],history) + raw._request.return_value=dict(status=200,result=bound(dict(history,items=[dict(meta,text='forbidden')])) ) + with self.assertRaises(api.AgentAPIError):ProjectFilesClient(raw).file_history(**READ,file_id=identity()) + mapping=dict(source=None,project_id=PROJECT,mapping=dict(items=[],offset=0,limit=40,total=0), + archived_count=0,copied_count=0,missing_regenerable_count=0) + raw._request.return_value=dict(status=200,result=bound(mapping)) + ProjectFilesClient(raw).fork_info(**READ) + for change in ({'offset':1},{'limit':1},{'total':1}): + corrupt=copy.deepcopy(mapping);corrupt['mapping'].update(change) + raw._request.return_value=dict(status=200,result=bound(corrupt)) + with self.assertRaises(api.AgentAPIError):ProjectFilesClient(raw).fork_info(**READ) + + def test_save_as_correlates_new_state_and_never_retries_failed_ack(self): + arguments=dict(WRITE,root='fixture',directory='independent',name='Independent Ω') + state=dict(format='project_files_state_v1',binding=ACTOR,state='open',roots=['fixture'], + project=dict(project_id=FORK,name=arguments['name'],checkpoint=NEXT),available_actions=ACTIONS) + fork=dict(source_project_id=PROJECT,source_checkpoint=CP,project_id=FORK,checkpoint=NEXT, + root='fixture',directory='independent',request_key=KEY,reconciled=False, + mapping_count=2,copied_count=3,missing_regenerable_count=1) + result=dict(state=state,fork=fork);raw=ready(dict(status=200,result=result),owner=True) + self.assertEqual(ProjectFilesClient(raw).save_as(**arguments),result) + for field,bad in (('directory','elsewhere'),('source_checkpoint',NEXT),('project_id',PROJECT),('request_key','different-request')): + corrupt=copy.deepcopy(result);corrupt['fork'][field]=bad + raw._request.return_value=dict(status=200,result=corrupt) + with self.assertRaises(api.AgentAPIError):ProjectFilesClient(raw).save_as(**arguments) + corrupt=copy.deepcopy(result);corrupt['state']['binding']=BINDING + raw._request.return_value=dict(status=200,result=corrupt) + with self.assertRaises(api.AgentAPIError):ProjectFilesClient(raw).save_as(**arguments) + raw._request.return_value=dict(status=503,result={'error':'Inspect current selection.','code':'outcome_unknown'}) + before=raw._request.call_count + with self.assertRaises(api.AgentAPIError):ProjectFilesClient(raw).save_as(**arguments) + self.assertEqual(raw._request.call_count-before,1) + + def test_file_observations_and_original_intents_refuse_cross_project_metadata(self): + meta=version('exact');observation=dict(id=identity(),kind='script',path='scripts/inert.py',revision=1, + expected_sha256=meta['sha256'],expected_bytes=meta['bytes'],observed_sha256=meta['sha256'], + observed_bytes=meta['bytes'],state='unchanged',reason=None) + page=dict(items=[observation],offset=0,limit=40,total=1) + raw=ready(dict(status=200,result=bound(page))) + ProjectFilesClient(raw).files_inspect(**READ) + for change in ({'id':CP},{'observed_sha256':CP},{'observed_bytes':0}): + raw._request.return_value=dict(status=200,result=bound(dict(page,items=[dict(observation,**change)]))) + with self.assertRaises(api.AgentAPIError):ProjectFilesClient(raw).files_inspect(**READ) + origin=dict(project_id=FORK,checkpoint=NEXT,family='model_definition',id=ACTOR,revision=2,created_at=STAMP) + current=dict(format='project_material_fork_intent_v1',catalog_id=PROJECT,id=ACTOR,revision=1, + name='Retained original',definition_family='model_definition', + definition={'source_selector':{'catalog_id':FORK},'specification':{'width':17}}, + required_reselection=['source_selector']) + detail=dict(family='model_definition',id=PROJECT+'.'+ACTOR,current=current,fork_origin=origin) + raw._request.return_value=dict(status=200,result=bound(detail)) + self.assertEqual(ProjectFilesClient(raw).material_intent(**READ,family='model_definition',definition_id=ACTOR)['value'],detail) + for field in ('catalog_id','id','definition_family'): + corrupt=copy.deepcopy(detail);corrupt['current'][field]='pipeline_graph' if field=='definition_family' else FORK + raw._request.return_value=dict(status=200,result=bound(corrupt)) + with self.assertRaises(api.AgentAPIError): + ProjectFilesClient(raw).material_intent(**READ,family='model_definition',definition_id=ACTOR) + summary=dict(family='model_definition',id=PROJECT+'.'+ACTOR,catalog_id=PROJECT,definition_id=ACTOR, + name=current['name'],revision=1,required_reselection=current['required_reselection'],fork_origin=origin) + raw._request.return_value=dict(status=200,result=bound(dict(items=[summary],offset=0,limit=40,total=1))) + ProjectFilesClient(raw).material_intents(**READ) + raw._request.return_value=dict(status=200,result=bound(dict(items=[dict(summary,catalog_id=FORK)],offset=0,limit=40,total=1))) + with self.assertRaises(api.AgentAPIError):ProjectFilesClient(raw).material_intents(**READ) + + def test_dispatch_projects_metadata_pages_without_captured_text_or_definition_bulk(self): + import threading + access=SimpleNamespace(lock=threading.RLock(),require=Mock()) + session=SimpleNamespace(access=access,lock=threading.RLock(),db=SimpleNamespace(lock=threading.RLock()), + check=Mock(return_value={'project_id':PROJECT,'records':[]})) + files=SimpleNamespace(history=Mock(return_value=[version('large text '+str(i),i+1) for i in range(42)])) + projection=SimpleNamespace(session=session,files=files) + page=project_dispatch.dispatch(projection,'project_files.file_history',dict(READ,file_id=identity(),offset=40,limit=2)) + self.assertEqual((page['total'],[item['revision'] for item in page['items']]),(42,[41,42])) + self.assertTrue(all('text' not in item for item in page['items'])) + api.validate_output('project_files.file_history',dict(status=200,result=bound(page))) + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_project_files_m4_clients_http.py b/tests/test_project_files_m4_clients_http.py new file mode 100644 index 00000000..89e8e26c --- /dev/null +++ b/tests/test_project_files_m4_clients_http.py @@ -0,0 +1,202 @@ +"""Actual M4 SDK, CLI and MCP over an authenticated disposable HTTP server. + +Requires lead-enabled M4 and an explicit frozen product manifest. No provider, +preflight, runtime capture, script execution or ambient user catalog is used. +""" +from datetime import timedelta +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import time +import unittest +import uuid + +import agent_api as api +import project_workspace +from tuldok_client import ProjectFilesClient +import test_project_files_http as m1 + +ROOT=Path(__file__).resolve().parents[1] + + +def frozen_subject(): + name=os.environ.get('TULDOK_M4_FREEZE_MANIFEST') + if not name:raise AssertionError('Lead must provide TULDOK_M4_FREEZE_MANIFEST before actual acceptance') + value=json.loads(Path(name).read_text());files=value['files'] + current={file:hashlib.sha256((ROOT/file).read_bytes()).hexdigest() for file in files} + if current!=files:raise AssertionError('Frozen product changed before actual acceptance') + return dict(manifest_sha256=hashlib.sha256(Path(name).read_bytes()).hexdigest(),files=current) + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'),'Requires disposable PostgreSQL') +class ProjectFilesM4ClientsHTTP(unittest.TestCase): + cleanup_owned=m1.ProjectFilesHTTP.cleanup_owned + select_host=m1.ProjectFilesHTTP.select_host + request=m1.ProjectFilesHTTP.request + state=m1.ProjectFilesHTTP.state + envelope=m1.ProjectFilesHTTP.envelope + + def setUp(self): + self.assertTrue(project_workspace.M4_ENABLED,'Lead must enable M4 for actual public acceptance') + self.subject=frozen_subject();self.performed=[];self.started=time.monotonic() + m1.ProjectFilesHTTP.setUp(self) + self.addCleanup(self.receipt) + + def receipt(self): + current={file:hashlib.sha256((ROOT/file).read_bytes()).hexdigest() for file in self.subject['files']} + report=Path(os.environ.get('TULDOK_QA_OUTPUT_ROOT','/workspace/scratch/tuldok-save-load-qa/artifacts'))/'m4-clients' + report.mkdir(parents=True,exist_ok=True) + (report/(self._testMethodName+'-'+uuid.uuid4().hex+'.json')).write_text(json.dumps(dict( + test=self.id(),seconds=round(time.monotonic()-self.started,3),performed=self.performed, + source=self.subject,end_sha256=current,source_stable=current==self.subject['files'], + test_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + fixture='Disposable local PG; real HTTP/SDK/CLI/MCP transports; inert exact UTF8 files'),indent=2)+'\n') + self.assertEqual(current,self.subject['files'],'Product changed during actual acceptance') + + def config(self,name,token): + token_file=self.path/(name+'.token');config_file=self.path/(name+'.config.json') + token_file.write_text(token+'\n');token_file.chmod(0o600) + config_file.write_text(json.dumps(dict(endpoint=self.origin,catalog_id=self.owner.catalog_id, + token_file=str(token_file),timeout=15)));config_file.chmod(0o600) + return config_file + + @unittest.skipUnless(importlib.util.find_spec('mcp') and importlib.util.find_spec('anyio'), 'Requires optional locked MCP dependencies') + def test_exact_file_revisions_save_as_and_current_role_through_sdk_cli_mcp(self): + import anyio + from mcp import ClientSession,StdioServerParameters + from mcp.client.stdio import stdio_client + owner=ProjectFilesClient(api.ProjectFilesOwnerClient(self.origin,self.owner.token,self.owner.catalog_id,timeout=15)) + initial=owner.status();opened=owner.new(root='fixture',directory='transport',name='Exact file project Ω',**self.envelope(initial)) + root=self.external/'transport';(root/'scripts').mkdir();(root/'notes').mkdir() + sentinel=self.path/'must-not-execute' + script=('from pathlib import Path\nPath('+repr(str(sentinel))+').write_text("executed")\n# inert NUL follows\n\0Ω 🌸\r\n').encode() + (root/'scripts/inert.py').write_bytes(script);(root/'notes/empty.md').write_bytes(b'') + with self.owner.bound(self.workspace): + credential=self.workspace.access.issue({'label':'M4 current editor','scopes':['inspect','edit']}) + agent=api.AgentClient(self.origin,credential['token'],self.owner.catalog_id,timeout=15) + self.assertEqual(agent.bootstrap()['identity']['kind'],'agent');files=ProjectFilesClient(agent) + script_result=files.file_adopt(**self.envelope(),kind='script',path='scripts/inert.py',expected_revision=0, + expected_sha256=hashlib.sha256(script).hexdigest())['value'] + empty_result=files.file_adopt(**self.envelope(),kind='note',path='notes/empty.md',expected_revision=0, + expected_sha256=hashlib.sha256(b'').hexdigest())['value'] + def read(): + state=self.state();return dict(binding=state['binding'],checkpoint=state['project']['checkpoint']) + self.assertEqual(files.file_get(**read(),file_id=script_result['id'])['value']['version']['text'].encode(),script) + self.assertEqual(files.file_get(**read(),file_id=empty_result['id'])['value']['version']['text'],'') + self.performed.append('Actual current-agent SDK adopts and reads exact NUL/Unicode and empty UTF8 files') + agent_config=self.config('agent',credential['token']);owner_config=self.config('owner',self.owner.token) + environment={key:value for key,value in os.environ.items() if not key.startswith(('TULDOK_','PG'))} + environment['PYTHONDONTWRITEBYTECODE']='1' + def cli(config,command,*,owner_mode=False,arguments=None,flags=()): + command=[sys.executable,str(ROOT/'project_cli.py'),'--config',str(config), + *(['--owner-project-files'] if owner_mode else []),command,*flags] + if arguments is not None: + path=self.path/('args-'+uuid.uuid4().hex+'.json');path.write_text(json.dumps(arguments));command+=['--arguments',str(path)] + result=subprocess.run(command,cwd=ROOT,env=environment,capture_output=True,text=True,timeout=30) + self.assertEqual(result.returncode,0,result.stderr) + self.assertNotIn(self.owner.token,result.stdout+result.stderr);self.assertNotIn(credential['token'],result.stdout+result.stderr) + return json.loads(result.stdout) + original=cli(agent_config,'file-file-get',arguments=dict(read(),file_id=script_result['id'],revision=1)) + self.assertEqual(original['value']['version']['text'].encode(),script) + changed=script+b'# second authored observation\n';(root/'scripts/inert.py').write_bytes(changed) + async def exercise(): + params=StdioServerParameters(command=sys.executable,args=[str(ROOT/'mcp_stdio.py'),'--config',str(agent_config)],cwd=str(ROOT),env=environment) + with (self.path/'mcp.stderr').open('w+') as errors: + async with stdio_client(params,errlog=errors) as (reader,writer): + started=time.monotonic() + async with ClientSession(reader,writer,read_timeout_seconds=timedelta(seconds=20)) as session: + await session.initialize();self.performed.append(dict(actual_mcp_initialize_seconds=round(time.monotonic()-started,3),deadline_seconds=20)) + tools={tool.name for tool in (await session.list_tools()).tools} + self.assertTrue({'project_files_file_get','project_files_file_adopt','project_files_file_history'}<=tools) + async def tool(action,args): + result=await session.call_tool('project_files_'+action,args) + self.assertFalse(result.isError,result) + envelope=result.structuredContent;self.assertEqual(envelope['status'],200,envelope) + self.assertEqual(json.loads(result.content[0].text),envelope) + return envelope['result'] + exact=await tool('file_get',dict(read(),file_id=script_result['id'],revision=1)) + self.assertEqual(exact['value']['version']['text'].encode(),script) + adopted=await tool('file_adopt',dict(self.envelope(),kind='script',path='scripts/inert.py',expected_revision=1, + expected_sha256=hashlib.sha256(changed).hexdigest())) + self.assertEqual(adopted['value']['revision'],2) + page=await tool('file_history',dict(read(),file_id=script_result['id'],offset=0,limit=1)) + self.assertEqual((page['value']['total'],page['value']['items'][0]['revision']),(2,1)) + self.assertNotIn('text',page['value']['items'][0]) + page=await tool('file_history',dict(read(),file_id=script_result['id'],offset=1,limit=1)) + self.assertEqual(page['value']['items'][0]['revision'],2) + errors.seek(0);self.assertNotIn(credential['token'],errors.read()) + anyio.run(exercise) + self.performed.append('Actual MCP reads original NUL bytes, adopts revision2, pages metadata-only history') + from test_project_material_fork import recipe + original_recipe=recipe(self.state()['project']['project_id']) + recipe_body={key:value for key,value in original_recipe.items() if key not in ('format','id','revision')} + retained_recipe=files.recipe_create(**self.envelope(),**recipe_body)['value'] + self.performed.append('Actual SDK authors an inert recipe draft with explicit historical reference fields; no material preparation') + source=self.state();args=self.envelope(source) + denial=agent.call('project_files.save_as',dict(args,root='fixture',directory='denied',name='Denied')) + self.assertEqual(denial['status'],403);self.assertFalse((self.external/'denied').exists()) + fork=cli(owner_config,'file-save-as',owner_mode=True,flags=[ + '--binding',args['binding'],'--expected-checkpoint',args['expected_checkpoint'],'--request-key',args['request_key'], + '--root','fixture','--directory','independent','--name','Independent exact transport Ω']) + self.assertNotEqual(fork['fork']['project_id'],source['project']['project_id']) + self.assertEqual(fork['state'],self.state()) + self.assertEqual((self.external/'independent/scripts/inert.py').read_bytes(),changed) + self.assertEqual((self.external/'independent/notes/empty.md').read_bytes(),b'') + old=agent.call('project_files.file_get',dict(binding=source['binding'],checkpoint=source['project']['checkpoint'],file_id=script_result['id'])) + self.assertEqual(old['status'],409) + observed=files.files_inspect(**read())['value']['items'];self.assertEqual(len(observed),2) + self.assertTrue(all(item['state']=='unchanged' for item in observed)) + current=next(item for item in observed if item['kind']=='script') + self.assertNotEqual(current['id'],script_result['id']) + self.assertEqual(files.file_get(**read(),file_id=current['id'],revision=1)['value']['version']['text'].encode(),script) + self.assertEqual(files.file_get(**read(),file_id=current['id'],revision=2)['value']['version']['text'].encode(),changed) + intents=files.material_intents(**read())['value']['items'] + self.assertEqual(len(intents),1) + intent=files.material_intent(**read(),family=intents[0]['family'],definition_id=intents[0]['definition_id'])['value'] + self.assertEqual(intent['current']['definition'],retained_recipe) + self.assertEqual(intent['current']['definition']['runtime']['catalog_id'],source['project']['project_id']) + self.assertNotEqual(intent['current']['catalog_id'],source['project']['project_id']) + capabilities=files.material_intent_capabilities(**read())['value']['items'] + self.assertTrue(any(item['id']==intent['id'] and 'runtime' in item['requires_reselection'] for item in capabilities)) + self.assertFalse(sentinel.exists()) + self.performed.append('Actual owner CLI Save As selects independent identities; old binding rejected; both exact histories retained; script never executed') + + def test_fork_text_v1_refuses_before_mutation_and_v2_retains_exact_origin(self): + files=ProjectFilesClient(api.ProjectFilesOwnerClient(self.origin,self.owner.token,self.owner.catalog_id,timeout=15)) + source=files.new(root='fixture',directory='text-source',name='Source for versioned text',**self.envelope(files.status())) + original=files.import_text({'name':'Original authored Ω','text':'Exact scientific decision 山', + 'groups':['fixture'],'parents':[],'rights':'Owned text fixture'},**self.envelope(source))['record'] + fork=files.save_as(root='fixture',directory='text-fork',name='Versioned fork Ω',**self.envelope()) + current=files.status();query=files.query(binding=current['binding'],checkpoint=current['project']['checkpoint']) + row=query['items'][0];self.assertNotEqual(row['id'],original['id']) + read=dict(binding=current['binding'],checkpoint=current['project']['checkpoint'],record_id=row['id']) + detail=files.get(row['id'],binding=current['binding'],checkpoint=current['project']['checkpoint']) + provenance=detail['record']['provenance'];self.assertEqual(provenance['method'],'project_fork') + self.assertEqual(provenance['origin']['project_id'],source['project']['project_id']) + self.assertEqual(provenance['origin']['id'],original['id']) + before=m1.canonical_bytes(self.external/'text-fork') + transport=files.client + route=api.operation('project_files.get',1)['path'].replace('{record_id}',row['id']) + from urllib.parse import urlencode + old=transport._request('GET',route+'?'+urlencode({k:v for k,v in read.items() if k!='record_id'}),selection=('project_files.get',1)) + self.assertEqual(old['status'],409,old);self.assertEqual(old['result']['code'],'unsupported') + annotation=dict(revision=detail['record']['revision'],source_revision=detail['record']['source_revision'], + task='text_corpus',annotation={'note':'New independent rationale Ω'},groups=detail['record']['groups'],review='draft') + body=dict(self.envelope(current),annotation=annotation) + old=transport._request('POST',route,body,selection=('project_files.annotate',1)) + self.assertEqual(old['status'],409,old);self.assertEqual(old['result']['code'],'unsupported') + self.assertEqual(m1.canonical_bytes(self.external/'text-fork'),before) + saved=files.annotate(row['id'],annotation,**self.envelope(current)) + self.assertEqual(saved['record']['provenance'],provenance) + self.assertEqual(saved['record']['annotation'],annotation['annotation']) + latest=files.get(row['id'],binding=saved['binding'],checkpoint=saved['checkpoint'],history_limit=4) + self.assertEqual([r['revision'] for r in latest['history']],[2,1]) + self.assertTrue(all(r['provenance']==provenance for r in latest['history'])) + self.performed.append('Actual v1 get/annotate reject fork provenance before any canonical write; negotiated v2 reads and annotates exact origin/history') + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_project_files_m4_http.py b/tests/test_project_files_m4_http.py new file mode 100644 index 00000000..a520c416 --- /dev/null +++ b/tests/test_project_files_m4_http.py @@ -0,0 +1,122 @@ +"""Actual idle independent forks, Git checkout and relocation using disposable PG.""" +import copy +import hashlib +import os +import shutil +import subprocess +import unittest +from unittest.mock import patch +import uuid + +import project_workspace +from project_store import ProjectStoreError +import test_project_files_http as m1 +import test_project_files_m2_http as m2 + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'), 'Requires disposable PostgreSQL') +class ProjectFilesM4HTTP(unittest.TestCase): + cleanup_owned=m1.ProjectFilesHTTP.cleanup_owned + select_host=m1.ProjectFilesHTTP.select_host + request=m1.ProjectFilesHTTP.request + state=m1.ProjectFilesHTTP.state + envelope=m1.ProjectFilesHTTP.envelope + action=m1.ProjectFilesHTTP.action + create=m1.ProjectFilesHTTP.create + arguments=m2.ProjectFilesM2HTTP.arguments + transport=m2.ProjectFilesM2HTTP.transport + call=m2.ProjectFilesM2HTTP.call + + def setUp(self): + self.operations=[] + m1.ProjectFilesHTTP.setUp(self) + gate=patch.object(project_workspace,'M4_ENABLED',True) + gate.start();self.addCleanup(gate.stop) + + def fork(self,**fields): + arguments=self.arguments('save_as',root='fixture',directory='fork',name='Independent fork',**fields) + status,value=self.transport('save_as',arguments) + self.assertEqual(status,200,value) + return arguments,value + + def test_nonempty_independent_fork_exact_science_new_editable_ids_and_same_request_key(self): + self.create() + row=self.call('data_import_text',text='Unique observation café 山',name='Original', + groups=['fixture'],rights='Owned fixture',request_key='same-key') + root=self.external/'alpha' + (root/'scripts').mkdir();raw=b'#!/bin/sh\nexit 99\n' + (root/'scripts'/'inert.sh').write_bytes(raw) + file=self.call('file_adopt',kind='script',path='scripts/inert.sh',expected_revision=0, + expected_sha256=hashlib.sha256(raw).hexdigest()) + original=copy.deepcopy(self.workspace._projection.session.snapshot) + before=m1.canonical_bytes(root) + arguments,value=self.fork() + self.assertNotEqual(value['fork']['source_project_id'],value['fork']['project_id']) + self.assertEqual(value['state'],self.state()) + self.assertEqual(m1.canonical_bytes(root),before) + current=self.workspace._projection.session.snapshot + indexed={(r['family'],r['id']):r for r in current['records']} + for r in original['records']:self.assertEqual(indexed[(r['family'],r['id'])],r) + active=self.call('data_query')['items'] + self.assertEqual(len(active),1);self.assertNotEqual(active[0]['id'],row['id']) + self.assertEqual(self.call('data_get',record_id=active[0]['id'])['text'],row['text']) + status,archived=self.transport('data_availability',self.arguments('data_availability',record_id=row['id'])) + self.assertEqual(status,404,archived) + self.assertEqual((self.external/'fork/scripts/inert.sh').read_bytes(),raw) + files=self.call('files_inspect')['items'] + self.assertEqual(len(files),1);self.assertNotEqual(files[0]['id'],file['id']) + status,replay=self.transport('save_as',arguments) + self.assertEqual((status,replay),(200,value)) + # Source receipts remain exact historical facts; their request keys do + # not suppress a new independent project's current edit. + changed=self.call('data_import_text',text='Independent new data',name='New',groups=['fixture'], + rights='Owned fork fixture',request_key='same-key') + self.assertNotEqual(changed['id'],row['id']) + self.assertEqual(m1.canonical_bytes(root),before) + self.action('close');self.action('open',root='fixture',directory='fork') + self.assertEqual(len(self.call('data_query')['items']),2) + self.assertIsNone(self.workspace._projection.execution._runs) + + def test_published_unselected_fork_exact_retry_and_changed_file_guard(self): + self.create() + self.call('data_import_text',text='Retained unique data',name='Original',groups=['fixture'],rights='Owned') + source=self.state();before=m1.canonical_bytes(self.external/'alpha') + arguments=self.arguments('save_as',root='fixture',directory='fork',name='Retained destination') + with patch.object(self.workspace,'_projection_for',side_effect=RuntimeError('injected projection failure')): + status,failure=self.transport('save_as',arguments) + self.assertEqual(status,503,failure) + self.assertTrue(failure['receipt']['committed']);self.assertFalse(failure['receipt']['selected']) + self.assertEqual(self.state(),source) + self.assertEqual(m1.canonical_bytes(self.external/'alpha'),before) + status,value=self.transport('save_as',arguments) + self.assertEqual(status,200,value);self.assertTrue(value['fork']['reconciled']) + root=self.external/'fork';(root/'notes').mkdir();(root/'notes/reason.md').write_text('Original') + self.call('file_adopt',kind='note',path='notes/reason.md',expected_revision=0, + expected_sha256=hashlib.sha256(b'Original').hexdigest()) + (root/'notes/reason.md').write_text('Unadopted scientific decision') + status,failure=self.transport('save_as',self.arguments('save_as',root='fixture',directory='blocked',name='Blocked')) + self.assertEqual(status,409,failure);self.assertEqual(failure['code'],'working_files_changed') + self.assertFalse((self.external/'blocked').exists()) + + def test_git_checkout_stale_projection_and_moved_folder_inert_reconstruction(self): + self.create();root=self.external/'alpha' + first=self.call('data_import_text',text='Git observation one',name='One',groups=['fixture'],rights='Owned') + def git(*args):return subprocess.check_output(['git','-C',str(root),*args],stderr=subprocess.STDOUT) + git('init','-q');git('config','user.name','Disposable Fixture');git('config','user.email','fixture@example.invalid') + git('add','.');git('commit','-qm','First authored checkpoint');old=git('rev-parse','HEAD').decode().strip() + self.call('data_import_text',text='Git observation two',name='Two',groups=['fixture'],rights='Owned') + git('add','.');git('commit','-qm','Second authored checkpoint') + stale=self.state() + git('checkout','-q',old) + state=self.state();self.assertEqual(state['state'],'recovery_required') + status,failure=self.transport('data_import_text',dict(self.envelope(stale),text='Must not overwrite',name='Blocked',groups=['fixture'],rights='Owned')) + self.assertEqual(status,503,failure) + self.action('open',root='fixture',directory='alpha') + self.assertEqual([r['id'] for r in self.call('data_query')['items']],[first['id']]) + self.action('close');shutil.move(root,self.external/'moved') + with patch('runs.Runs',side_effect=AssertionError('Open constructed Runs')),patch('subprocess.Popen',side_effect=AssertionError('Open executed')): + self.action('open',root='fixture',directory='moved') + self.assertEqual(self.call('data_get',record_id=first['id'])['text'],'Git observation one') + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_project_files_material_http.py b/tests/test_project_files_material_http.py new file mode 100644 index 00000000..84bbc78c --- /dev/null +++ b/tests/test_project_files_material_http.py @@ -0,0 +1,197 @@ +"""Fresh neutral material composition through real project HTTP and PostgreSQL. + +The source and exact-byte input are the existing ExternalCase fixture. This test +qualifies nonexecuting capture/metadata, never its execution or scientific value. +Run only after the lead freezes the public contracts and recipe host sources. +""" +import hashlib +import json +import os +from pathlib import Path +import shutil +import time +import unittest +from unittest.mock import patch +import uuid + +import psycopg + +from metadata import CATALOG_LOCAL_TIMEOUT +from pipeline import digest +import test_project_files_http as m1 +import test_project_files_m2_http as m2 + +ROOT=Path(__file__).resolve().parents[1] +PRODUCT_FILES=tuple(sorted(set(m2.SOURCE_FILES)|{ + 'pipeline.py','model_definitions.py','projects.py','recipe_contract.py','recipe_process.py', + 'metadata.py','workbench.py','extension_bindings.py','extension_evidence.py','model_evidence.py', + 'one_fit_evidence.py'})) + + +def subject_hashes(): + return {name:hashlib.sha256((ROOT/name).read_bytes()).hexdigest() for name in PRODUCT_FILES} + + +def neutral_source(root): + """Reuse the legacy authored process fixture without its Dataset setUp.""" + source=(Path(__file__).parent/'fixtures/external_recipe.py').read_bytes() + exact_input=b'Neutral execution input: Unicode \xce\xbb and exact bytes\x00\xff\n' + sha=lambda raw:hashlib.sha256(raw).hexdigest() + definition={ + 'schema_version':1,'format':'external_recipe_v1','id':'neutral', + 'name':'Neutral process qualification','version':1,'runtime':'system_python', + 'sources':[{'path':'recipe.py','sha256':sha(source)}],'entrypoint':'recipe.py', + 'arguments':[{'parameter':'mode'},{'input':'sample'},{'output_dir':True},{'parameter':'note'}], + 'parameters':{'mode':{'type':'string','default':'complete','enum':['complete','sleep','nonzero'],'max_length':16}, + 'note':{'type':'string','default':'Unicode λ 😀','max_length':32}}, + 'inputs':{'sample':{'max_bytes':4096}}, + 'outputs':[{'path':'result.txt','role':'result','max_bytes':4096,'required':True}], + 'environment':{},'resources':{'wall_seconds':10,'max_output_bytes':16384}} + definition_raw=json.dumps(definition).encode() + manifest={'schema_version':1,'id':uuid.uuid4().hex,'name':'Neutral fixture; no scientific claim', + 'data':[{'id':'sample','label':'Exact input','path':'input.bin','sha256':sha(exact_input)}], + 'recipes':[{'id':'neutral','label':'Neutral executable definition','path':'recipe.json','sha256':sha(definition_raw)}], + 'evidence':[],'requirements':[]} + manifest_raw=json.dumps(manifest).encode() + for name,raw in {'recipe.py':source,'input.bin':exact_input,'recipe.json':definition_raw,'project.json':manifest_raw}.items(): + (root/name).write_bytes(raw) + return manifest,manifest_raw,definition_raw,source,exact_input + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'),'Requires disposable real local PostgreSQL') +class ProjectFilesMaterialHTTP(unittest.TestCase): + # One catalog-local wait plus the existing transport/reply allowance. + request_timeout_seconds=CATALOG_LOCAL_TIMEOUT+15 + # Use the actual M2 transport/setup helpers without inheriting unrelated + # acceptance methods or constructing the legacy execution Dataset fixture. + cleanup_owned=m2.ProjectFilesM2HTTP.cleanup_owned + select_host=m2.ProjectFilesM2HTTP.select_host + request=m2.ProjectFilesM2HTTP.request + state=m2.ProjectFilesM2HTTP.state + envelope=m2.ProjectFilesM2HTTP.envelope + action=m2.ProjectFilesM2HTTP.action + create=m2.ProjectFilesM2HTTP.create + arguments=m2.ProjectFilesM2HTTP.arguments + transport=m2.ProjectFilesM2HTTP.transport + call=m2.ProjectFilesM2HTTP.call + + def setUp(self): + self.material_subject=subject_hashes() + m2.ProjectFilesM2HTTP.setUp(self) + + def receipt(self): + current=subject_hashes() + directory=Path(os.environ.get('TULDOK_QA_OUTPUT_ROOT','/workspace/scratch/tuldok-save-load-qa/artifacts'))/'m2-material-http' + directory.mkdir(parents=True,exist_ok=True) + (directory/(self._testMethodName+'-'+uuid.uuid4().hex+'.json')).write_text(json.dumps({ + 'test':self.id(),'seconds':round(time.monotonic()-self.started,3),'operations':self.operations, + 'source_sha256':self.material_subject,'end_sha256':current,'source_stable':current==self.material_subject, + 'fixture':'Existing authored ExternalCase source/input; no execution, runtime provisioning or scientific qualification.'},indent=2)+'\n') + self.assertEqual(current,self.material_subject,'Material/public host source changed during actual HTTP proof') + + def assert_no_live_authority(self,*,studies=0,trials=0): + projection=self.workspace._projection + for table in ('workspace_projects','workspace_runs','access_actors','access_credentials','access_admissions', + 'access_execution_grants','access_execution_consumptions', + 'model_attempt_reservations','model_operation_receipts'): + self.assertEqual(projection.db.execute('SELECT count(*) FROM '+table).fetchone()[0],0,table) + # Canonical authoring intent is reconstructed without importing current + # epochs, Run rows, grants, reservations, or native request receipts. + self.assertEqual(projection.db.execute('SELECT count(*) FROM model_studies').fetchone()[0],studies) + self.assertEqual(projection.db.execute('SELECT count(*) FROM model_trials').fetchone()[0],trials) + self.assertEqual(projection.study_execution._epochs,{}) + self.assertIsNone(projection.execution._runs) + for row in projection.db.execute('SELECT document FROM model_studies'): + self.assertFalse(projection.study_execution.epoch_current(json.loads(row['document']))) + + def closure(self,ref): + with self.owner.bound(self.workspace): + return self.workspace._projection.materials.composition_closure(ref) + + def test_fresh_neutral_material_preparation_and_cold_refs_are_nonexecuting(self): + manifest,manifest_raw,definition_raw,source,exact_input=neutral_source(self.external) + cold_dsn=os.environ.get('TULDOK_PROJECT_FILES_COLD_DATABASE_URL') + self.assertTrue(cold_dsn,'Cold proof requires its separately authorized disposable role/database') + with patch('app.Dataset.__init__',side_effect=AssertionError('New/Open cannot construct a legacy Dataset')) as legacy_dataset, \ + patch('runs.Runs',side_effect=AssertionError('New/Open cannot construct a live Runs owner')) as live_runs, \ + patch('subprocess.Popen',side_effect=AssertionError('Neutral preparation must not execute or probe')) as processes: + self.create();logical=self.state()['project']['project_id'] + self.assertNotEqual(logical,self.workspace.db.dataset_id) + self.assertNotEqual(logical,manifest['id']) + self.assert_no_live_authority() + registered=self.call('source_register',root='fixture',manifest_path='project.json') + self.assertEqual(registered['origin_catalog_id'],logical) + self.assertEqual(registered['project_id'],manifest['id']) + self.assertEqual(registered['manifest_sha256'],hashlib.sha256(manifest_raw).hexdigest()) + draft=self.call('model_create',name='Neutral captured model metadata',kind='captured_source', + task={'kind':'task','catalog_id':logical,'id':'neutral.bytes','revision':1, + 'sha256':digest({'format':'authored_neutral_task_v1'})}, + specification={'format':'authored_neutral_model_v1'}, + source_selector={'project_id':manifest['id'],'recipe_id':'neutral','expected_sha256':hashlib.sha256(definition_raw).hexdigest()}, + preprocessing_policy={'kind':'identity'},output_policy={'kind':'exact_bytes'}) + model=self.call('model_publish',model_id=draft['id'],revision=draft['revision']) + self.assertEqual(model['ref']['catalog_id'],logical) + self.assertEqual(model['material']['source_selector'],draft['source_selector']) + graph=self.call('graph_create_external',name='Neutral exact-byte execution',project_id=manifest['id'], + recipe_id='neutral',expected_sha256=hashlib.sha256(definition_raw).hexdigest(), + inputs={'sample':{'reference_id':'sample','expected_sha256':hashlib.sha256(exact_input).hexdigest()}}) + self.assertEqual(graph['recipe'],'external_recipe') + self.assertEqual(self.call('graph_get',graph_id=graph['id']),graph) + material=self.call('recipe_prepare',graph_id=graph['id'],revision=graph['revision'],model=model['ref']) + self.assertEqual(material['ref']['catalog_id'],logical) + self.assertEqual(material['material']['model'],model['ref']) + self.assertEqual(material['material']['data'],[]) + self.assertEqual(self.call('recipe_material',ref=material['ref'],verify=True),material) + closure=self.closure(material['ref']) + self.assertEqual(closure['kind'],'external') + self.assertEqual(closure['captured_inputs'],{'input/sample':exact_input}) + self.assertEqual(closure['execution_material']['catalog_id'],logical) + self.assertEqual(closure['execution_material']['project']['project_id'],manifest['id']) + self.assertEqual(closure['execution_material']['inputs']['sample']['measured_sha256'],hashlib.sha256(exact_input).hexdigest()) + self.assertEqual(closure['execution_material']['captures']['source/recipe.py']['sha256'],hashlib.sha256(source).hexdigest()) + runtime=self.call('runtime_material',ref=material['material']['runtime'],verify=True) + self.assertEqual(runtime['environment'],closure['execution_material']['runtime']) + policy={'format':'selection_policy_v1','metric_definition_sha256':'b'*64,'checkpoint_rule':'explicit', + 'replicate_reduce':'individual','required_capabilities':[],'numerical_acceptance':'diagnostic','tie_break':'trial_id'} + study=self.call('study_create',name='Neutral retained composition intent',limits={ + 'attempt_slots':3,'admitted_wall_seconds':1000,'retained_bytes':10**9,'peak_staging_bytes':10**9},policy=policy) + trial=self.call('trial_prepare',study_id=study['id'],revision=study['revision'],material=material['ref'],replicate=0) + self.assertEqual(trial['material'],material['ref']) + self.assertEqual(self.call('trial',trial_id=trial['id'],origin_catalog_id=logical),trial) + view=self.call('study',study_id=study['id'],origin_catalog_id=logical,limit=1) + self.assertEqual(view['study']['trial_count'],1) + self.assertTrue(all(value==0 for value in view['charged'].values())) + for table in ('workspace_runs','access_admissions','access_execution_grants','access_execution_consumptions','model_attempt_reservations'): + self.assertEqual(self.workspace._projection.db.execute('SELECT count(*) FROM '+table).fetchone()[0],0,table) + self.action('save');expected_state=self.state();self.action('close') + original=self.external/'alpha';portable=m1.canonical_bytes(original) + cold_root=self.path/'cold-material-root';cold_root.mkdir();clone=cold_root/'clone' + shutil.copytree(original,clone,ignore=shutil.ignore_patterns('.git','.tuldok-local')) + self.assertEqual(m1.canonical_bytes(clone),portable) + source_database=psycopg.conninfo.conninfo_to_dict(os.environ['TULDOK_DATABASE_URL'])['dbname'] + with psycopg.connect(cold_dsn) as connection: + database,allowed=connection.execute('SELECT current_database(),has_database_privilege(current_user,%s,\'CONNECT\')',(source_database,)).fetchone() + self.assertNotEqual(database,source_database);self.assertFalse(allowed) + self.select_host(self.path/'cold-material-control',{'fixture':cold_root},cold_dsn) + self.action('open',root='fixture',directory='clone') + self.assertEqual(self.state()['project'],expected_state['project']) + self.assert_no_live_authority(studies=1,trials=1) + self.assertEqual(self.workspace._projection.db.execute('SELECT id FROM model_studies').fetchone()[0],study['id']) + self.assertEqual(self.workspace._projection.db.execute('SELECT id FROM model_trials').fetchone()[0],trial['id']) + self.assertEqual(self.call('model_get',model_id=draft['id']),draft) + self.assertEqual(self.call('model_material',ref=model['ref'],verify=True),model) + self.assertEqual(self.call('graph_get',graph_id=graph['id']),graph) + self.assertEqual(self.call('recipe_material',ref=material['ref'],verify=True),material) + self.assertEqual(self.call('runtime_material',ref=material['material']['runtime'],verify=True),runtime) + self.assertEqual(self.call('trial',trial_id=trial['id'],origin_catalog_id=logical),trial) + self.assertEqual(self.call('study',study_id=study['id'],origin_catalog_id=logical,limit=1),view) + self.assertEqual(self.closure(material['ref']),closure) + snapshotref={key:registered[key] for key in ('origin_catalog_id','project_id','manifest_sha256')} + status,error=self.transport('source_references',self.arguments('source_references',**snapshotref)) + self.assertEqual(status,409,error) + self.assert_no_live_authority(studies=1,trials=1);self.action('save') + self.assertEqual(m1.canonical_bytes(clone),portable) + processes.assert_not_called();live_runs.assert_not_called();legacy_dataset.assert_not_called() + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_project_files_owner_mcp.py b/tests/test_project_files_owner_mcp.py new file mode 100644 index 00000000..1e3d74ca --- /dev/null +++ b/tests/test_project_files_owner_mcp.py @@ -0,0 +1,263 @@ +"""Explicit project-owner MCP mode over real stdio, HTTP and disposable PG. + +The qualification subject is a separate immutable copy of the accepted M4 +client source plus exactly this test and the explicit MCP profile change. +No runtime, recipe, provider or scientific execution occurs in this fixture. +""" +from datetime import timedelta +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import subprocess +import sys +import time +import unittest +from unittest.mock import Mock, patch +import uuid + +import agent_api +import mcp_stdio +import test_project_files_http as http_fixture + +ROOT=Path(__file__).resolve().parents[1] +SDK_AVAILABLE=all(importlib.util.find_spec(name) for name in ('mcp','anyio','jsonschema')) + + +def subject(): + name=os.environ.get('TULDOK_OWNER_MCP_MANIFEST') + if not name:raise AssertionError('Provide the immutable owner-MCP qualification manifest') + path=Path(name);value=json.loads(path.read_text()) + if value['qualification_copy']!=str(ROOT):raise AssertionError('Tests must run in the selected immutable copy') + current={name:hashlib.sha256((ROOT/name).read_bytes()).hexdigest() for name in value['files']} + if current!=value['files']:raise AssertionError('Immutable owner-MCP source changed before the test') + return dict(manifest_sha256=hashlib.sha256(path.read_bytes()).hexdigest(), + parent_manifest_sha256=value['parent_manifest_sha256'],files=current) + + +@unittest.skipUnless(SDK_AVAILABLE,'Requires the approved optional MCP dependencies') +class OwnerModeSelection(unittest.TestCase): + def test_owner_opt_in_selects_only_existing_finite_owner_client_before_serving(self): + import anyio + owner=Mock();order=[];owner.bootstrap.side_effect=lambda:order.append('bootstrap') + def ready(function,client,*args): + self.assertIs(function,mcp_stdio.serve);self.assertIs(client,owner) + self.assertEqual(order,['bootstrap']);order.append('serve') + with (patch.object(agent_api.ProjectFilesOwnerClient,'from_config',return_value=owner) as selected, + patch.object(agent_api.AgentClient,'from_config',side_effect=AssertionError('No implicit agent fallback')), + patch.object(anyio,'run',side_effect=ready)): + self.assertEqual(mcp_stdio.main(['--config','fixture-only','--project-files-owner']),0) + selected.assert_called_once_with('fixture-only');self.assertEqual(order,['bootstrap','serve']) + + def test_default_agent_and_owner_bootstrap_refusal_never_fall_back(self): + import anyio + agent=Mock() + with (patch.object(agent_api.AgentClient,'from_config',return_value=agent) as selected, + patch.object(agent_api.ProjectFilesOwnerClient,'from_config',side_effect=AssertionError('Owner mode requires flag')), + patch.object(anyio,'run') as run): + self.assertEqual(mcp_stdio.main(['--config','fixture-only']),0) + self.assertIs(run.call_args.args[1],agent);selected.assert_called_once() + owner=Mock();owner.bootstrap.side_effect=agent_api.AgentAPIError('Refused fixture identity.','unauthenticated',401) + with (patch.object(agent_api.ProjectFilesOwnerClient,'from_config',return_value=owner), + patch.object(agent_api.AgentClient,'from_config',side_effect=AssertionError('No fallback after owner refusal')), + patch.object(anyio,'run') as run,patch('sys.stderr',new_callable=io.StringIO)): + self.assertEqual(mcp_stdio.main(['--config','fixture-only','--project-files-owner']),1) + run.assert_not_called() + + +@unittest.skipUnless(SDK_AVAILABLE,'Requires the approved optional MCP dependencies') +class TransferModeSelection(unittest.TestCase): + def test_transfer_opt_in_selects_five_only_owner_client_before_serving(self): + import anyio + owner=Mock();order=[];owner.bootstrap.side_effect=lambda:order.append('bootstrap') + self.assertEqual(agent_api.ProjectTransferOwnerClient.OPERATIONS, + frozenset('project_transfer.'+name for name in ('status','preview','report_page','stop','extract'))) + def ready(function,client,*args): + self.assertIs(function,mcp_stdio.serve);self.assertIs(client,owner) + self.assertEqual(order,['bootstrap']);order.append('serve') + with (patch.object(agent_api.ProjectTransferOwnerClient,'from_config',return_value=owner) as selected, + patch.object(agent_api.ProjectFilesOwnerClient,'from_config',side_effect=AssertionError('No file-owner fallback')), + patch.object(agent_api.AgentClient,'from_config',side_effect=AssertionError('No agent fallback')), + patch.object(anyio,'run',side_effect=ready)): + self.assertEqual(mcp_stdio.main(['--config','fixture-only','--project-transfer-owner']),0) + selected.assert_called_once_with('fixture-only');self.assertEqual(order,['bootstrap','serve']) + + def test_owner_profile_flags_are_mutually_exclusive_before_configuration_read(self): + with (patch.object(agent_api.ProjectTransferOwnerClient,'from_config') as transfer, + patch.object(agent_api.ProjectFilesOwnerClient,'from_config') as files, + patch.object(agent_api.AgentClient,'from_config') as agent, + patch('sys.stderr',new_callable=io.StringIO)): + with self.assertRaises(SystemExit) as refusal: + mcp_stdio.main(['--config','fixture-only','--project-files-owner','--project-transfer-owner']) + self.assertEqual(refusal.exception.code,2) + transfer.assert_not_called();files.assert_not_called();agent.assert_not_called() + + def test_transfer_bootstrap_refusal_cannot_serve_or_select_another_authority(self): + import anyio + owner=Mock();owner.bootstrap.side_effect=agent_api.AgentAPIError('Fixture host credential refused.','unauthenticated',401) + with (patch.object(agent_api.ProjectTransferOwnerClient,'from_config',return_value=owner), + patch.object(agent_api.ProjectFilesOwnerClient,'from_config',side_effect=AssertionError('No file-owner fallback')), + patch.object(agent_api.AgentClient,'from_config',side_effect=AssertionError('No agent fallback')), + patch.object(anyio,'run') as run,patch('sys.stderr',new_callable=io.StringIO)): + self.assertEqual(mcp_stdio.main(['--config','fixture-only','--project-transfer-owner']),1) + owner.bootstrap.assert_called_once_with();run.assert_not_called() + + +@unittest.skipUnless(SDK_AVAILABLE and os.environ.get('TULDOK_DATABASE_URL'), + 'Requires approved MCP dependencies and disposable PostgreSQL') +class OwnerMCPHTTP(unittest.TestCase): + def setUp(self): + self.subject=subject();self.performed=[];self.started=time.monotonic() + self.fixture=http_fixture.ProjectFilesHTTP('test_authenticated_owner_only_selection_and_inert_control') + self.fixture.setUp();self.addCleanup(self.fixture.doCleanups) + self.addCleanup(self.receipt) + self.environment={key:value for key,value in os.environ.items() if not key.startswith(('TULDOK_','PG'))} + self.environment['PYTHONDONTWRITEBYTECODE']='1' + + def receipt(self): + current={name:hashlib.sha256((ROOT/name).read_bytes()).hexdigest() for name in self.subject['files']} + target=Path(os.environ.get('TULDOK_QA_OUTPUT_ROOT','/workspace/scratch/tuldok-save-load-qa/artifacts'))/'owner-mcp' + target.mkdir(parents=True,exist_ok=True) + value=dict(test=self.id(),seconds=round(time.monotonic()-self.started,3),performed=self.performed, + qualification_manifest_sha256=self.subject['manifest_sha256'],parent_manifest_sha256=self.subject['parent_manifest_sha256'], + source_stable=current==self.subject['files'],mcp_stdio_sha256=current['mcp_stdio.py'], + test_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + fixture='Actual MCP SDK stdio and authenticated HTTP; disposable PostgreSQL; authored text only') + (target/(self._testMethodName+'-'+uuid.uuid4().hex+'.json')).write_text(json.dumps(value,indent=2)+'\n') + self.assertEqual(current,self.subject['files'],'Immutable owner-MCP source changed during the test') + + def config(self,label,token,*,catalog_id=None): + f=self.fixture;private=f.path/(label+'.token');config=f.path/(label+'.json') + private.write_text(token+'\n');private.chmod(0o600) + config.write_text(json.dumps(dict(endpoint=f.origin,catalog_id=catalog_id or f.owner.catalog_id,token_file=str(private),timeout=15))) + config.chmod(0o600);return config + + def parameters(self,config,*,owner=True): + from mcp import StdioServerParameters + return StdioServerParameters(command=sys.executable,args=[str(ROOT/'mcp_stdio.py'),'--config',str(config), + *(['--project-files-owner'] if owner else [])],cwd=str(ROOT),env=self.environment) + + async def tools(self,session,*,owner): + listed=(await session.list_tools()).tools;names={item.name for item in listed} + self.assertTrue(names) + if owner: + allowed={agent_api.operation(name)['mcp_name'] for name in agent_api.ProjectFilesOwnerClient.OPERATIONS} + self.assertTrue(names<=allowed) + self.assertFalse({'context_get','identity_get','runs_recover_publication','runs_recover_coordinator'}&names) + self.assertTrue({'project_files_new','project_files_open','project_files_save','project_files_save_as','project_files_close'}<=names) + else:self.assertIn('context_get',names) + return names + + async def tool(self,session,action,arguments,*,status=200): + result=await session.call_tool('project_files_'+action,arguments) + envelope=result.structuredContent + self.assertEqual(envelope['status'],status,envelope) + self.assertEqual(result.isError,status>=400) + self.assertEqual(json.loads(result.content[0].text),envelope) + return envelope['result'] + + def test_owner_new_import_save_save_as_close_open_and_nonproject_profile_refusal(self): + import anyio + from mcp import ClientSession + from mcp.client.stdio import stdio_client + from mcp.shared.exceptions import McpError + f=self.fixture;config=self.config('owner',f.owner.token) + async def exercise(): + with (f.path/'owner-mcp.stderr').open('w+') as errors: + async with stdio_client(self.parameters(config),errlog=errors) as (reader,writer): + async with ClientSession(reader,writer,read_timeout_seconds=timedelta(seconds=20)) as session: + await session.initialize();names=await self.tools(session,owner=True) + self.performed.append(dict(owner_tool_count=len(names),all_tools_finite_project_files=True)) + with self.assertRaises(McpError) as refusal: + await session.call_tool('runs_recover_publication',{}) + self.assertEqual(refusal.exception.error.code,-32602) + initial=await self.tool(session,'status',{}) + opened=await self.tool(session,'new',f.envelope(initial,root='fixture',directory='owner-source',name='Actual owner MCP 山')) + imported=await self.tool(session,'import_text',f.envelope(opened,asset=dict( + text=http_fixture.TEXT,name='Actual owner MCP authored observation',groups=['owner-mcp'],parents=[],rights='Owned synthetic fixture'))) + state=await self.tool(session,'status',{});source_id=state['project']['project_id'] + source_before=http_fixture.canonical_bytes(f.external/'owner-source') + saved=await self.tool(session,'save',f.envelope(state)) + self.assertEqual(saved['project'],state['project']) + fork=await self.tool(session,'save_as',f.envelope(saved,root='fixture',directory='owner-fork',name='Independent MCP Ω')) + current=fork['state'];self.assertNotEqual(current['project']['project_id'],source_id) + self.assertEqual(http_fixture.canonical_bytes(f.external/'owner-source'),source_before) + closed=await self.tool(session,'close',f.envelope(current));self.assertEqual(closed['state'],'no_project_open') + reopened=await self.tool(session,'open',f.envelope(closed,root='fixture',directory='owner-fork')) + self.assertEqual(reopened['project'],current['project']) + page=await self.tool(session,'query',dict(binding=reopened['binding'],checkpoint=reopened['project']['checkpoint'])) + self.assertEqual(len(page['items']),1);record_id=page['items'][0]['id'] + self.assertNotEqual(record_id,imported['record']['id']) + actual=await self.tool(session,'get',dict(record_id=record_id,binding=reopened['binding'],checkpoint=reopened['project']['checkpoint'])) + self.assertEqual(actual['record']['text'],http_fixture.TEXT) + self.assertIsNone(f.workspace._projection.execution._runs) + self.performed.extend(['Actual owner MCP New/import_text/Save/SaveAs/Close/Open completed', + 'Independent project and record IDs; original source bytes exact; Unicode text cold restored', + 'Nonproject publication owner tool refused at MCP profile boundary; no execution owner constructed']) + errors.seek(0);self.assertNotIn(f.owner.token,errors.read()) + anyio.run(exercise) + + def test_bootstrap_denies_wrong_identity_revoked_and_other_catalog_without_fallback(self): + f=self.fixture + with f.owner.bound(f.workspace): + agent=f.workspace.access.issue(dict(label='MCP denial fixture agent',scopes=['inspect','edit'])) + cases=[('owner-without-optin',self.config('owner-default',f.owner.token),False,f.owner.token), + ('agent-in-owner-mode',self.config('agent-owner-mode',agent['token']),True,agent['token'])] + saved=(f.workspace,f.owner,f.origin) + f.select_host(f.path/'other-control',{'fixture':f.external}) + other=f.owner + f.workspace,f.owner,f.origin=saved + cases.append(('other-catalog-owner',self.config('other-owner',other.token),True,other.token)) + def refused(label,config,owner,token): + command=[sys.executable,str(ROOT/'mcp_stdio.py'),'--config',str(config),*(['--project-files-owner'] if owner else [])] + result=subprocess.run(command,input='',cwd=ROOT,env=self.environment,capture_output=True,text=True,timeout=20) + self.assertEqual(result.returncode,1,(label,result.stdout,result.stderr));self.assertEqual(result.stdout,'') + self.assertNotIn(token,result.stderr);self.assertNotIn(f.origin,result.stderr) + self.performed.append(label+' refused before MCP service initialization') + for case in cases:refused(*case) + with f.owner.bound(f.workspace):f.workspace.access.revoke(f.owner.identity['credential_id']) + refused('revoked-owner',self.config('revoked-owner',f.owner.token),True,f.owner.token) + self.assertEqual(list(f.external.iterdir()),[]) + + def test_revocation_after_owner_bootstrap_denies_mutation_and_retains_no_project(self): + import anyio + from mcp import ClientSession + from mcp.client.stdio import stdio_client + f=self.fixture;config=self.config('revoke-after-start',f.owner.token) + async def exercise(): + with (f.path/'revoked-mcp.stderr').open('w+') as errors: + async with stdio_client(self.parameters(config),errlog=errors) as (reader,writer): + async with ClientSession(reader,writer,read_timeout_seconds=timedelta(seconds=20)) as session: + await session.initialize();await self.tools(session,owner=True) + state=await self.tool(session,'status',{}) + with f.owner.bound(f.workspace):f.workspace.access.revoke(f.owner.identity['credential_id']) + denial=await self.tool(session,'new',f.envelope(state,root='fixture',directory='revoked-write',name='Denied'),status=401) + self.assertEqual(denial['code'],'unauthenticated') + self.assertFalse((f.external/'revoked-write').exists());self.assertIsNone(f.workspace._store) + self.performed.append('Current HTTP credential revocation after MCP bootstrap prevents New without a filesystem effect') + errors.seek(0);self.assertNotIn(f.owner.token,errors.read()) + anyio.run(exercise) + + def test_default_agent_mcp_still_reads_edits_and_cannot_use_owner_lifecycle(self): + import anyio + from mcp import ClientSession + from mcp.client.stdio import stdio_client + f=self.fixture;f.action('new',root='fixture',directory='agent-source',name='Default agent fixture') + with f.owner.bound(f.workspace):agent=f.workspace.access.issue(dict(label='Default MCP editor',scopes=['inspect','edit'])) + config=self.config('default-agent',agent['token']) + async def exercise(): + with (f.path/'agent-mcp.stderr').open('w+') as errors: + async with stdio_client(self.parameters(config,owner=False),errlog=errors) as (reader,writer): + async with ClientSession(reader,writer,read_timeout_seconds=timedelta(seconds=20)) as session: + await session.initialize();await self.tools(session,owner=False) + current=await self.tool(session,'status',{}) + result=await self.tool(session,'import_text',f.envelope(current,asset=dict(text='Default agent exact text',name='Agent note',groups=['agent'],parents=[],rights='Owned fixture'))) + self.assertEqual(result['record']['text'],'Default agent exact text') + current=await self.tool(session,'status',{});before=http_fixture.canonical_bytes(f.external/'agent-source') + await self.tool(session,'save_as',f.envelope(current,root='fixture',directory='agent-denied',name='Denied'),status=403) + self.assertFalse((f.external/'agent-denied').exists());self.assertEqual(http_fixture.canonical_bytes(f.external/'agent-source'),before) + self.performed.append('Unchanged default agent MCP can inspect/edit; owner SaveAs returns403 with no canonical effect') + errors.seek(0);self.assertNotIn(agent['token'],errors.read()) + anyio.run(exercise) diff --git a/tests/test_project_fork.py b/tests/test_project_fork.py new file mode 100644 index 00000000..946f8b75 --- /dev/null +++ b/tests/test_project_fork.py @@ -0,0 +1,322 @@ +"""Independent fork publication: exact origins, verified bytes, no-clobber roots.""" +import copy +import errno +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch +import uuid + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import project_fork as fork_module +from project_artifacts import ProjectArtifacts +from project_fork import ProjectForkError, active_records, decode_fork, publish_fork +from project_material import ProjectMaterial, decode_records as decode_material_records +from project_store import ProjectStore, ProjectStoreError, DIR_FLAGS +from test_project_material import model, SOURCE + + +def body(value): + return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest() + + +def validate(records, project_id): + decode_fork(records, project_id=project_id) + decode_material_records(records) + + +class ForkFilesystemTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='tuldok-fork-') + self.addCleanup(self.tmp.cleanup) + self.path = Path(self.tmp.name) + self.root = self.path / 'projects' + self.root.mkdir() + self.root_fd = os.open(self.root, DIR_FLAGS) + self.addCleanup(lambda: os.close(self.root_fd)) + self.source_path = self.root / 'source' + self.source = ProjectStore.create(self.source_path, 'Source scientific intent', 'create-source', 'a' * 64) + self.addCleanup(lambda: self.source.close()) + self.artifacts = ProjectArtifacts(self.source) + self.addCleanup(self.artifacts.close) + self.origin = self.source.snapshot()['project_id'] + self.material_value, payloads = model(self.origin) + local = self.path / 'local-material' + capture = local / self.material_value['ref']['sha256'] + capture.mkdir(parents=True) + for name, raw in payloads.items(): + (capture / name).write_bytes(raw) + session = SimpleNamespace(snapshot=self.source.snapshot(), artifacts=self.artifacts, check=lambda: None) + self.material = ProjectMaterial(session) + self.record = self.material.capture_model_material(self.material_value, local) + records = [*self.material.declarations(self.record), self.record] + self.original = self.source.commit(records, self.source.snapshot()['checkpoint'], 'retain', body(records)) + + def candidate(self, source, destination): + return dict(records=copy.deepcopy(source['records']), mapping=[], archived=[], + artifacts=[dict(declaration=copy.deepcopy(item['document']), preservation='unique') + for item in source['records'] if item['family'] == 'project_artifact']) + + def fork(self, relative='fork', **options): + arguments = dict(root_path=self.root, name='Independent copy', expected_checkpoint=self.original['checkpoint'], + request_key='fork:one', quiescent=True, build_candidate=self.candidate, validate_candidate=validate) + arguments.update(options) + return publish_fork(self.source, self.artifacts, self.root_fd, relative, **arguments) + + def assert_code(self, code, function, *args, **kwargs): + with self.assertRaises(ProjectStoreError) as caught: + function(*args, **kwargs) + self.assertEqual(caught.exception.code, code) + return caught.exception + + def canonical_bytes(self): + return {p.relative_to(self.source_path).as_posix(): p.read_bytes() + for p in self.source_path.rglob('*') if p.is_file() and '.tuldok-local' not in p.parts} + + def test_complete_fork_retains_exact_original_material_hashes_and_copies_without_aliases(self): + before = self.canonical_bytes() + with patch.object(subprocess, 'Popen', side_effect=AssertionError('No project execution')): + result = self.fork() + snapshot, overlay = result['snapshot'], result['fork'] + self.assertNotEqual(snapshot['project_id'], self.origin) + self.assertFalse(result['reconciled']) + self.assertEqual(overlay['source'], dict(project_id=self.origin, checkpoint=self.original['checkpoint'])) + self.assertEqual(next(item for item in snapshot['records'] if item['family'] == 'model_material'), self.record) + with ProjectStore.open(self.root / 'fork') as destination, ProjectArtifacts(destination) as artifacts: + reader = ProjectMaterial(SimpleNamespace(snapshot=destination.snapshot(), artifacts=artifacts, check=lambda: None)) + self.assertEqual(reader.model(self.material_value['ref'], verify=True), self.material_value) + source_ref = self.material_value['material']['source_closure'][0]['blob'] + self.assertEqual(reader.blob(self.material_value['ref'], source_ref), SOURCE) + for sha in overlay['artifacts']['copied']: + old = self.source_path / 'artifacts/sha256' / sha + new = self.root / 'fork/artifacts/sha256' / sha + self.assertEqual(new.read_bytes(), old.read_bytes()) + self.assertNotEqual((new.stat().st_dev, new.stat().st_ino), (old.stat().st_dev, old.stat().st_ino)) + self.assertEqual(new.stat().st_nlink, 1) + manifest = json.loads((self.root / 'fork/.tuldok/project.json').read_bytes()) + self.assertTrue(all(item['namespace'] == snapshot['project_id'] for item in manifest['inventory'])) + self.assertEqual(self.canonical_bytes(), before) + + def test_exact_retry_reconciles_and_changed_body_or_unrelated_destination_never_overwrites(self): + first = self.fork() + before = (self.root / 'fork/.tuldok/project.json').read_bytes() + with patch.object(self, 'candidate', side_effect=AssertionError('Never repeat construction')): + replay = self.fork() + self.assertTrue(replay['reconciled']) + self.assertEqual(replay['snapshot'], first['snapshot']) + self.assert_code('conflict', self.fork, name='Different request') + self.assertEqual((self.root / 'fork/.tuldok/project.json').read_bytes(), before) + other = self.root / 'unrelated' + with ProjectStore.create(other, 'Existing owner', 'other', 'b' * 64): + pass + unrelated = (other / '.tuldok/project.json').read_bytes() + self.assert_code('conflict', self.fork, 'unrelated') + self.assertEqual((other / '.tuldok/project.json').read_bytes(), unrelated) + + def test_quiescence_checkpoint_overlap_and_symlink_guards_precede_publication(self): + before = self.canonical_bytes() + self.assert_code('busy', self.fork, quiescent=False) + self.assert_code('stale', self.fork, expected_checkpoint='f' * 64) + self.assert_code('invalid', self.fork, 'source/nested-fork') + self.assert_code('invalid', self.fork, 'source') + alias = self.root / 'alias' + alias.symlink_to(self.source_path, target_is_directory=True) + self.assert_code('conflict', self.fork, 'alias') + self.assert_code('invalid', self.fork, '../escape') + self.assertEqual(self.canonical_bytes(), before) + self.assertFalse((self.source_path / 'nested-fork').exists()) + + def test_missing_unique_corrupt_and_source_hardlinks_block_without_final_destination(self): + declaration = next(item['document'] for item in self.original['records'] if item['family'] == 'project_artifact') + payload = self.source_path / 'artifacts/sha256' / declaration['sha256'] + raw = payload.read_bytes() + payload.unlink() + self.assert_code('unavailable', self.fork) + self.assertFalse((self.root / 'fork').exists()) + payload.write_bytes(b'corrupt') + self.assert_code('invalid', self.fork) + payload.unlink() + payload.write_bytes(raw) + os.link(payload, self.path / 'unsafe-alias') + self.assert_code('invalid', self.fork) + self.assertFalse((self.root / 'fork').exists()) + self.assertEqual(self.source.snapshot(), self.original) + + def test_missing_regenerable_is_explicit_and_present_regenerable_is_still_copied(self): + # Model evidence above remains unique. Two separate owner-declared cache + # payloads exercise the explicit regenerable policy without downgrading it. + caches = [b'Regenerable optional cache one', b'Regenerable optional cache two'] + declarations = [dict(format='project_artifact_v1', sha256=hashlib.sha256(raw).hexdigest(), bytes=len(raw)) for raw in caches] + records = copy.deepcopy(self.original['records']) + for raw, declaration in zip(caches, declarations): + self.artifacts.publish_bytes(raw, declaration) + records.append(dict(family='project_artifact', id=declaration['sha256'], document=declaration)) + self.original = self.source.commit(records, self.original['checkpoint'], 'cache-declarations', body(records)) + missing = declarations[0]['sha256'] + (self.source_path / 'artifacts/sha256' / missing).unlink() + regenerable = {item['sha256'] for item in declarations} + def candidate(source, destination): + value = self.candidate(source, destination) + for edge in value['artifacts']: + if edge['declaration']['sha256'] in regenerable: + edge['preservation'] = 'regenerable' + return value + result = self.fork(build_candidate=candidate) + self.assertEqual(result['fork']['artifacts']['missing_regenerable'], [missing]) + self.assertIn(declarations[1]['sha256'], result['fork']['artifacts']['copied']) + self.assertEqual(len(result['fork']['artifacts']['copied']), len(records)-2) + with ProjectStore.open(self.root / 'fork') as destination, ProjectArtifacts(destination) as owner: + self.assertEqual(owner.status(declarations[0])['state'], 'missing') + + def test_invalid_candidate_or_omitted_inventory_retains_stage_and_source(self): + before = self.canonical_bytes() + def changed(source, destination): + value = self.candidate(source, destination) + next(item for item in value['records'] if item['family']=='project_artifact')['document']['bytes'] += 1 + return value + error = self.assert_code('invalid', self.fork, build_candidate=changed) + self.assertTrue(error.staging_directory.startswith('.tuldok-fork-')) + self.assertFalse((self.root / 'fork').exists()) + def missing_plan(source, destination): + value = self.candidate(source, destination) + value['artifacts'] = [] + return value + self.assert_code('invalid', self.fork, build_candidate=missing_plan) + self.assertEqual(self.canonical_bytes(), before) + + def test_final_rename_race_never_overwrites_existing_destination(self): + rename = fork_module._rename_noreplace + target = self.root / 'fork' + def race(parent, source, destination): + if destination == 'fork': + self.assertFalse(target.exists()) + target.mkdir() + (target / 'owned.txt').write_bytes(b'Existing owner') + rename(parent, source, destination) + with patch.object(fork_module, '_rename_noreplace', side_effect=race): + self.assert_code('conflict', self.fork) + self.assertEqual(list(target.iterdir()), [target / 'owned.txt']) + self.assertEqual((target / 'owned.txt').read_bytes(), b'Existing owner') + + def test_post_rename_fsync_failure_is_unknown_and_exact_retry_requires_fresh_barrier(self): + sync = fork_module.os.fsync + root = self.root.stat() + def fail_final_parent(fd): + info = os.fstat(fd) + if (info.st_dev, info.st_ino) == (root.st_dev, root.st_ino) and (self.root / 'fork').exists(): + raise OSError(errno.EIO, 'fork parent durability unknown') + return sync(fd) + with patch.object(fork_module.os, 'fsync', side_effect=fail_final_parent): + error = self.assert_code('unknown', self.fork) + self.assertTrue(error.receipt['committed']) + self.assertFalse(error.receipt['selected']) + self.assert_code('unavailable', self.fork) + reconciled = self.fork() + self.assertTrue(reconciled['reconciled']) + self.assertEqual(reconciled['snapshot']['project_id'], error.receipt['project_id']) + self.assertEqual(self.source.snapshot(), self.original) + + def test_real_process_kill_before_and_after_destination_publication_is_coherent(self): + self.artifacts.close() + self.source.close() + code = r''' +import copy,os,sys +import project_fork as m +from project_artifacts import ProjectArtifacts +from project_store import ProjectStore,DIR_FLAGS +from project_material import decode_records +root,window=sys.argv[1:] +source=ProjectStore.open(os.path.join(root,'source')) +artifacts=ProjectArtifacts(source) +rootfd=os.open(root,DIR_FLAGS) +rename=m._rename_noreplace +commit=ProjectStore.commit +def kill_rename(*args): + if window=='before_rename':os._exit(77) + rename(*args) + if window=='after_rename':os._exit(77) +def kill_commit(*args,**kwargs): + value=commit(*args,**kwargs) + if window=='after_checkpoint':os._exit(77) + return value +m._rename_noreplace=kill_rename +ProjectStore.commit=kill_commit +def build(snapshot,dest): + return {'records':copy.deepcopy(snapshot['records']),'mapping':[],'archived':[], + 'artifacts':[{'declaration':item['document'],'preservation':'unique'} + for item in snapshot['records'] if item['family']=='project_artifact']} +def validate(records,project): + m.decode_fork(records,project_id=project);decode_records(records) +m.publish_fork(source,artifacts,rootfd,'fork',root_path=root,name='Independent copy', + expected_checkpoint=source.snapshot()['checkpoint'],request_key='fork:one',quiescent=True, + build_candidate=build,validate_candidate=validate) +raise AssertionError('Fault window was not reached') +''' + for window in ('after_checkpoint', 'before_rename', 'after_rename'): + with self.subTest(window=window): + root = self.path / ('kill-' + window) + root.mkdir() + shutil.copytree(self.source_path, root / 'source') + child = subprocess.run([sys.executable, '-c', code, str(root), window], + cwd=Path(fork_module.__file__).parent, capture_output=True, text=True, timeout=15) + self.assertEqual(child.returncode, 77, child.stderr) + self.assertEqual((root / 'fork').exists(), window == 'after_rename') + with ProjectStore.open(root / 'source') as source, ProjectArtifacts(source) as artifacts: + self.assertEqual(source.snapshot(), self.original) + if window == 'after_rename': + rootfd = os.open(root, DIR_FLAGS) + try: + result = publish_fork(source, artifacts, rootfd, 'fork', root_path=root, + name='Independent copy', expected_checkpoint=self.original['checkpoint'], + request_key='fork:one', quiescent=True, + build_candidate=lambda *_: self.fail('Never repeat an already published fork'), + validate_candidate=validate) + self.assertTrue(result['reconciled']) + finally: + os.close(rootfd) + else: + stages = list(root.glob('.tuldok-fork-*')) + self.assertEqual(len(stages), 1) + with ProjectStore.open(stages[0]) as retained: + overlay = decode_fork(retained.snapshot()['records']) + self.assertEqual(overlay['source']['checkpoint'], self.original['checkpoint']) + self.assertEqual(next(item for item in retained.snapshot()['records'] + if item['family']=='model_material'), self.record) + + def test_current_overlay_filters_receipts_and_original_editables_without_rewriting_history(self): + source_id = 'a' * 32 + new_id = 'b' * 32 + # Structural mapping tests stay at this codec boundary; semantic data + # origin construction is owned and tested by ProjectData's fork codec. + originals = [dict(family='workbench_text', id=source_id, document={'format': 'workbench_text_v1'}), + dict(family='project_operation', id='c' * 64, document={'format': 'project_operation_v1'})] + destination = dict(family='workbench_record', id=new_id, document={'format': 'workbench_record_v1'}) + identity = 'd' * 32 + source = dict(project_id='e' * 32, checkpoint='f' * 64) + overlay = dict(format='project_fork_v1', source=source, destination_project_id=identity, + request_key='fork:fixture', request_sha256=fork_module._digest(fork_module._input(source, 'copy', 'Copy', 'fork:fixture')), + directory='copy', name='Copy', mapping=[dict(family='workbench_text', source_id=source_id, + source_revision=1, destination_id=new_id)], archived=[dict(family=i['family'], id=i['id']) for i in sorted(originals, key=lambda i:(i['family'],i['id']))], + artifacts=dict(copied=[], missing_regenerable=[])) + current = dict(family='project_fork', id=identity, document=overlay) + records = [*originals, destination, current] + self.assertEqual(decode_fork(records, project_id=identity), overlay) + self.assertEqual(active_records(records, project_id=identity), [destination, current]) + self.assertEqual(records[0], originals[0]) + bad = copy.deepcopy(records) + bad[-1]['document']['mapping'][0]['source_revision'] = True + self.assert_code('invalid', decode_fork, bad) + bad = copy.deepcopy(records) + bad[-1]['document']['mapping'][0]['destination_id'] = source_id + self.assert_code('invalid', decode_fork, bad) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_project_fork_composition.py b/tests/test_project_fork_composition.py new file mode 100644 index 00000000..34c7fb23 --- /dev/null +++ b/tests/test_project_fork_composition.py @@ -0,0 +1,312 @@ +"""Fixed pure fork assembly and conservative payload identity dispositions.""" +import copy +import hashlib +import os +from pathlib import Path +from types import SimpleNamespace +import unittest +from unittest.mock import patch +import uuid + +from project_artifacts import validate_declaration +from project_data import decode_data_records, validate_operation_result as data_result +from project_files import ProjectFiles, decode_records as file_records, validate_operation_result as file_result +from project_fork import (ARCHIVE_FAMILIES, ProjectForkError, _digest, _input, active_records, + decode_fork, publish_fork) +from project_fork_composition import artifact_plan, build_candidate +from project_history import (capture_run, declarations as run_declarations, decode_records as history_records, + validate_operation_result as history_result) +from project_material import (ProjectMaterial, FAMILIES, _edges, _inventory, decode_records as material_records, + validate_operation_result as material_result) +from project_producers import decode_records as producer_records, _referenced_artifacts +from project_session import operation_documents, operation_records +from project_store import ProjectStore, ProjectStoreError, DIR_FLAGS +from workbench import WorkbenchError +import test_project_producers as producer_fixture +import test_project_material as material_fixture +import test_project_material_fork as material_fork_fixture +import test_project_history as history_fixture + +NOW='2026-10-22T12:00:00+00:00' + + +def declaration(sha,size): + return dict(family='project_artifact',id=sha,document=dict(format='project_artifact_v1',sha256=sha,bytes=size)) + + +def runtime_records(catalog, *, shared_sha=None, shared_bytes=1): + from recipe_process import encoded + from runtime_snapshots import descriptor + from pipeline import digest + paths={'python/bin/python3.12':'elf','systemlibs/loader':'elf','python/lib/startup.py':'data', + 'python/lib/dynload/module.so':'elf','packages/numpy/__init__.py':'data'} + files=[dict(path=path,bytes=shared_bytes if path=='packages/numpy/__init__.py' else 1, + sha256=shared_sha if path=='packages/numpy/__init__.py' and shared_sha else hashlib.sha256(path.encode()).hexdigest(), + mode=0o500 if kind=='elf' else 0o400,kind=kind) for path,kind in sorted(paths.items())] + manifest=dict(schema_version=1,profile='captured_cpu_python_v1',python_version='3.12',packages={'numpy':'0','torch':'0'}, + launch=dict(python='python/bin/python3.12',loader='systemlibs/loader',home='python',stdlib='python/lib', + dynload='python/lib/dynload',packages='packages',library_dirs=['systemlibs']), + files=files,total_bytes=sum(row['bytes'] for row in files)) + raw=encoded(manifest);sha=hashlib.sha256(raw).hexdigest() + saved=descriptor(manifest,Path('/inert/original/runtime-snapshots')/sha,catalog) + ref=dict(kind='runtime',catalog_id=catalog,id=digest(saved),sha256=digest(saved)) + inventory=[dict(name='manifest.json',sha256=sha,bytes=len(raw)), + *[dict(name=row['path'],sha256=row['sha256'],bytes=row['bytes']) for row in files]] + record=dict(family='runtime_material',id=catalog+'.'+ref['sha256'],document=dict( + format=FAMILIES['runtime_material'],origin_catalog_id=catalog,value=dict(ref=ref,descriptor=saved,environment=None,manifest=manifest), + artifacts=_edges(inventory,'runtime_material'),audit=[])) + records=[record]+[declaration(item['sha256'],item['bytes']) for item in inventory] + material_records(records) + return records,manifest,raw + + +def add_overlay(candidate,source,destination,key='composed-fork'): + origin=dict(project_id=source['project_id'],checkpoint=source['checkpoint']) + doc=dict(format='project_fork_v1',source=origin,destination_project_id=destination,request_key=key, + directory='fork',name='Composed fork',request_sha256=_digest(_input(origin,'fork','Composed fork',key)), + mapping=copy.deepcopy(candidate['mapping']),archived=copy.deepcopy(candidate['archived']), + artifacts=dict(copied=sorted(item['declaration']['sha256'] for item in candidate['artifacts'] if item['preservation']=='unique'), + missing_regenerable=sorted(item['declaration']['sha256'] for item in candidate['artifacts'] if item['preservation']=='regenerable'))) + return sorted(copy.deepcopy(candidate['records'])+[dict(family='project_fork',id=destination,document=doc)],key=lambda r:(r['family'],r['id'])) + + +class ArtifactDispositionTests(unittest.TestCase): + def setUp(self):self.catalog=uuid.uuid4().hex + + def policies(self,records):return {item['declaration']['sha256']:item['preservation'] for item in artifact_plan(records)} + + def test_runtime_files_only_are_regenerable_and_unknown_orphans_default_unique(self): + records,manifest,raw=runtime_records(self.catalog) + orphan=declaration('a'*64,123);records.append(orphan) + with patch('os.open',side_effect=AssertionError('No filesystem effects')),patch('subprocess.Popen',side_effect=AssertionError('No execution')): + policies=self.policies(records) + self.assertEqual(set(policies),{r['id'] for r in records if r['family']=='project_artifact'}) + self.assertTrue(all(policies[row['sha256']]=='regenerable' for row in manifest['files'])) + self.assertEqual(policies[hashlib.sha256(raw).hexdigest()],'unique') + self.assertEqual(policies[orphan['id']],'unique') + + def test_same_bytes_used_as_model_policy_promote_runtime_alias_to_unique(self): + value,_=material_fixture.model(self.catalog);blob=value['material']['specification'] + records,_,_=runtime_records(self.catalog,shared_sha=blob['sha256'],shared_bytes=blob['bytes']) + model=dict(family='model_material',id=self.catalog+'.'+value['ref']['sha256'],document=dict( + format=FAMILIES['model_material'],origin_catalog_id=self.catalog,value=value, + artifacts=_edges(_inventory('model_material',value),'model_material'),audit=[])) + combined={(r['family'],r['id']):r for r in records} + combined[(model['family'],model['id'])]=model + for item in _inventory('model_material',value):combined[('project_artifact',item['sha256'])]=declaration(item['sha256'],item['bytes']) + self.assertEqual(self.policies(list(combined.values()))[blob['sha256']],'unique') + + def test_data_images_bundles_and_releases_are_unique_even_with_weaker_edge_label(self): + records,manifest,_=runtime_records(self.catalog);sha=manifest['files'][0]['sha256'] + def edge(kind,role):return dict(artifact=dict(namespace=self.catalog,sha256=sha),media_type='application/octet-stream', + kind=kind,role=role,preservation='regenerable',required_for=['inspect','release']) + sources=[dict(kind='image',original=edge('image','original'),oriented=edge('image','oriented')), + dict(kind='sequence',asset=edge('sequence','original')), + dict(kind='audio',asset=edge('audio','original'))] + for source in sources: + item=dict(family='workbench_record',id=uuid.uuid4().hex,document=dict(source=source)) + self.assertEqual(self.policies(records+[item])[sha],'unique') + item=dict(family='dataset_release',id=sha,document=dict(asset=edge('release','frozen_archive'))) + self.assertEqual(self.policies(records+[item])[sha],'unique') + + def test_producer_entry_history_and_prompt_batches_cannot_weaken_runtime_alias(self): + records,manifest,_=runtime_records(self.catalog);sha=manifest['files'][0]['sha256'] + edge=dict(artifact=dict(namespace=self.catalog,sha256=sha)) + for location in ('job','entry','history','prompt'): + doc=dict(payloads={},entries=[],prompt_batches=[]) + if location=='job':doc['payloads']={'raw_response':edge} + if location=='entry':doc['entries']=[dict(payloads={'result_request':edge})] + if location=='history':doc['entries']=[dict(payloads={},history_payloads=[{'attempt_request':edge}])] + if location=='prompt':doc['prompt_batches']=[dict(payloads={'canonical_request':edge})] + with self.subTest(location=location): + item=dict(family='generation_history',id=self.catalog+'.'+uuid.uuid4().hex,document=doc) + self.assertEqual(self.policies(records+[item])[sha],'unique') + + def test_run_payload_publication_and_host_source_are_unique(self): + records,manifest,_=runtime_records(self.catalog);blob={k:manifest['files'][0][k] for k in ('sha256','bytes')} + for location in ('payload','publication','host'): + doc=dict(payloads=[],publication=None,run={}) + if location=='payload':doc['payloads']=[dict(name='weights.bin',blob=blob,preservation='regenerable')] + if location=='publication':doc['publication']=dict(source=blob,result=None) + if location=='host':doc['run']=dict(execution_kind='external_recipe',material=dict(host_files={'recipe_process.py':blob['sha256']})) + item=dict(family='run_history',id=self.catalog+'.'+uuid.uuid4().hex,document=doc) + with self.subTest(location=location):self.assertEqual(self.policies(records+[item])[blob['sha256']],'unique') + + def test_missing_unique_host_hash_is_explicit_without_invented_size(self): + missing='e'*64 + record=dict(family='run_history',id=self.catalog+'.'+uuid.uuid4().hex,document=dict(payloads=[],publication=None, + run=dict(execution_kind='external_recipe',material=dict(host_files={'recipe_process.py':missing})))) + with self.assertRaises(ProjectForkError) as caught:artifact_plan([record]) + self.assertEqual(caught.exception.code,'missing_unique') + self.assertEqual(caught.exception.missing_unique,[dict(family='run_history',id=record['id'],role='host_source',name='recipe_process.py',sha256=missing,bytes=None)]) + + def test_native_measured_bytes_win_over_regenerable_runtime_alias_and_missing_declaration_blocks(self): + sha=hashlib.sha256(b'').hexdigest() + records,_,_=runtime_records(self.catalog,shared_sha=sha,shared_bytes=0) + self.assertEqual(self.policies(records)[sha],'regenerable') + for name,role in (('stdout.log','measured_stream'),('receipt.json','custody_proof'), + ('source/text_training.py','execution_source'),('output/weights.bin','measured_output')): + phase=dict(family='native_execution_history',id=self.catalog+'.'+uuid.uuid4().hex, + document=dict(payloads=[dict(name=name,role=role,blob=dict(sha256=sha,bytes=0), + preservation='unique',required_for=['execution_proof'])])) + with self.subTest(name=name): + self.assertEqual(self.policies(records+[phase])[sha],'unique') + with self.assertRaises(ProjectForkError) as caught: + artifact_plan([record for record in records if record['id']!=sha]+[phase]) + self.assertEqual(caught.exception.code,'missing_unique') + self.assertEqual(caught.exception.missing_unique,[dict(family=phase['family'],id=phase['id'], + role=role,name=name,sha256=sha,bytes=0)]) + + def test_intrinsic_conflicts_and_missing_known_owner_refs_refuse(self): + records,manifest,_=runtime_records(self.catalog) + with self.assertRaises((WorkbenchError,ProjectStoreError)):artifact_plan(records+[copy.deepcopy(records[-1])]) + bad=copy.deepcopy(records);bad[-1]['document']['bytes']+=1 + with self.assertRaises((WorkbenchError,ProjectStoreError)):artifact_plan(bad) + record=dict(family='workbench_record',id=uuid.uuid4().hex,document=dict(source=dict(kind='audio',asset=dict( + role='original',artifact=dict(sha256='f'*64))))) + with self.assertRaises(ProjectForkError) as caught:artifact_plan(records+[record]) + self.assertEqual(caught.exception.code,'missing_unique') + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'),'Requires disposable PostgreSQL') +class ForkCompositionTests(unittest.TestCase): + def setUp(self): + self.fixture=producer_fixture.ProjectProducerTests('test_grounded_source_class_quotes_and_exact_response_are_one_closed_history') + self.fixture.setUp();self.addCleanup(self.fixture.doCleanups) + f=self.fixture + self.catalog=f.session.snapshot['project_id'];f.session._validate=self.validate + self.material=ProjectMaterial(f.session);self.files=ProjectFiles(f.session) + + def validate(self,records): + decode_data_records(records);material_records(records);file_records(records);producer_records(records) + history_records(records,materials=ProjectMaterial(SimpleNamespace(snapshot=dict(records=records),check=lambda:None))) + for receipt in operation_documents(records): + operation,result=receipt['operation'],receipt['result'] + if not (data_result(operation,result,records) or file_result(operation,result,records) + or material_result(operation,result,records) or history_result(operation,result,records)): + raise WorkbenchError('Unowned fixture receipt.') + + def commit(self,upserts): + f=self.fixture;merged={(r['family'],r['id']):r for r in f.session.snapshot['records']} + merged.update({(r['family'],r['id']):r for r in upserts}) + records=[merged[key] for key in sorted(merged)];self.validate(records) + snapshot=f.store.commit(records,f.checkpoint(),uuid.uuid4().hex,'c'*64);f.session.adopt(snapshot) + return snapshot + + def seed_material_and_files(self): + f=self.fixture;pure=material_fork_fixture.MaterialForkCodecs();pure.setUp();known=pure.known() + known['task']['catalog_id']=self.catalog + record=self.material.capture_definition('model_definition',known,f.session.snapshot['records'],action='models.create') + bound=self.material.capture_definition('recipe_definition',material_fork_fixture.recipe(self.catalog),f.session.snapshot['records'],action='recipes.create') + graph=self.material.capture_definition('pipeline_graph',material_fork_fixture.graph(),f.session.snapshot['records'],action='pipeline.create') + self.commit([record,bound,graph]) + target=f.store.path/'notes/decision.md';target.parent.mkdir();raw='Exact scientific note cafe\u0301 😀\r\n'.encode();target.write_bytes(raw) + self.files.adopt(dict(kind='note',path='notes/decision.md',expected_revision=0,expected_sha256=hashlib.sha256(raw).hexdigest()),f.checkpoint(),'adopt-note') + return record,raw + + def make(self,snapshot=None): + snapshot=snapshot or self.fixture.session.snapshot;destination=uuid.uuid4().hex + candidate=build_candidate(snapshot,destination,created_at=NOW,id_factory=lambda *key:uuid.uuid4().hex) + combined=add_overlay(candidate,snapshot,destination) + self.validate(combined);decode_fork(combined,project_id=destination) + return candidate,combined,destination + + def test_heterogeneous_real_owner_fixture_preserves_all_originals_and_fresh_fixed_drafts(self): + f=self.fixture;model,note=self.seed_material_and_files() + proposal=f.proposal();f.seed('text_classification_proposals',proposal) + self.commit(f.producers.capture_source()) + run=history_fixture.legacy_run(self.catalog);raw=b'Owned frozen input archive fixture' + sha=hashlib.sha256(raw).hexdigest();run.update(release_id=sha,release_sha256=sha,release_bytes=len(raw)) + f.artifacts.publish_bytes(raw,dict(format='project_artifact_v1',sha256=sha,bytes=len(raw))) + historical=capture_run(run,payloads=[]) + self.commit([historical,*run_declarations(historical),history_fixture.study(origin=self.catalog)]) + before=copy.deepcopy(f.session.snapshot) + with patch('os.open',side_effect=AssertionError('Pure composition opens no files')),patch('subprocess.Popen',side_effect=AssertionError('Pure composition launches nothing')): + candidate,combined,destination=self.make(before) + self.assertEqual(f.session.snapshot,before) + indexed={(r['family'],r['id']):r for r in candidate['records']} + self.assertTrue(all(indexed[(r['family'],r['id'])]==r for r in before['records'])) + archive={(r['family'],r['id']) for r in candidate['archived']} + self.assertEqual(archive,{(r['family'],r['id']) for r in before['records'] if r['family'] in ARCHIVE_FAMILIES}) + active=active_records(combined,project_id=destination) + self.assertFalse(operation_records(combined,project_id=destination)) + self.assertEqual({r['family'] for r in active},{'workbench_record','model_definition','pipeline_graph','recipe_definition','project_file', + 'project_artifact','text_proposal_history','run_history','study_history','project_fork'}) + newmodel=next(r for r in active if r['family']=='model_definition') + self.assertNotEqual(newmodel['id'],model['id']);self.assertEqual(newmodel['document']['current']['task']['catalog_id'],destination) + file=next(r for r in active if r['family']=='project_file') + self.assertEqual(file['document']['current']['text'].encode(),note) + self.assertTrue(all(item['preservation']=='unique' for item in candidate['artifacts'])) + self.assertEqual(candidate['records'],sorted(candidate['records'],key=lambda r:(r['family'],r['id']))) + self.assertEqual(candidate['mapping'],sorted(candidate['mapping'],key=lambda m:(m['family'],m['source_id'],m['source_revision'] or 0,m['destination_id']))) + + def test_nested_composition_archives_prior_overlay_and_operations_without_derived_history_growth(self): + self.seed_material_and_files();first,combined,intermediate=self.make() + snapshot=dict(project_id=intermediate,name='First fork',checkpoint='d'*64,records=combined) + second,nested,destination=self.make(snapshot) + old={(r['family'],r['id']):r for r in combined};new={(r['family'],r['id']):r for r in nested} + self.assertTrue(all(new[key]==record for key,record in old.items())) + self.assertIn(dict(family='project_fork',id=intermediate),second['archived']) + self.assertTrue(set((r['family'],r['id']) for r in first['archived'])<=set((r['family'],r['id']) for r in second['archived'])) + first_active=[r for r in active_records(combined,project_id=intermediate) if r['family'] in ('workbench_record','model_definition','recipe_definition','pipeline_graph','project_file')] + latest=[r for r in active_records(nested,project_id=destination) if r['family'] in ('workbench_record','model_definition','recipe_definition','pipeline_graph','project_file')] + self.assertEqual(len(first_active),len(latest)) + self.assertEqual(len(second['mapping']),len(first['mapping'])) + + def test_publication_copies_unique_bytes_while_missing_runtime_files_stay_explicit(self): + f=self.fixture;self.seed_material_and_files();runtime,manifest,raw=runtime_records(self.catalog) + f.artifacts.publish_bytes(raw,dict(format='project_artifact_v1',sha256=hashlib.sha256(raw).hexdigest(),bytes=len(raw))) + self.commit(runtime);source=f.session.snapshot + root=os.open(f.path,DIR_FLAGS);self.addCleanup(os.close,root) + result=publish_fork(f.store,f.artifacts,root,'independent-complete',root_path=f.path,name='Independent complete', + expected_checkpoint=source['checkpoint'],request_key='composition-publish',quiescent=True, + build_candidate=lambda snap,dest:build_candidate(snap,dest,created_at=NOW,id_factory=lambda *key:uuid.uuid4().hex), + validate_candidate=lambda records,identity:self.validate(records)) + self.assertEqual(set(result['fork']['artifacts']['missing_regenerable']),{row['sha256'] for row in manifest['files']}) + self.assertEqual(f.store.snapshot(),source) + from project_artifacts import ProjectArtifacts + with ProjectStore.open(f.path/'independent-complete') as destination,ProjectArtifacts(destination) as artifacts: + for sha in result['fork']['artifacts']['copied']: + declaration=next(r['document'] for r in destination.snapshot()['records'] if r['family']=='project_artifact' and r['id']==sha) + self.assertEqual(artifacts.status(declaration)['state'],'verified') + self.assertEqual(operation_records(destination.snapshot()['records'],project_id=destination.snapshot()['project_id']),{}) + + def test_allocator_collision_invalid_snapshot_and_destination_are_rejected(self): + self.seed_material_and_files();snapshot=self.fixture.session.snapshot + for dest,factory in [(snapshot['project_id'],lambda *key:uuid.uuid4().hex),(uuid.uuid4().hex,lambda *key:self.fixture.text['id']), + (uuid.uuid4().hex,lambda *key:'e'*32)]: + with self.subTest(destination=dest),self.assertRaises((WorkbenchError,ProjectStoreError)): + build_candidate(snapshot,dest,created_at=NOW,id_factory=factory) + bad=copy.deepcopy(snapshot);bad['records'].append(copy.deepcopy(bad['records'][0])) + with self.assertRaises((WorkbenchError,ProjectStoreError)):build_candidate(bad,uuid.uuid4().hex,created_at=NOW,id_factory=lambda *key:uuid.uuid4().hex) + + def test_revision_specific_response_mapping_and_deterministic_allocator_preserve_judgment_history(self): + f=self.fixture;parent=f.text + def change(method,*args):return method(*args,f.checkpoint(),uuid.uuid4().hex) + answers=[] + for completion in ('Exact historical answer','Other exact answer'): + answers.append(change(f.data.save_response,dict(id=uuid.uuid4().hex,prompt_id=parent['id'],revision=0, + parent_revision=1,source_revision=1,completion=completion,review='human_reviewed'))['response']) + judgment=change(f.data.save_preference,dict(id=uuid.uuid4().hex,prompt_id=parent['id'],parent_revision=1,source_revision=1, + left_id=answers[0]['id'],right_id=answers[1]['id'],left_revision=1,right_revision=1,revision=0, + outcome='left',rationale='Exact original comparison',review='human_reviewed'))['judgment'] + change(f.data.save_response,dict(id=answers[0]['id'],prompt_id=parent['id'],revision=1,parent_revision=1, + source_revision=1,completion='Later changed answer',review='human_reviewed')) + change(f.data.create_selection,dict(name='Authored membership',items=[dict(id=parent['id'],revision=1,source_revision=1)])) + source=f.session.snapshot;before=copy.deepcopy(source);destination=uuid.uuid4().hex + def allocator(family,identity,revision):return uuid.uuid5(uuid.UUID(hex=destination),family+'|'+identity+'|'+str(revision)).hex + first=build_candidate(source,destination,created_at=NOW,id_factory=allocator) + second=build_candidate(source,destination,created_at=NOW,id_factory=allocator) + self.assertEqual(first,second);self.assertEqual(source,before) + combined=add_overlay(first,source,destination);self.validate(combined) + versions=[mapping for mapping in first['mapping'] if mapping['family']=='workbench_response' and mapping['source_id']==answers[0]['id']] + self.assertEqual([m['source_revision'] for m in versions],[1,2]) + self.assertEqual(len({m['destination_id'] for m in versions}),2) + active=active_records(combined,project_id=destination) + newjudgment=next(r['document']['current'] for r in active if r['family']=='workbench_preference') + self.assertEqual(newjudgment['rationale'],judgment['rationale']);self.assertEqual(newjudgment['review'],'draft') + selected=next(r['document']['current'] for r in active if r['family']=='workbench_response' and r['id']==newjudgment['left_id']) + self.assertEqual(selected['completion'],'Exact historical answer') + membership=next(r['document']['current']['items'] for r in active if r['family']=='saved_selection') + self.assertNotEqual(membership[0]['id'],parent['id']) + self.assertEqual(membership[0]['id'],newjudgment['prompt_id']) diff --git a/tests/test_project_history.py b/tests/test_project_history.py new file mode 100644 index 00000000..61da134c --- /dev/null +++ b/tests/test_project_history.py @@ -0,0 +1,658 @@ +"""Inert history identity/closure and real PostgreSQL checkpoint boundaries.""" +import copy +import hashlib +import json +import os +from pathlib import Path +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch +import uuid + +sys.path.insert(0,str(Path(__file__).resolve().parents[1])) +from access import Access +from experiments import new_study_document, validate_retained_reservation +from metadata import Metadata, connection_info +from pipeline import adapter_identity_for_graph, digest, execution_digest +from project_artifacts import ProjectArtifacts +from project_history import (FAMILY_FORMATS, HistoryFacts, ProjectHistory, capture_run, decode_records, declarations, envelope, + validate_operation_result, validate_data_exposures) +from project_session import ProjectSession +from project_store import ProjectStore +from workbench import WorkbenchError +from auth_helpers import ExplicitOwnerFixture + +ORIGIN='1'*32 +OTHER='2'*32 +MATERIAL='a'*64 + + +def policy(): + return {'format':'selection_policy_v1','metric_definition_sha256':'b'*64,'checkpoint_rule':'explicit', + 'replicate_reduce':'individual','required_capabilities':[],'numerical_acceptance':'diagnostic','tie_break':'trial_id'} + + +def study(identity='3'*32,origin=ORIGIN,trials=()): + value=new_study_document({'name':'Exact intent λ','limits':dict(attempt_slots=3,admitted_wall_seconds=100, + retained_bytes=10000,peak_staging_bytes=10000),'policy':policy()},catalog_id=origin,study_id=identity,policy_id='4'*32) + value['trial_ids']=list(trials) + return envelope('study_history',origin,identity,dict(format=FAMILY_FORMATS['study_history'],origin_catalog_id=origin, + study=value,memberships=[{'revision':1,'trial_ids':list(trials)}],policies=[{'ref':value['policy'],'policy':policy()}], + reservations=[],operations=[],revisions=[copy.deepcopy(value)],audit=[],revision_history_complete=True)) + + +def trial(identity,study_id,origin=ORIGIN,parent=None,baseline=None): + ref={'kind':'recipe_material','catalog_id':origin,'id':MATERIAL,'sha256':MATERIAL} + value={'format':'trial_v1','id':identity,'study_id':study_id,'material':ref,'replicate':0} + lineage=None + if parent is not None or baseline is not None: + intent={'ancestry':'derived' if parent else 'root','parent':parent,'baseline':baseline,'cohort':'cross-study', + 'hypothesis':'Exact saved scientific intent','change_summary':'One factor', + 'target':{'metric_id':'accuracy','metric_definition_sha256':None,'direction':'maximize','threshold':None, + 'acceptance':'Diagnostics only'}} + lineage={'format':'trial_lineage_v1','trial':{'catalog_id':origin,'trial_id':identity,'material_sha256':MATERIAL}, + 'intent':intent,'actor_id':'5'*32,'recorded_at':'2026-10-22T12:00:00+00:00','timing':'before_admission'} + return envelope('trial_history',origin,identity,dict(format=FAMILY_FORMATS['trial_history'],origin_catalog_id=origin, + trial=value,lineage=lineage)) + + +class Materials: + """Only material lookup is substituted; history/lineage validators are real.""" + def recipe(self,ref,*,verify=False): + if ref!={'kind':'recipe_material','catalog_id':ORIGIN,'id':MATERIAL,'sha256':MATERIAL}: + raise WorkbenchError('Unknown exact fixture material.','unavailable',409) + return {'ref':copy.deepcopy(ref),'material':{'resource_envelope':dict(attempt_slots=1, + admitted_wall_seconds=10,retained_bytes=100,peak_staging_bytes=100)}} + + +def legacy_run(origin=ORIGIN): + graph={'schema_version':1,'mode':'real','id':'6'*32,'revision':1,'name':'Saved graph', + 'recipe_version':1,'nodes':[{'id':'publish','type':'publish','definition_version':1,'parameters':{}}],'edges':[]} + return {'schema_version':1,'mode':'real','id':'7'*32,'dataset_id':origin,'request_key':'old-request-0001', + 'request_sha256':'c'*64,'state':'completed','created_at':'2026-10-22T12:00:00+00:00', + 'started_at':None,'finished_at':'2026-10-22T12:01:00+00:00','graph':graph,'graph_revision':1, + 'graph_sha256':execution_digest(graph),'graph_document_sha256':digest(graph),'parameters':{}, + 'adapter':adapter_identity_for_graph(graph),'artifacts':[],'metrics':None,'attempt':1,'parent_run_id':None, + 'events':[],'error':None,'quality_claim':False,'progress':{'stage':'completed','completed':None,'total':None}, + 'node_attempts':[],'release_id':'d'*64,'release_sha256':'d'*64,'release_bytes':1,'source_records':[], + 'prior_test_exposure':{},'runtime':{},'exposure_events':[],'admission_id':'8'*32, + 'worker':{'pid':12345},'supervisor':{'pid':23456},'worker_command':['never','execute'], + 'python':'/historical/python','process_phase':'finished','release_path':'/historical/release.zip'} + + +class HistoryCodecTests(unittest.TestCase): + def test_fork_preserves_archived_disclosure_observation_without_current_permission(self): + from project_fork import _digest,_input + from project_session import operation_records + fact=dict(format='project_disclosure_v1',origin_catalog_id=ORIGIN,run_id='7'*32,payload_set_sha256='a'*64,population='all', + reason='Original explicit disclosure observation',actor_id='8'*32,occurred_at='2026-10-22T00:00:00+00:00') + key='original-disclosure' + receipt=dict(format='project_operation_v2',project_id=ORIGIN,operation='history.disclose', + target=fact['run_id'],request_key=key,input_sha256='b'*64,result=fact) + original=dict(family='project_operation',id=ORIGIN+'.'+hashlib.sha256(key.encode()).hexdigest(),document=receipt) + source=dict(project_id=ORIGIN,checkpoint='c'*64) + fork=dict(format='project_fork_v1',source=source,destination_project_id=OTHER, + request_key='fork-disclosure',request_sha256=_digest(_input(source,'copy','Copy','fork-disclosure')), + directory='copy',name='Copy',mapping=[],archived=[dict(family=original['family'],id=original['id'])], + artifacts=dict(copied=[],missing_regenerable=[])) + records=[original,dict(family='project_fork',id=OTHER,document=fork)] + calls=[] + session=SimpleNamespace(snapshot=dict(records=records,project_id=OTHER),artifacts=None, + access=SimpleNamespace(require=lambda *args,**kwargs:calls.append(args))) + history=ProjectHistory(session,materials=Materials()) + self.assertEqual(operation_records(records),{}) + self.assertEqual(history.disclosure_facts(fact['run_id'],origin_catalog_id=ORIGIN),[fact]) + self.assertEqual(history.disclosure_facts(fact['run_id'],origin_catalog_id=OTHER),[]) + self.assertEqual(calls[0],('inspect','runs.get',fact['run_id'])) + with self.assertRaisesRegex(WorkbenchError,'Current explicit disclosure'): + history._disclose(legacy_run(),'test') + + def test_cross_study_parent_baseline_and_origin_namespace_remain_exact(self): + parent_id='9'*32;child_id='a'*32 + parent_ref={'catalog_id':ORIGIN,'trial_id':parent_id,'material_sha256':MATERIAL} + records=[study('3'*32,trials=[parent_id]),study('b'*32,trials=[child_id]), + trial(parent_id,'3'*32),trial(child_id,'b'*32,parent=parent_ref,baseline=parent_ref)] + before=copy.deepcopy(records) + with patch('app.Dataset',side_effect=AssertionError('Inert decode constructed Dataset')), \ + patch('runs.Runs',side_effect=AssertionError('Inert decode constructed Runs')), \ + patch('subprocess.Popen',side_effect=AssertionError('Inert decode launched process')): + facts=decode_records(records,materials=Materials()) + self.assertEqual(facts.trials[(ORIGIN,child_id)]['lineage']['intent']['parent'],parent_ref) + self.assertEqual(records,before) + changed=copy.deepcopy(records);changed[-1]['document']['lineage']['intent']['baseline']['catalog_id']=OTHER + with self.assertRaises(WorkbenchError):decode_records(changed,materials=Materials()) + + def test_closure_omissions_cycles_and_history_completeness_are_rejected(self): + parent_id='9'*32;child_id='a'*32 + ref=lambda identity:{'catalog_id':ORIGIN,'trial_id':identity,'material_sha256':MATERIAL} + records=[study(trials=[parent_id,child_id]),trial(parent_id,'3'*32,parent=ref(child_id)), + trial(child_id,'3'*32,parent=ref(parent_id))] + with self.assertRaisesRegex(WorkbenchError,'cycle'):decode_records(records,materials=Materials()) + with self.assertRaises(WorkbenchError):decode_records(records[:-1],materials=Materials()) + value=study();value['document']['study']['revision']=2;value['document']['revisions']=[copy.deepcopy(value['document']['study'])] + with self.assertRaisesRegex(WorkbenchError,'revision gap'):decode_records([value]) + value['document']['revision_history_complete']=False + value['document']['audit']=[{'actor_id':None,'action':'studies.reconcile','target':value['document']['study']['id'], + 'details':{'state':'unknown'},'result':'interrupted','occurred_at':'2026-10-22T12:00:00+00:00'}] + self.assertFalse(decode_records([value]).studies[(ORIGIN,'3'*32)]['revision_history_complete']) + + def test_inert_run_excludes_controls_and_preserves_exact_historical_hashes(self): + run=legacy_run();saved=capture_run(run,payloads=[]) + self.assertEqual(saved['document']['run']['graph_sha256'],run['graph_sha256']) + self.assertNotIn('worker',saved['document']['run']);self.assertNotIn('worker_command',saved['document']['run']) + with patch('runs.process_status',side_effect=AssertionError('No PID observation')): + result=decode_records([saved,*declarations(saved)]) + self.assertEqual(result.runs[(ORIGIN,run['id'])]['run']['dataset_id'],ORIGIN) + changed=copy.deepcopy(saved);changed['document']['run']['worker']={'pid':1} + with self.assertRaises(WorkbenchError):decode_records([changed,*declarations(changed)]) + with self.assertRaises(WorkbenchError):capture_run(dict(run,new_unowned_field={}),payloads=[]) + + def test_missing_payload_is_declared_and_malformed_or_control_records_refuse(self): + raw=b'Unique observation\x00\xff';sha=hashlib.sha256(raw).hexdigest() + run=legacy_run();run['artifacts']=[{'name':'test-metrics.json','sha256':sha,'bytes':len(raw),'media_type':'application/json','split':'test'}] + edge={'name':'test-metrics.json','blob':{'sha256':sha,'bytes':len(raw)},'population':'test','role':'model_evidence', + 'preservation':'unique','required_for':['scientific_inspection']} + record=capture_run(run,payloads=[edge]) + declaration={'family':'project_artifact','id':sha,'document':{'format':'project_artifact_v1','sha256':sha,'bytes':len(raw)}} + self.assertEqual(len(decode_records([record,*declarations(record)]).runs),1) + with self.assertRaisesRegex(WorkbenchError,'declaration'):decode_records([record]) + for change in ({'credential':{'token':'secret'}},{'worker':None}): + changed=copy.deepcopy(record);changed['document'].update(change) + with self.assertRaises(WorkbenchError):decode_records([changed,*declarations(record)]) + + def test_operation_results_bind_historical_revision_and_reject_plausible_tamper(self): + value=study();original=copy.deepcopy(value['document']['study']) + changed=copy.deepcopy(original);changed.update(revision=2,name='Later authored name') + value['document']['study']=changed;value['document']['revisions'].append(changed) + self.assertTrue(validate_operation_result('studies.create',original,[value])) + self.assertTrue(validate_operation_result('studies.save',changed,[value])) + forged=copy.deepcopy(original);forged['name']='Plausible forged success' + self.assertTrue(validate_operation_result('studies.create',forged)) + with self.assertRaisesRegex(WorkbenchError,'retained revision'): + validate_operation_result('studies.create',forged,[value]) + parent=trial('9'*32,'3'*32);result=copy.deepcopy(parent['document']['trial']) + self.assertTrue(validate_operation_result('studies.prepare_trial',result,[parent])) + result['replicate']=1 + with self.assertRaisesRegex(WorkbenchError,'retained Trial'): + validate_operation_result('studies.prepare_trial',result,[parent]) + + def test_exposure_binds_exact_run_release_test_source_and_saved_observation(self): + run=legacy_run();run['source_records']=[{'id':'source-1','kind':'text','split':'test','content_hash':'a'*64}] + run['exposure_events']=[{'id':'e'*32,'reason':'Exact comparison','created_at':'2026-10-22T12:00:00+00:00'}] + facts=HistoryFacts();facts.runs[(ORIGIN,run['id'])]={'run':run} + exposure={'family':'dataset_exposure','id':'f'*32,'document':{'format':'dataset_exposure_v1','id':'f'*32, + 'record_id':'source-1','source_hash':'a'*64,'reason':'Exact comparison','run_id':run['id'], + 'release_id':run['release_id'],'created_at':'2026-10-22T12:00:00+00:00'}} + self.assertTrue(validate_data_exposures([exposure],facts)) + for key,value in (('run_id','8'*32),('release_id','b'*64),('source_hash','c'*64),('reason','Different reason')): + forged=copy.deepcopy(exposure);forged['document'][key]=value + with self.subTest(key=key),self.assertRaises(WorkbenchError):validate_data_exposures([forged],facts) + facts.runs[(OTHER,run['id'])]={'run':copy.deepcopy(run)} + with self.assertRaisesRegex(WorkbenchError,'unique'):validate_data_exposures([exposure],facts) + + def test_unclassified_audit_and_admission_receipt_cannot_hide_credentials(self): + value=study() + value['document']['audit']=[{'actor_id':'5'*32,'action':'access.issue','target':'6'*32, + 'details':{'token':'secret','token_sha256':'a'*64},'result':'success','occurred_at':'2026-10-22T12:00:00+00:00'}] + with self.assertRaisesRegex(WorkbenchError,'Unclassified'):decode_records([value]) + value=study();value['document']['operations']=[{'actor_id':'5'*32,'operation_id':'studies.admit', + 'request_key':'admission-0001','request_sha256':'a'*64,'result':{'token':'secret'}}] + with self.assertRaises(WorkbenchError):decode_records([value]) + value=study();value['document']['audit']=[{'actor_id':None,'action':'studies.reconcile','target':'3'*32, + 'details':{'state':'unknown','token':'secret'},'result':'interrupted','occurred_at':'2026-10-22T12:00:00+00:00'}] + with self.assertRaisesRegex(WorkbenchError,'Unclassified'):decode_records([value]) + + def test_admission_receipt_with_valid_reservation_and_nested_token_is_explicitly_unsupported(self): + from project_history import _approval + trial_value=trial('9'*32,'3'*32)['document']['trial'] + charge=Materials().recipe(trial_value['material'])['material']['resource_envelope'] + reservation={'id':'e'*32,'study_id':'3'*32,'admission_epoch':1,'request_key':'admission-0001','actor_id':'5'*32, + 'trial_id':trial_value['id'],'parent_run_id':None,'run_id':'7'*32,'request_sha256':'c'*64, + 'material_sha256':MATERIAL,'envelope':charge,'state':'settled','settled':copy.deepcopy(charge)} + run={'id':'7'*32,'actor_id':'5'*32,'request_sha256':'c'*64,'parent_run_id':None,'approval_material_sha256':MATERIAL} + validate_retained_reservation(reservation,trial=trial_value,composition=Materials().recipe(trial_value['material']),run=run) + fake={'reservation':reservation,'run':{'id':run['id'],'dataset_id':ORIGIN,'artifacts':[{'token':'secret'}]}} + with self.assertRaisesRegex(WorkbenchError,'unsupported') as error: + validate_operation_result('studies.admit',fake) + self.assertEqual(error.exception.code,'unsupported') + native=legacy_run();expected={'pipeline.inspect_release':[native['release_id']], + 'dataset_releases.locate':[native['release_id']],'pipeline.probe':['image_classification'], + 'pipeline.probe_native':['image_classification'],'pipeline.definition_identity':['image_classification'], + 'pipeline.exposures':[None],'runs.update':[native['id']]} + admission={'id':native['admission_id'],'catalog_id':ORIGIN,'actor_id':'5'*32,'credential_id':'6'*32, + 'kind':'runs','job_id':native['id'],'request_digest':native['request_sha256'],'effects':expected,'created_at':1} + proof={'grant':None,'consumption':None,'admission':admission} + self.assertIsNone(_approval(proof,native)) + bad=copy.deepcopy(proof);bad['admission']['effects']['unknown.authority']=['secret'] + with self.assertRaisesRegex(WorkbenchError,'Unclassified historical admission effects'):_approval(bad,native) + external=dict(native,execution_kind='external_recipe') + proof['admission']['effects']={'runs.update':[native['id']]} + self.assertIsNone(_approval(proof,external)) + proof['admission']['effects']['runs.update']=['9'*32] + with self.assertRaises(WorkbenchError):_approval(proof,external) + + def test_retained_receipt_and_inventory_codec_never_uses_live_runs_class(self): + import base64 + from test_builtin_artifact_sources import BuiltinArtifactSourceTests + from runs import artifact_capture_source,decode_diagnostic_json + raw,expected=BuiltinArtifactSourceTests().source() + run={'id':expected['run_id'],'dataset_id':expected['catalog_id'],'state':'completed', + 'request_sha256':expected['request_sha256'],'graph':{'recipe':'image_classification_model_v2'}, + 'graph_sha256':expected['graph_sha256'],'release_sha256':expected['release_sha256'], + 'evidence_context':{'material_sha256':expected['material_sha256'],'adapter_sha256':expected['adapter_sha256']}} + item={'name':expected['artifact_name'],'sha256':expected['measured_sha256'],'bytes':len(raw),'split':None} + proof=base64.b64decode(expected['artifact_manifest_base64']) + with patch('runs.Runs',side_effect=AssertionError('No live Runs dependency')): + self.assertEqual(artifact_capture_source(run,item,raw,proof),expected) + self.assertEqual(decode_diagnostic_json(b'{"exact":1}'),{'exact':1}) + for malformed in (b'{"exact":1,"exact":2}',b'{"exact":NaN}'): + with self.assertRaises(ValueError):decode_diagnostic_json(malformed) + with self.assertRaises(WorkbenchError):artifact_capture_source(run,item,raw+b'x',proof) + + def test_settled_charge_cannot_be_lowered_or_disconnected_from_run(self): + trial_value=trial('9'*32,'3'*32)['document']['trial'];material=Materials().recipe(trial_value['material']) + charge=material['material']['resource_envelope'] + value={'id':'e'*32,'study_id':'3'*32,'admission_epoch':1,'request_key':'reservation-0001','actor_id':'5'*32, + 'trial_id':trial_value['id'],'parent_run_id':None,'run_id':'7'*32,'request_sha256':'c'*64, + 'material_sha256':MATERIAL,'envelope':charge,'state':'settled','settled':copy.deepcopy(charge)} + run={'id':'7'*32,'actor_id':'5'*32,'request_sha256':'c'*64,'parent_run_id':None,'approval_material_sha256':MATERIAL} + self.assertEqual(validate_retained_reservation(value,trial=trial_value,composition=material,run=run),value) + changed=copy.deepcopy(value);changed['settled']['retained_bytes']=1 + with self.assertRaises(WorkbenchError):validate_retained_reservation(changed,trial=trial_value,composition=material,run=run) + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'),'Requires disposable PostgreSQL') +class ProjectHistoryPostgresTests(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory(prefix='tuldok-history-');self.addCleanup(self.tmp.cleanup) + self.path=Path(self.tmp.name);self.databases=[];self.stores=[];self.artifact_owners=[] + self.addCleanup(self.cleanup) + self.store=ProjectStore.create(self.path/'project','Scientific history','new','f'*64);self.stores.append(self.store) + self.start() + + def cleanup(self): + import psycopg + from psycopg import sql + for artifacts in self.artifact_owners:artifacts.close() + for store in self.stores:store.close() + schemas=[] + for db in self.databases: + manifest=json.loads((db.path/'.tuldok.json').read_bytes()) + schemas.append('projection_'+manifest['projection_id'] if manifest['version']==2 else 'catalog_'+db.dataset_id) + db.close() + with psycopg.connect(connection_info(),autocommit=True) as connection: + for schema in schemas:connection.execute(sql.SQL('DROP SCHEMA IF EXISTS {} CASCADE').format(sql.Identifier(schema))) + + def start(self): + db=Metadata(self.path/('host-'+uuid.uuid4().hex),dsn=os.environ['TULDOK_DATABASE_URL']);self.databases.append(db) + host=SimpleNamespace(db=db,access=Access(db,db.path));owner=ExplicitOwnerFixture.bootstrap(host) + artifacts=ProjectArtifacts(self.store);self.artifact_owners.append(artifacts) + session=ProjectSession(self.store,db,host.access,artifacts,validate_records=lambda records:decode_records(records,materials=Materials())) + self.host,self.owner,self.session=host,owner,session + self.history=ProjectHistory(session,materials=Materials()) + + def test_metadata_edits_cold_reopen_keep_history_without_active_authority(self): + with self.owner.bound(self.host): + before=self.session.snapshot['checkpoint'] + body={'name':'Unique hypothesis λ','limits':dict(attempt_slots=3,admitted_wall_seconds=100,retained_bytes=10000, + peak_staging_bytes=10000),'policy':policy()} + created=self.history.create_study(body,before,'create-0001') + updated=self.history.save_study(created['id'],dict(revision=1,name='Revised intent',limits=created['limits'],policy=created['policy']), + self.session.snapshot['checkpoint'],'save-0001') + self.assertEqual(updated['revision'],2) + self.assertEqual(self.history.create_study(body,before,'create-0001'),created) + snapshot=self.session.snapshot + self.artifact_owners[-1].close();self.store.close() + self.store=ProjectStore.open(self.path/'project');self.stores.append(self.store) + with patch('app.Dataset',side_effect=AssertionError('No Dataset on cold history')), \ + patch('runs.Runs',side_effect=AssertionError('No recovery owner')): + self.start() + with self.owner.bound(self.host): + self.assertEqual(self.session.snapshot,snapshot) + state=self.history.study(created['id']) + self.assertEqual(state['study'],updated) + self.assertTrue(state['revision_history_complete']) + self.assertEqual(self.store.save(snapshot['checkpoint']),snapshot) + for table in ('workspace_runs','access_execution_grants','access_execution_consumptions','access_admissions','model_operation_receipts'): + self.assertEqual(self.host.db.execute('SELECT count(*) FROM '+table).fetchone()[0],0) + + def test_trial_and_cross_study_lineage_checkpoint_preserve_previous_revision(self): + class OwnedMaterial: + def __init__(self,origin):self.origin=origin + def recipe(self,ref,*,verify=False): + self.ref=ref + if ref!={'kind':'recipe_material','catalog_id':self.origin,'id':MATERIAL,'sha256':MATERIAL}: + raise WorkbenchError('Material differs.') + return {'ref':copy.deepcopy(ref),'material':{'resource_envelope':dict(attempt_slots=1, + admitted_wall_seconds=10,retained_bytes=100,peak_staging_bytes=100)}} + origin=self.session.snapshot['project_id'];material=OwnedMaterial(origin) + self.session._validate=lambda records:decode_records(records,materials=material) + self.history=ProjectHistory(self.session,materials=material) + body={'name':'Parent Study','limits':dict(attempt_slots=3,admitted_wall_seconds=100,retained_bytes=10000, + peak_staging_bytes=10000),'policy':policy()} + with self.owner.bound(self.host): + parent_study=self.history.create_study(body,self.session.snapshot['checkpoint'],'parent-study') + child_study=self.history.create_study(dict(body,name='Separate comparison Study'),self.session.snapshot['checkpoint'],'child-study') + ref={'kind':'recipe_material','catalog_id':origin,'id':MATERIAL,'sha256':MATERIAL} + parent=self.history.prepare_trial_from_material(parent_study['id'],{'revision':1,'material':ref,'replicate':0}, + self.session.snapshot['checkpoint'],'parent-trial') + child=self.history.prepare_trial_from_material(child_study['id'],{'revision':1,'material':ref,'replicate':0}, + self.session.snapshot['checkpoint'],'child-trial') + parent_ref={'catalog_id':origin,'trial_id':parent['id'],'material_sha256':MATERIAL} + child_ref={'catalog_id':origin,'trial_id':child['id'],'material_sha256':MATERIAL} + intended=trial('a'*32,'b'*32,parent=parent_ref,baseline=parent_ref)['document']['lineage']['intent'] + value=self.history.record_lineage({'trial':child_ref,'intent':intended},self.session.snapshot['checkpoint'],'lineage-child') + self.assertEqual(value['intent']['parent'],parent_ref) + self.assertEqual(value['intent']['baseline'],parent_ref) + before=self.session.snapshot + cyclic=dict(intended,parent=child_ref,baseline=None) + with self.assertRaisesRegex(WorkbenchError,'cycle'): + self.history.record_lineage({'trial':parent_ref,'intent':cyclic},before['checkpoint'],'lineage-cycle') + self.assertEqual(self.session.snapshot,before) + self.assertEqual(self.history.study(child_study['id'])['study']['revision'],2) + self.assertEqual(self.history.trial(child['id']),child) + for table in ('model_trials','model_studies','workspace_runs','access_admissions'): + self.assertEqual(self.host.db.execute('SELECT count(*) FROM '+table).fetchone()[0],0) + + def test_missing_unique_payload_and_fresh_protected_disclosure_are_separate(self): + raw=b'Unique protected observation\x00\xff';sha=hashlib.sha256(raw).hexdigest() + declaration={'format':'project_artifact_v1','sha256':sha,'bytes':len(raw)} + run=legacy_run();run['artifacts']=[{'name':'test-metrics.json','sha256':sha,'bytes':len(raw),'media_type':'application/json','split':'test'}] + edge={'name':'test-metrics.json','blob':{'sha256':sha,'bytes':len(raw)},'population':'test','role':'model_evidence', + 'preservation':'unique','required_for':['inspection']} + record=capture_run(run,payloads=[edge]);records=[record,*declarations(record)] + self.store.commit(records,self.store.snapshot()['checkpoint'],'fixture-history','e'*64) + self.session.adopt(self.store.snapshot()) + with self.owner.bound(self.host): + self.assertEqual(self.history.run(run['id'],origin_catalog_id=ORIGIN)['payloads'][0]['availability']['state'],'missing') + with self.assertRaisesRegex(WorkbenchError,'disclosure'):self.history.artifact(run['id'],'test-metrics.json',origin_catalog_id=ORIGIN) + self.session.artifacts.publish_bytes(raw,declaration) + # A malicious portable label cannot turn a protected artifact into + # present authority. Current test filename/owner split still wins. + forged=copy.deepcopy(records);forged[0]['document']['payloads'][0]['population']='validation' + self.store.commit(forged,self.store.snapshot()['checkpoint'],'forged-label','d'*64) + self.session.adopt(self.store.snapshot()) + with self.assertRaisesRegex(WorkbenchError,'disclosure'):self.history.artifact(run['id'],'test-metrics.json',origin_catalog_id=ORIGIN) + calls=[];self.history.authorize_disclosure=lambda origin_run,population:calls.append((origin_run['dataset_id'],population)) + self.assertEqual(self.history.artifact(run['id'],'test-metrics.json',origin_catalog_id=ORIGIN),raw) + self.assertEqual(calls,[(ORIGIN,'test'),(ORIGIN,'test')]) + self.assertEqual(self.host.db.execute('SELECT count(*) FROM workspace_runs').fetchone()[0],0) + + def test_public_history_hides_frozen_holdout_and_document_segments_need_current_permission(self): + import base64 + from project_store import _encode + run=legacy_run();run['source_records']=[{'id':'frozen-example','kind':'text','split':'test', + 'text':'PRIVATE-HOLDOUT-SENTINEL','target':'PRIVATE-TARGET-SENTINEL'}] + run['evidence_context']={'fixture':'PRIVATE-CONTEXT-SENTINEL'} + record=capture_run(run,payloads=[]);records=[record,*declarations(record)] + self.store.commit(records,self.store.snapshot()['checkpoint'],'private-run-fixture','e'*64) + self.session.adopt(self.store.snapshot()) + with self.owner.bound(self.host): + result=self.history.run(run['id'],origin_catalog_id=ORIGIN,offset=0,limit=1) + self.assertNotIn('PRIVATE-',json.dumps(result)) + self.assertNotIn('source_records',result['run']);self.assertNotIn('evidence_context',result['run']) + descriptor=self.history.run_document_descriptor(run['id'],origin_catalog_id=ORIGIN) + self.assertEqual(descriptor,{'sha256':hashlib.sha256(_encode(record['document'])).hexdigest(), + 'bytes':len(_encode(record['document']))}) + with self.assertRaisesRegex(WorkbenchError,'disclosure'): + self.history.run_document_segment(run['id'],descriptor['sha256'],origin_catalog_id=ORIGIN) + self.history.authorize_disclosure=lambda *_:None + parts=[];offset=0 + while True: + segment=self.history.run_document_segment(run['id'],descriptor['sha256'],offset,limit=97,origin_catalog_id=ORIGIN) + parts.append(base64.b64decode(segment['base64'])) + if segment['next_offset'] is None:break + offset=segment['next_offset'] + self.assertEqual(b''.join(parts),_encode(record['document'])) + with self.assertRaisesRegex(WorkbenchError,'changed'): + self.history.run_document_segment(run['id'],'0'*64,origin_catalog_id=ORIGIN) + # Permission is process-local; a new owner over the same facts has none. + cold=ProjectHistory(self.session,materials=Materials()) + with self.owner.bound(self.host),self.assertRaisesRegex(WorkbenchError,'disclosure'): + cold.run_document_segment(run['id'],descriptor['sha256'],origin_catalog_id=ORIGIN) + + def test_disclosure_fact_is_actor_bound_and_never_restores_current_permission(self): + from access import AccessError + raw=b'Unique holdout observation';sha=hashlib.sha256(raw).hexdigest() + run=legacy_run();run['artifacts']=[{'name':'test-metrics.json','sha256':sha,'bytes':len(raw),'media_type':'application/json','split':'test'}] + edge={'name':'test-metrics.json','blob':{'sha256':sha,'bytes':len(raw)},'population':'test','role':'model_evidence', + 'preservation':'unique','required_for':['inspection']} + archived=capture_run(run,payloads=[edge]);records=[archived,*declarations(archived)] + self.store.commit(records,self.store.snapshot()['checkpoint'],'disclosure-fixture','d'*64) + self.session.adopt(self.store.snapshot()) + with self.owner.bound(self.host): + first=self.host.access.issue({'label':'Current disclosure authority','scopes':['inspect','expose-holdout']}) + second=self.host.access.issue({'label':'Different current actor','scopes':['inspect','expose-holdout']}) + inspect_only=self.host.access.issue({'label':'Inspect only','scopes':['inspect']}) + body={'origin_catalog_id':ORIGIN,'reason':'Compare this exact retained holdout population'} + before=self.session.snapshot['checkpoint'] + with self.host.access.bind(inspect_only['token']),self.assertRaises(AccessError): + self.history.record_disclosure(run['id'],body,before,'disclosure-0001') + with self.host.access.bind(first['token']): + fact=self.history.record_disclosure(run['id'],body,before,'disclosure-0001') + self.assertEqual(fact['actor_id'],first['identity']['actor_id']) + self.assertEqual(fact['payload_set_sha256'],self.history.payload_set_sha256(run['id'],origin_catalog_id=ORIGIN)) + self.assertEqual(self.history.record_disclosure(run['id'],body,before,'disclosure-0001'),fact) + self.assertEqual(self.history.run(run['id'],origin_catalog_id=ORIGIN)['disclosures'],[fact]) + self.assertNotIn('exposure_events',self.history.run(run['id'],origin_catalog_id=ORIGIN)['run']) + with self.host.access.bind(second['token']),self.assertRaises(WorkbenchError): + self.history.record_disclosure(run['id'],body,before,'disclosure-0001') + self.session.artifacts.publish_bytes(raw,{'format':'project_artifact_v1','sha256':sha,'bytes':len(raw)}) + with self.host.access.bind(first['token']),self.assertRaisesRegex(WorkbenchError,'disclosure'): + self.history.artifact(run['id'],'test-metrics.json',origin_catalog_id=ORIGIN) + snapshot=self.session.snapshot + self.artifact_owners[-1].close();self.store.close() + self.store=ProjectStore.open(self.path/'project');self.stores.append(self.store);self.start() + with self.owner.bound(self.host): + self.assertEqual(self.session.snapshot,snapshot) + self.assertEqual(self.history.disclosure_facts(run['id'],origin_catalog_id=ORIGIN),[fact]) + with self.assertRaisesRegex(WorkbenchError,'disclosure'): + self.history.artifact(run['id'],'test-metrics.json',origin_catalog_id=ORIGIN) + + def test_disclosure_snapshot_survives_payload_growth_and_replacement_without_new_permission(self): + from project_fork_composition import artifact_plan + run=legacy_run();run.update(state='running',finished_at=None) + run['progress']['stage']='running' + def artifact(raw,name='test-metrics.json'): + item=dict(name=name,sha256=hashlib.sha256(raw).hexdigest(),bytes=len(raw),media_type='application/json',split='test') + edge=dict(name=name,blob={k:item[k] for k in ('sha256','bytes')},population='test',role='model_evidence', + preservation='unique',required_for=['inspection']) + self.session.artifacts.publish_bytes(raw,dict(format='project_artifact_v1',**edge['blob'])) + return item,edge + old_item,old_edge=artifact(b'{"partial":1}') + run['artifacts']=[old_item] + original=capture_run(run,payloads=[old_edge]) + self.store.commit([original,*declarations(original)],self.store.snapshot()['checkpoint'],'growing-run','d'*64) + self.session.adopt(self.store.snapshot()) + body=dict(origin_catalog_id=ORIGIN,reason='Inspect the exact current observation') + with self.owner.bound(self.host),patch('runs.Runs',side_effect=AssertionError('No Run recovery in disclosure')): + first=self.history.record_disclosure(run['id'],body,self.session.snapshot['checkpoint'],'disclose-running') + self.assertEqual(first['format'],'project_disclosure_v2') + saved=copy.deepcopy(self.session.snapshot['records']) + observation=next(r for r in saved if r['family']=='run_observation') + self.assertEqual(observation['document']['observation'],original['document']) + legacy={k:v for k,v in first.items() if k!='observation_sha256'} + legacy['format']='project_disclosure_v1' + self.assertTrue(validate_operation_result('history.disclose',legacy,saved)) + replacement,replacement_edge=artifact(b'{"complete":2}') + added,added_edge=artifact(b'{"late":3}','late-output.json') + run.update(state='completed',finished_at='2026-10-22T12:01:00+00:00',artifacts=[replacement,added]) + run['progress']['stage']='completed' + grown=capture_run(run,payloads=[replacement_edge,added_edge]) + merged={(r['family'],r['id']):r for r in [*saved,grown,*declarations(grown)]} + candidate=list(merged.values()) + decode_records(candidate,materials=Materials()) + self.assertTrue(validate_operation_result('history.disclose',first,candidate)) + with self.assertRaisesRegex(WorkbenchError,'payload set'): + validate_operation_result('history.disclose',legacy,candidate) + self.store.commit(candidate,self.session.snapshot['checkpoint'],'complete-growing-run','e'*64) + self.session.adopt(self.store.snapshot()) + self.assertEqual(self.history.disclosure_facts(run['id'],origin_catalog_id=ORIGIN),[first]) + second=self.history.record_disclosure(run['id'],body,self.session.snapshot['checkpoint'],'disclose-completed') + self.assertNotEqual(second['observation_sha256'],first['observation_sha256']) + self.assertNotEqual(second['payload_set_sha256'],first['payload_set_sha256']) + # A snapshot is science evidence, never a restored local capability. + cold=ProjectHistory(self.session,materials=Materials()) + with self.assertRaisesRegex(WorkbenchError,'disclosure'): + cold.artifact(run['id'],'test-metrics.json',origin_catalog_id=ORIGIN) + current=self.session.snapshot['records'] + forged=copy.deepcopy(first);forged['payload_set_sha256']='0'*64 + with self.assertRaisesRegex(WorkbenchError,'payload set'): + validate_operation_result('history.disclose',forged,current) + foreign=copy.deepcopy(observation) + foreign['document']['observation']['run']['request_sha256']='9'*64 + from project_store import _encode as canonical + sha=hashlib.sha256(canonical(foreign['document']['observation'])).hexdigest() + foreign['id']=foreign['document']['sha256']=sha + forged=dict(first,observation_sha256=sha) + with self.assertRaisesRegex(WorkbenchError,'immutable identity'): + validate_operation_result('history.disclose',forged,[*current,foreign]) + foreign_payload=copy.deepcopy(observation) + next(edge for edge in foreign_payload['document']['observation']['payloads'] + if edge['name']=='test-metrics.json')['blob']=copy.deepcopy(added_edge['blob']) + sha=hashlib.sha256(canonical(foreign_payload['document']['observation'])).hexdigest() + foreign_payload['id']=foreign_payload['document']['sha256']=sha + # These bytes have a real global declaration, but belong to a + # different output than the observed Run inventory names here. + with self.assertRaisesRegex(WorkbenchError,'Run artifact closure'): + decode_records([*current,foreign_payload],materials=Materials()) + missing=[r for r in current if not (r['family']=='project_artifact' and r['id']==old_item['sha256'])] + with self.assertRaisesRegex(WorkbenchError,'artifact declaration'): + decode_records(missing,materials=Materials()) + # The exact old snapshot bytes stay unique even if its retained + # edge is labelled regenerable; weak labels cannot discard science. + weaker=copy.deepcopy(current) + for record in weaker: + if record['family']=='run_observation': + for edge in record['document']['observation']['payloads']:edge['preservation']='regenerable' + plan=artifact_plan(weaker) + self.assertEqual(next(v['preservation'] for v in plan if v['declaration']['sha256']==old_item['sha256']),'unique') + self.assertEqual(self.host.db.execute('SELECT count(*) FROM workspace_runs').fetchone()[0],0) + + def test_sql_analysis_uses_supplied_origin_json_and_no_active_run_row(self): + from test_retained_analysis import fixture, EVALUATION, RUN + from retained_analysis import query_history + body,evidence=fixture() + run={'id':RUN,'dataset_id':ORIGIN,'state':'completed','artifacts':[{'name':'model-evidence.json','sha256':EVALUATION}], + 'metrics':{'history':[{'epoch':1,'validation':{'accuracy':0.0,'examples':2}}, + {'epoch':2,'validation':{'accuracy':0.5,'examples':2}}]}} + with self.owner.bound(self.host): + result=query_history(self.host.db,run,evidence,body,authorize=self.host.access.require) + self.assertEqual(result['provenance']['catalog_id'],ORIGIN) + self.assertEqual([point['value'] for point in result['curve']],[0.0,0.5]) + self.assertEqual(self.host.db.execute('SELECT count(*) FROM workspace_runs').fetchone()[0],0) + + +from test_model_definitions import CapturedModelCase + + +class ProjectHistoryActualOwnerTests(CapturedModelCase): + """Actual captured composition/admission proof imported into a fresh inert DB.""" + def test_cold_actual_composed_proof_keeps_origin_without_live_authority(self): + from experiments import Experiments + from project_material import ProjectMaterial + import psycopg + from psycopg import sql + self.dataset.models=self.models + studies=Experiments(self.dataset) + with self.owner.bound(self.dataset): + model=self.models.publish(self.models.create(self.model_body)['id'],{'revision':1}) + value=studies.create({'name':'Retained exact source proof','limits':dict(attempt_slots=3, + admitted_wall_seconds=1000,retained_bytes=10**8,peak_staging_bytes=10**8),'policy':policy()}) + prepared=studies.prepare_trial(value['id'],{'request_key':'actual-history-prepare','revision':1, + 'graph_id':self.graph['id'],'graph_revision':self.graph['revision'],'model':model['ref'],'replicate':0}) + value=studies._study(value['id']) + studies.continue_study(value['id'],{'request_key':'actual-history-open','revision':value['revision'],'admission_epoch':0}) + grant=studies.approve(value['id'],{'trial_id':prepared['id'],'expected_material_sha256':prepared['material']['sha256'], + 'consumer_actor_id':self.agent['identity']['actor_id']}) + value=studies._study(value['id']) + with self.dataset.access.bind(self.agent['token']),patch.object(self.dataset.runs,'_start'): + admitted=studies.admit(value['id'],{'request_key':'actual-history-admit','revision':value['revision'], + 'admission_epoch':value['admission_epoch'],'trial_id':prepared['id'],'approval_id':grant['id']}) + with self.owner.bound(self.dataset): + self.dataset.runs.cancel(admitted['run']['id']) + run=copy.deepcopy(self.dataset.runs._load(admitted['run']['id'])) + value=studies._study(value['id']);reservation=studies._reservations(value['id'])[0] + composition=self.dataset.pipelines.recipe_material(prepared['material'],verify=True) + consumption=dict(self.dataset.db.execute('SELECT * FROM access_execution_consumptions WHERE run_id=%s',(run['id'],)).fetchone()) + admission=dict(self.dataset.db.execute('SELECT * FROM access_admissions WHERE id=%s',(run['admission_id'],)).fetchone()) + admission['effects']=json.loads(admission.pop('effects_json')) + memberships=[{'revision':row['revision'],'trial_ids':json.loads(row['document'])['trial_ids']} + for row in self.dataset.db.execute('SELECT revision,document FROM model_study_memberships WHERE study_id=%s ORDER BY revision',(value['id'],))] + root=self.path/'portable-history';store=ProjectStore.create(root,'Actual cold history','new','f'*64) + self.addCleanup(store.close) + artifacts=ProjectArtifacts(store);self.addCleanup(artifacts.close) + view=SimpleNamespace(snapshot={'project_id':store.snapshot()['project_id'],'records':[]},artifacts=artifacts,check=lambda:None) + material=ProjectMaterial(view) + model_record=material.capture_model_material(model,self.models.path) + recipe_record=material.capture_recipe_material(composition,self.dataset.pipelines.material_path) + runtime_ref=composition['material']['runtime'] + runtime_record=material.capture_runtime({'ref':runtime_ref,'descriptor':None,'environment':run['material']['runtime'],'manifest':None}) + records=[model_record,recipe_record,runtime_record,*material.declarations(model_record),*material.declarations(recipe_record)] + snapshot=json.loads((self.dataset.pipelines.material_path/composition['ref']['sha256']/'recipe.json').read_bytes()) + capture_map={item['path']:item['blob'] for item in snapshot['capture_paths']} + edges=[] + for name,item in run['material']['captures'].items(): + blob=capture_map[name] + raw=(self.dataset.pipelines.material_path/composition['ref']['sha256']/blob['name']).read_bytes() + declaration={'format':'project_artifact_v1','sha256':item['sha256'],'bytes':item['bytes']} + artifacts.publish_bytes(raw,declaration) + edges.append({'name':name,'blob':dict(item),'population':'unknown','role':'execution_capture', + 'preservation':'unique','required_for':['execution_proof']}) + archived=capture_run(run,admission_proof={'grant':grant,'consumption':consumption,'admission':admission},payloads=edges) + origin=self.dataset.db.dataset_id + study_doc={'format':FAMILY_FORMATS['study_history'],'origin_catalog_id':origin,'study':value,'memberships':memberships, + 'policies':[{'ref':value['policy'],'policy':policy()}],'reservations':[reservation],'operations':[], + 'revisions':[copy.deepcopy(value)],'audit':[],'revision_history_complete':False} + trial_doc={'format':FAMILY_FORMATS['trial_history'],'origin_catalog_id':origin,'trial':prepared,'lineage':None} + records.extend([envelope('study_history',origin,value['id'],study_doc), + envelope('trial_history',origin,prepared['id'],trial_doc),archived,*declarations(archived)]) + records=list({(item['family'],item['id']):item for item in records}.values()) + view.snapshot={'project_id':store.snapshot()['project_id'],'records':records} + with patch('app.Dataset',side_effect=AssertionError('No Dataset on imported history')), \ + patch('runs.Runs',side_effect=AssertionError('No imported recovery owner')), \ + patch('subprocess.Popen',side_effect=AssertionError('No imported process')): + decode_records(records,materials=material) + store.commit(records,store.snapshot()['checkpoint'],'actual-history','e'*64) + db=Metadata(self.path/'fresh-inert-db',dsn=os.environ['TULDOK_DATABASE_URL']) + self.addCleanup(db.close) + manifest=json.loads((db.path/'.tuldok.json').read_bytes()) + def remove_projection(): + db.close() + with psycopg.connect(connection_info(),autocommit=True) as connection: + connection.execute(sql.SQL('DROP SCHEMA IF EXISTS {} CASCADE').format(sql.Identifier('catalog_'+manifest['dataset_id']))) + self.addCleanup(remove_projection) + host=SimpleNamespace(db=db,access=Access(db,db.path));owner=ExplicitOwnerFixture.bootstrap(host) + session=ProjectSession(store,db,host.access,artifacts,validate_records=lambda values:decode_records(values,materials=material)) + history=ProjectHistory(session,materials=material,authorize_disclosure=lambda *_:None) + with owner.bound(host): + result=history.run(run['id'],origin_catalog_id=origin) + self.assertNotIn('spec',result['run']) + descriptor=history.run_document_descriptor(run['id'],origin_catalog_id=origin) + import base64 + parts=[];offset=0 + while True: + segment=history.run_document_segment(run['id'],descriptor['sha256'],offset,origin_catalog_id=origin) + self.assertEqual(segment['offset'],offset) + chunk=base64.b64decode(segment['base64'],validate=True);parts.append(chunk) + self.assertLessEqual(len(chunk),262144) + if segment['next_offset'] is None:break + self.assertEqual(segment['next_offset'],offset+len(chunk));offset=segment['next_offset'] + full=b''.join(parts) + self.assertEqual(len(full),descriptor['bytes']) + self.assertEqual(hashlib.sha256(full).hexdigest(),descriptor['sha256']) + self.assertEqual(json.loads(full)['run']['spec'],run['spec']) + self.assertFalse(result['execution_available']) + self.assertEqual(history.study(value['id'],origin_catalog_id=origin)['charged'],reservation['envelope']) + self.assertEqual(history.artifact(run['id'],'input/sample',origin_catalog_id=origin),self.input) + self.assertEqual(material.composition_closure(prepared['material'])['execution_material'],run['material']) + malformed=copy.deepcopy(records) + next(item for item in malformed if item['family']=='run_history')['document']['admission_proof']['consumption']['request_sha256']='0'*64 + with self.assertRaises(WorkbenchError):decode_records(malformed,materials=material) + for table in ('workspace_runs','access_admissions','access_execution_grants','access_execution_consumptions','model_operation_receipts'): + self.assertEqual(db.execute('SELECT count(*) FROM '+table).fetchone()[0],0) + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_project_material.py b/tests/test_project_material.py new file mode 100644 index 00000000..8893d6ae --- /dev/null +++ b/tests/test_project_material.py @@ -0,0 +1,453 @@ +"""Owner codecs and canonical material custody without runtime or source execution. + +Neutral controlled source qualifies the custody fixture; it is not evidence for +current-host preflight, training, provider execution or runtime provisioning. +""" +import copy +import hashlib +import json +import math +import os +from pathlib import Path +import shutil +from types import SimpleNamespace +import tempfile +import unittest +from unittest.mock import patch +import uuid + +import agent_api +from model_definitions import ModelDefinitions, blob, decode_model_draft, decode_model_material, verify_model_closure +from pipeline import digest, decode_recipe_material, execution_digest, verify_recipe_closure +from project_artifacts import ProjectArtifacts, ProjectArtifactError, validate_declaration +from project_material import ProjectMaterial, decode_records, validate_operation_result +from project_store import ProjectStore +from project_session import ProjectSession, operation_records +from workbench import WorkbenchError + +SOURCE = b'raise AssertionError("retained source must never be imported")\n' + + +def definition(catalog, identity='b'*32): + return dict(format='model_draft_v1', id=identity, revision=1, name='Neutral model 山', + task=dict(kind='task',catalog_id=catalog,id='neutral.山',revision=1,sha256='a'*64), + kind='template',specification={'label':'café 山'},source_selector=None, + preprocessing_policy={'kind':'identity'},output_policy={'kind':'bytes'}) + + +def model(catalog): + draft=definition(catalog) + payloads={key+'.json':agent_api.encoded(draft[key]) for key in ('specification','preprocessing_policy','output_policy')} + payloads['source-0000.bin']=SOURCE + material=dict(format='model_material_v1',catalog_id=catalog,model_definition_id=draft['id'], + model_definition_revision=1,task=draft['task'],kind='template',entrypoint='model.py',source_selector=None, + source_closure=[dict(import_path='model.py',blob=blob(SOURCE,'source-0000.bin','application/octet-stream'))], + **{key:blob(payloads[key+'.json'],key+'.json') for key in ('specification','preprocessing_policy','output_policy')}) + ref=dict(kind='model',catalog_id=catalog,id=draft['id'],revision=1,sha256=digest(material)) + return dict(ref=ref,material=material),payloads + + +class MaterialCodecs(unittest.TestCase): + def setUp(self): + self.temporary=tempfile.TemporaryDirectory(prefix='tuldok-material-codec-') + self.addCleanup(self.temporary.cleanup);self.root=Path(self.temporary.name) + self.store=ProjectStore.create(self.root/'project','Material fixture','new-material','a'*64) + self.addCleanup(lambda:self.store.close()) + self.artifacts=ProjectArtifacts(self.store);self.addCleanup(lambda:self.artifacts.close()) + self.catalog=self.store.snapshot()['project_id'] + self.session=SimpleNamespace(snapshot=self.store.snapshot(),artifacts=self.artifacts,check=lambda:None) + self.material=ProjectMaterial(self.session) + + def capture(self): + value,payloads=model(self.catalog);root=self.root/'local-owner';target=root/value['ref']['sha256'];target.mkdir(parents=True) + for name,raw in payloads.items():(target/name).write_bytes(raw) + record=self.material.capture_model_material(value,root) + records=[*self.material.declarations(record),record] + return value,payloads,record,records,root + + def test_exact_unicode_hashes_source_bytes_and_closed_material_are_preserved(self): + value,payloads,record,records,root=self.capture() + self.assertNotEqual(digest(value['material']),hashlib.sha256(agent_api.encoded(value['material'])).hexdigest()) + self.assertIn(b'\\u5c71',payloads['specification.json']) + self.assertEqual(decode_model_material(value,catalog_id=self.catalog),value) + self.session.snapshot=dict(self.session.snapshot,records=records) + with patch('subprocess.Popen',side_effect=AssertionError('no process')): + self.assertEqual(self.material.model(value['ref'],verify=True),value) + self.assertEqual(self.material.blob(value['ref'],value['material']['source_closure'][0]['blob']),SOURCE) + bad=copy.deepcopy(value);bad['material']['source_closure'][0]['blob']['bytes']+=1 + with self.assertRaises(WorkbenchError):decode_model_material(bad,catalog_id=self.catalog) + bad=copy.deepcopy(record);bad['document']['artifacts'][0]['preservation']='regenerable' + with self.assertRaises(WorkbenchError):decode_records([bad]) + for malformed in (dict(record,extra=True),dict(record,document=None),dict(record,document={})): + with self.assertRaises(WorkbenchError):decode_records([malformed]) + + def test_cold_relocation_uses_canonical_artifacts_after_local_capture_deleted(self): + value,payloads,record,records,root=self.capture() + snapshot=self.store.commit(records,self.store.snapshot()['checkpoint'],'retain-material','b'*64) + self.artifacts.close();self.store.close();shutil.rmtree(root) + moved=self.root/'moved';(self.root/'project').rename(moved) + with ProjectStore.open(moved) as store,ProjectArtifacts(store) as artifacts: + material=ProjectMaterial(SimpleNamespace(snapshot=store.snapshot(),artifacts=artifacts,check=lambda:None)) + self.assertEqual(store.snapshot(),snapshot) + self.assertEqual(material.model(value['ref'],verify=True),value) + source=value['material']['source_closure'][0]['blob'] + self.assertEqual(material.blob(value['ref'],source),SOURCE) + (moved/'artifacts/sha256'/source['sha256']).unlink() + self.assertEqual(material.model(value['ref']),value) + with self.assertRaises(ProjectArtifactError):material.model(value['ref'],verify=True) + + def test_definition_history_is_contiguous_without_fabricating_prior_revisions(self): + first=definition(self.catalog);record=self.material.capture_definition('model_definition',first,[],action='models.create') + second=copy.deepcopy(first);second.update(revision=2,name='Changed') + updated=self.material.capture_definition('model_definition',second,[record],action='models.save') + self.assertEqual(len(updated['document']['revisions']),2) + bad=copy.deepcopy(updated);bad['document']['revisions'][0]['revision']=8 + with self.assertRaises(WorkbenchError):decode_records([bad]) + self.assertTrue(validate_operation_result('models.create',first)) + self.assertFalse(validate_operation_result('workbench.save',{})) + with self.assertRaises(WorkbenchError):validate_operation_result('models.create',dict(first,unknown=True)) + with self.assertRaises(WorkbenchError):decode_model_draft(first,catalog_id='f'*32) + graph=dict(schema_version=1,mode='real',id='d'*32,revision=1,name='Clone',recipe_version=1, + nodes=[dict(id='publish',type='publish',definition_version=1,parameters={})],edges=[]) + audit=dict(action='pipeline.clone_retained',target=graph['id'],details=dict(id='e'*32,revision=9, + material=dict(kind='recipe_material',catalog_id=self.catalog,id='c'*64,sha256='c'*64)), + result='success',actor_id='f'*32,occurred_at='2026-10-22T12:00:00+00:00') + clone=self.material.capture_definition('pipeline_graph',graph,[],action='graphs.clone',audit_facts=[audit]) + self.assertEqual(clone['document']['audit'][0],audit) + + def test_runtime_environment_is_historical_and_does_not_probe_origin_paths(self): + environment={'python':'/unavailable/original/python','packages':{'neutral':'1.0'},'definition_files':{'adapter.py':'a'*64}} + sha=digest(environment);ref=dict(kind='runtime',catalog_id=self.catalog,id=sha,sha256=sha) + record=self.material.capture_runtime(dict(ref=ref,descriptor=None,environment=environment,manifest=None)) + self.session.snapshot=dict(self.session.snapshot,records=[record]) + with patch('subprocess.Popen',side_effect=AssertionError('no process')),patch('os.stat',side_effect=AssertionError('no host paths')): + self.assertEqual(self.material.runtime(ref,verify=True)['environment'],environment) + + def test_operation_result_requires_exact_retained_revision_or_material(self): + first=definition(self.catalog);record=self.material.capture_definition('model_definition',first,[],action='models.create') + changed=copy.deepcopy(first);changed.update(revision=2,name='Changed') + history=self.material.capture_definition('model_definition',changed,[record],action='models.save') + self.assertTrue(validate_operation_result('models.create',first,[history])) + self.assertTrue(validate_operation_result('models.save',changed,[history])) + forged=dict(changed,name='Well-formed invented receipt') + with self.assertRaises(WorkbenchError):validate_operation_result('models.save',forged,[history]) + with self.assertRaises(WorkbenchError):validate_operation_result('models.create',changed,[history]) + value,payloads,immutable,records,root=self.capture() + self.assertTrue(validate_operation_result('models.publish',value,records)) + with self.assertRaises(WorkbenchError):validate_operation_result('models.publish',value,[history]) + self.assertFalse(validate_operation_result('source.register',{},records)) + + def test_action_specific_audit_details_refuse_authority_before_projection(self): + value=definition(self.catalog) + retained=self.material.capture_definition('model_definition',value,[],action='models.create') + variants=[dict(token_sha256='a'*64),dict(credential={'token':'never-import'}), + dict(document_sha256={'token':'never-import'}),dict(revision=True),dict(document_sha256='f'*64)] + with patch('model_definitions.ModelDefinitions',side_effect=AssertionError('No owner construction')): + for fields in variants: + bad=copy.deepcopy(retained);bad['document']['audit'][0]['details'].update(fields) + with self.subTest(fields=fields),self.assertRaises(WorkbenchError):decode_records([bad]) + bad=copy.deepcopy(retained);bad['document']['audit'][0]['action']='access.issue' + with self.assertRaises(WorkbenchError):decode_records([bad]) + + def test_captured_runtime_manifest_binding_and_explicit_reader_never_read_old_root(self): + from recipe_process import encoded,validate_cpu_snapshot_manifest + from runtime_snapshots import descriptor + paths={'python/bin/python3.12':'elf','systemlibs/loader':'elf','python/lib/startup.py':'data', + 'python/lib/dynload/module.so':'elf','packages/numpy/__init__.py':'data'} + manifest=dict(schema_version=1,profile='captured_cpu_python_v1',python_version='3.12',packages={'numpy':'0','torch':'0'}, + launch=dict(python='python/bin/python3.12',loader='systemlibs/loader',home='python',stdlib='python/lib', + dynload='python/lib/dynload',packages='packages',library_dirs=['systemlibs']), + files=[dict(path=path,bytes=1,sha256='a'*64,mode=0o500 if kind=='elf' else 0o400,kind=kind) for path,kind in sorted(paths.items())], + total_bytes=len(paths)) + raw=encoded(manifest);sha=hashlib.sha256(raw).hexdigest() + saved=descriptor(manifest,Path('/original-missing/runtime-snapshots')/sha,self.catalog) + runtime_sha=digest(saved);ref=dict(kind='runtime',catalog_id=self.catalog,id=runtime_sha,sha256=runtime_sha) + value=dict(ref=ref,descriptor=saved,environment=None,manifest=manifest) + with patch('recipe_process._cpu_open_root',side_effect=AssertionError('No old runtime root')), \ + patch('subprocess.Popen',side_effect=AssertionError('No process')): + record=self.material.capture_runtime(value,raw) + self.session.snapshot=dict(self.session.snapshot,records=[*self.material.declarations(record),record]) + self.assertEqual(self.material.runtime(ref,verify=True),value) + changed=copy.deepcopy(saved);changed['file_count']+=1 + with self.assertRaises(ValueError):validate_cpu_snapshot_manifest(changed,manifest) + snapshot=encoded(dict(format='external_recipe_snapshot_v1',preflight={'material':{'runtime':{},'cpu_snapshot':saved}})) + recipe_blob=blob(snapshot,'recipe.json') + self.artifacts.publish_bytes(snapshot,dict(format='project_artifact_v1',sha256=recipe_blob['sha256'],bytes=len(snapshot))) + recipe_record=dict(document={'value':{'material':{'runtime':ref,'recipe_document':recipe_blob}}}) + seen=[] + def supplied(descriptor):seen.append(descriptor);return raw + dependencies=self.material.capture_recipe_dependencies(recipe_record,[],runtime_manifest=supplied) + self.assertEqual(seen,[saved]);self.assertEqual(dependencies[-1],record) + with self.assertRaises(WorkbenchError):self.material.capture_recipe_dependencies(recipe_record,[]) + (self.root/'project/artifacts/sha256'/sha).unlink() + with self.assertRaises(ProjectArtifactError):self.material.capture_recipe_dependencies(recipe_record,dependencies) + restored=self.material.capture_recipe_dependencies(recipe_record,dependencies,runtime_manifest=supplied) + self.assertEqual(restored[-1],record) + self.assertEqual(self.material.runtime(ref,verify=True),value) + + def test_original_snapshot_manifest_whitespace_is_not_reserialized_for_identity(self): + manifest=dict(schema_version=1,id='c'*32,name='Snapshot',data=[],recipes=[],evidence=[],requirements=[]) + import json + raw=(json.dumps(manifest,indent=3,ensure_ascii=False)+'\n\n').encode() + saved=dict(id=manifest['id'],name=manifest['name'],root='old-root',root_identity=[7,9],manifest_path='project.json', + manifest_sha256=hashlib.sha256(raw).hexdigest(),manifest=manifest) + record=self.material.capture_snapshot(saved,raw,catalog_id=self.catalog) + self.session.snapshot=dict(self.session.snapshot,records=[*self.material.declarations(record),record]) + self.assertEqual(self.material.snapshot_ref(manifest['id'],saved['manifest_sha256'],verify=True),saved) + rebound=dict(saved,root='explicit-current-root',root_identity=[11,13],manifest_path='moved.json') + self.assertEqual(self.material.capture_snapshot(rebound,raw,catalog_id=self.catalog),record) + with self.assertRaises(WorkbenchError):self.material.capture_snapshot(saved,agent_api.encoded(manifest),catalog_id=self.catalog) + + def test_builtin_recipe_closure_uses_frozen_validation_without_current_host_qualification(self): + import one_fit_evidence + import text_training + from workbench import encode + value,payloads=model(self.catalog) + parameters={key:rule['default'] for key,rule in text_training.PARAMETER_BOUNDS.items()} + owner=one_fit_evidence.contract(text_training);definition=owner.model_definition(parameters) + body=value['material'];body['task']=owner.task_ref(self.catalog) + for key in ('specification','preprocessing_policy','output_policy'): + raw=agent_api.encoded(definition[key]);payloads[key+'.json']=raw;body[key]=blob(raw,key+'.json') + value['ref']['sha256']=digest(body) + graph=dict(schema_version=1,mode='real',id='e'*32,revision=1,name='Frozen recipe λ',recipe_version=1, + recipe=text_training.MODEL_RECIPE,nodes=[dict(id='train',type='train',definition_version=1,parameters=parameters)],edges=[]) + runtime=dict(python='/original/now-missing',definition_files={'model.py':hashlib.sha256(SOURCE).hexdigest()}) + release=dict(id='f'*64,sha256='f'*64,split_counts={'validation':1}) + validation=dict(valid=True,execution_sha256=execution_digest(graph),runtime=runtime,release=release) + item=blob(SOURCE,'capture-0000.bin','application/octet-stream') + snapshot=dict(format='builtin_recipe_snapshot_v1',graph=graph,validation=validation,model_definition=definition, + capture_paths=[dict(path='model.py',blob=item)]) + profile=owner.profile(self.catalog,value['ref']['sha256'],parameters,1) + recipe_payloads={'capture-0000.bin':SOURCE,'recipe.json':encode(snapshot).encode(),'profile.json':encode(profile).encode()} + composition=dict(format='recipe_material_v1',catalog_id=self.catalog,recipe_id=graph['id'],recipe_revision=1, + recipe_document=blob(recipe_payloads['recipe.json'],'recipe.json'),executable_sha256=validation['execution_sha256'], + model=value['ref'],data=[dict(kind='release',catalog_id=self.catalog,id=release['id'],sha256=release['sha256'])], + trainer=dict(kind='trainer',catalog_id=self.catalog,id=text_training.MODEL_ADAPTER['name']+'_v2',revision=2,sha256=digest(text_training.MODEL_ADAPTER)), + evaluation_profile=profile['definition'],resolved_profile=blob(recipe_payloads['profile.json'],'profile.json'), + runtime=dict(kind='runtime',catalog_id=self.catalog,id=digest(runtime),sha256=digest(runtime)),captures=[item], + resource_envelope=dict(attempt_slots=1,admitted_wall_seconds=1,retained_bytes=1,peak_staging_bytes=1)) + sha=digest(composition);retained=dict(ref=dict(kind='recipe_material',catalog_id=self.catalog,id=sha,sha256=sha),material=composition) + def read(ref,item,maximum):return (payloads if ref['kind']=='model' else recipe_payloads)[item['name']] + with patch('pipeline.Pipelines.validate_graph',side_effect=AssertionError('No current host qualification')), \ + patch('subprocess.Popen',side_effect=AssertionError('No process')): + closure=verify_recipe_closure(retained,value,read_blob=read) + self.assertEqual(closure['execution_material'],validation) + for name,raw in recipe_payloads.items(): + item=next(item for item in [composition['recipe_document'],composition['resolved_profile'],*composition['captures']] if item['name']==name) + self.artifacts.publish_bytes(raw,dict(format='project_artifact_v1',sha256=item['sha256'],bytes=item['bytes'])) + dependencies=self.material.capture_recipe_dependencies(dict(document={'value':retained}),[]) + self.assertEqual(dependencies[-1]['document']['value']['environment'],runtime) + self.assertEqual(self.material.capture_recipe_dependencies(dict(document={'value':retained}),dependencies),[]) + for field,replacement in (('data',{}),('executable_sha256','wrong'),('captures',[dict(item,media_type='text/plain')])): + bad=copy.deepcopy(retained);bad['material'][field]=replacement + changed=digest(bad['material']);bad['ref'].update(id=changed,sha256=changed) + with self.assertRaises(WorkbenchError):decode_recipe_material(bad,catalog_id=self.catalog) + changed=dict(recipe_payloads,**{'profile.json':b'{}'}) + with self.assertRaises(WorkbenchError):verify_recipe_closure(retained,value,read_blob=lambda ref,item,maximum:(payloads if ref['kind']=='model' else changed)[item['name']]) + + +class EvidenceCallbacks(unittest.TestCase): + def test_retained_one_fit_readers_match_directory_without_source_paths_or_inference(self): + """Controlled observations test custody/semantics, not a trained model.""" + import audio_training + import text_training + import one_fit_evidence as evidence + for adapter in (text_training,audio_training): + with self.subTest(adapter=adapter.ADAPTER['name']),tempfile.TemporaryDirectory() as temporary: + root=Path(temporary);catalog='1'*32;owner=evidence.contract(adapter) + parameters={name:rule['default'] for name,rule in adapter.PARAMETER_BOUNDS.items()} + context=dict(material_sha256='c'*64,adapter_sha256='e'*64,model_sha256='d'*64, + profile=owner.profile(catalog,'d'*64,parameters,1)) + records=[] + for index,split in enumerate(('train','validation')): + record={key:'a'*64 for key in adapter.REFERENCE_KEYS} + record.update(id=str(index+2)*32,revision=1,source_revision=1,split=split,class_index=0, + text='Frozen validation text λ',content_hash='b'*64,sample_rate_hz=8000,channels=1, + channel=0,start_frame=0,end_frame=16) + records.append(record) + clocks=evidence.fit_clocks(.125) + validation=dict(examples=1,loss=-math.log(.75),accuracy=1.0,macro_f1=.5,confusion_matrix=[[1,0],[0,0]]) + metrics=dict(class_vocabulary=['left','right'],validation=validation,clocks=clocks) + run=dict(id='f'*32,dataset_id=catalog,graph_sha256='b'*64,release_id='a'*64,release_sha256='a'*64, + parameters=parameters,adapter=dict(adapter.ADAPTER,version=2),evidence_context=context, + source_records=records,metrics=metrics,graph={'recipe':adapter.MODEL_RECIPE}) + job=evidence.job_for_run(run) + saved=dict(adapter=run['adapter'],parameters=parameters,transform=adapter.TRANSFORM, + class_vocabulary=metrics['class_vocabulary'],source_identity={key:job[key] + for key in ('dataset_id','run_id','graph_sha256','release_id','release_sha256')}) + saved.update(dict(vocabulary={'frozen':0},idf=[1.0]) if adapter is text_training else dict(mean=[0.0]*8,scale=[1.0]*8)) + inputs=[records[1]['text']] if adapter is text_training else [[0.0]*8] + for name,body in (('model.json',saved),('metrics.json',metrics)): + (root/name).write_text(json.dumps(body,ensure_ascii=False)) + evidence.capture(root,job=job,adapter=adapter.ADAPTER, + manifest=dict(records=records,class_vocabulary=metrics['class_vocabulary']), + reference_keys=adapter.REFERENCE_KEYS,saved=saved,inputs=inputs,validation=validation, + rows=[dict(truth_index=0,predicted_index=0,probabilities=[.75,.25])],clocks=clocks) + payloads={path.name:path.read_bytes() for path in root.iterdir()} + run['artifacts']=[dict(name=name,sha256=hashlib.sha256(raw).hexdigest(),bytes=len(raw),media_type='application/json') + for name,raw in payloads.items()] + with patch('text_training.predict_model',side_effect=AssertionError('No inference')), \ + patch('audio_training.predict_features',side_effect=AssertionError('No inference')), \ + patch('subprocess.Popen',side_effect=AssertionError('No process')): + expected=evidence.verify(root,run,metrics) + self.assertEqual(evidence.verify_blobs(run,metrics,read_blob=lambda name,maximum:payloads[name]),expected) + material=ProjectMaterial(SimpleNamespace(check=lambda:None)) + result=material.evidence(run,read_blob=lambda name,maximum:payloads[name]) + self.assertEqual(result['state'],'verified') + checkpoint=result['checkpoint_index']['entries'][0]['checkpoint']['sha256'] + selected=material.evidence(run,{'checkpoint_sha256':checkpoint},read_blob=lambda name,maximum:payloads[name]) + self.assertEqual(selected['checkpoint'],expected[1][0]) + self.assertEqual(selected['predictions'],expected[2][0]) + corrupted=dict(payloads,**{'probe-inputs.json':b'{}'}) + with self.assertRaises(WorkbenchError):material.evidence(run,read_blob=lambda name,maximum:corrupted[name]) + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'),'Requires disposable PostgreSQL') +class CanonicalPublication(unittest.TestCase): + def setUp(self): + from metadata import Metadata + from access import Access + from auth_helpers import ExplicitOwnerFixture + self.temporary=tempfile.TemporaryDirectory(prefix='tuldok-material-session-');self.path=Path(self.temporary.name) + self.schemas=[];self.resources=[];self.addCleanup(self.temporary.cleanup);self.addCleanup(self.cleanup) + self.store=ProjectStore.create(self.path/'project','Canonical models','new','a'*64);self.resources.append(self.store) + self.artifacts=ProjectArtifacts(self.store);self.resources.append(self.artifacts) + self.host=Metadata(self.path/'host');self.resources.append(self.host);self.schemas.append(self.host.schema) + self.access=Access(self.host,self.host.path);self.owner=ExplicitOwnerFixture.bootstrap(SimpleNamespace(db=self.host,access=self.access)) + self.db=Metadata(self.path/'projection',dataset_id=self.store.snapshot()['project_id'],projection_id=uuid.uuid4().hex) + self.resources.append(self.db);self.schemas.append(self.db.schema) + self.session=ProjectSession(self.store,self.db,self.access,self.artifacts,validate_records=self.validate) + self.material=ProjectMaterial(self.session) + qualification=SimpleNamespace(model_template=lambda draft:('model.py',{'model.py':SOURCE})) + self.dataset=SimpleNamespace(db=self.db,access=self.material.owner_access,lock=self.db.lock,path=self.db.path,pipelines=qualification) + self.models=ModelDefinitions(self.dataset) + + def cleanup(self): + import psycopg + from psycopg import sql + from metadata import connection_info + for resource in reversed(self.resources):resource.close() + with psycopg.connect(connection_info(),autocommit=True) as db: + for schema in self.schemas:db.execute(sql.SQL('DROP SCHEMA IF EXISTS {} CASCADE').format(sql.Identifier(schema))) + + def validate(self,records): + decode_records(records) + for record in records: + if record['family']=='project_artifact':validate_declaration(record['document']) + for receipt in operation_records(records).values(): + if not validate_operation_result(receipt['operation'],receipt['result']):raise AssertionError('unowned fixture receipt') + + def create(self): + draft=definition(self.db.dataset_id) + body={key:value for key,value in draft.items() if key not in ('format','id','revision')} + return self.material.mutate_definition('model_definition','models.create',None,body,self.session.snapshot['checkpoint'],uuid.uuid4().hex,lambda:self.models.create(body)) + + def test_model_publication_is_canonical_before_ack_and_has_one_session_receipt(self): + with self.access.bind(self.owner.token): + draft=self.create();value=self.material.publish_model(self.models,draft['id'],{'revision':1},self.session.snapshot['checkpoint'],'publish-neutral') + self.assertEqual(self.material.model(value['ref'],verify=True),value) + records=self.store.snapshot()['records'] + self.assertEqual({record['family'] for record in records},{'project_artifact','project_operation','model_definition','model_material'}) + self.assertNotIn('operations',next(item['document'] for item in records if item['family']=='model_definition')) + replay=self.material.publish_model(self.models,draft['id'],{'revision':1},'0'*64,'publish-neutral') + self.assertEqual(replay,value) + original=next(item for item in records if item['family']=='model_material') + again=self.material.publish_model(self.models,draft['id'],{'revision':1},self.session.snapshot['checkpoint'],'publish-again') + self.assertEqual(again,value) + self.assertEqual(next(item for item in self.store.snapshot()['records'] if item['family']=='model_material'),original) + + def test_agent_publish_preserves_legacy_edit_scope_and_owner_reconcile_guard_precedes_replay(self): + from access import AccessError + with self.access.bind(self.owner.token): + agent=self.access.issue({'label':'Material editor','scopes':['inspect','edit']}) + with self.access.bind(agent['token']): + draft=self.create();value=self.material.publish_model(self.models,draft['id'],{'revision':1},self.session.snapshot['checkpoint'],'agent-publication') + self.assertEqual(self.material.model(value['ref'],verify=True),value) + with self.access.bind(self.owner.token): + self.material.publish_model(self.models,draft['id'],{'revision':1,'reconcile_capture':True},self.session.snapshot['checkpoint'],'owner-reconcile-receipt') + stable=self.store.snapshot() + with self.access.bind(agent['token']): + with self.assertRaises(AccessError):self.material.publish_model(self.models,draft['id'],{'revision':1,'reconcile_capture':True},'0'*64,'owner-reconcile-receipt') + self.assertEqual(self.store.snapshot(),stable) + + def test_real_deferred_sql_failure_cannot_delete_canonical_source_material(self): + with self.access.bind(self.owner.token): + draft=self.create() + self.db.execute("CREATE FUNCTION fixture_refuse_material() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'controlled material commit rejection' USING ERRCODE='23514'; END; $$") + self.db.execute('CREATE CONSTRAINT TRIGGER fixture_material_failure AFTER INSERT ON model_materials DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION fixture_refuse_material()') + with self.assertRaises(WorkbenchError) as error: + self.material.publish_model(self.models,draft['id'],{'revision':1},self.session.snapshot['checkpoint'],'canonical-before-sql') + self.assertEqual(error.exception.code,'projection_unavailable') + snapshot=self.store.snapshot();retained=next(item for item in snapshot['records'] if item['family']=='model_material')['document']['value'] + self.assertFalse((self.models.path/retained['ref']['sha256']).exists(),'Legacy rollback removes only disposable projection cache') + cold=ProjectMaterial(SimpleNamespace(snapshot=snapshot,artifacts=self.artifacts,check=lambda:None)) + self.assertEqual(cold.model(retained['ref'],verify=True),retained) + self.assertEqual(cold.blob(retained['ref'],retained['material']['source_closure'][0]['blob']),SOURCE) + + def test_restore_is_inert_and_explicit_preparation_rebuilds_only_projection_cache(self): + from metadata import Metadata + from model_definitions import retained_json + with self.access.bind(self.owner.token): + draft=self.create();value=self.material.publish_model(self.models,draft['id'],{'revision':1},self.session.snapshot['checkpoint'],'cold-model') + cold_db=Metadata(self.path/'cold-projection',dataset_id=self.db.dataset_id,projection_id=uuid.uuid4().hex) + self.resources.append(cold_db);self.schemas.append(cold_db.schema) + cold=ProjectMaterial(SimpleNamespace(snapshot=self.store.snapshot(),artifacts=self.artifacts,db=cold_db,check=lambda:None)) + with patch('model_definitions.ModelDefinitions',side_effect=AssertionError('No mutable owner on Open')), \ + patch('pipeline.Pipelines',side_effect=AssertionError('No mutable owner on Open')), \ + patch('subprocess.Popen',side_effect=AssertionError('No process')): + cold.restore() + self.assertEqual(retained_json(cold_db.execute('SELECT document FROM model_definition_drafts WHERE id=%s',(draft['id'],)).fetchone()[0]),draft) + self.assertEqual(retained_json(cold_db.execute('SELECT document FROM model_materials WHERE sha256=%s',(value['ref']['sha256'],)).fetchone()[0]),value) + cache=cold.materialize_projection(value['ref'],cold_db.path/'model-materials') + self.assertEqual((cache/'source-0000.bin').read_bytes(),SOURCE) + shutil.rmtree(cache) + with self.assertRaises(WorkbenchError):cold.materialize_projection(value['ref'],self.path/'not-a-projection') + self.assertEqual(cold.model(value['ref'],verify=True),value) + + def test_builtin_prepare_execution_gate_precedes_owner_probe_cache_and_artifact_changes(self): + from pipeline import Pipelines + with self.access.bind(self.owner.token): + draft=self.create();value=self.material.publish_model(self.models,draft['id'],{'revision':1},self.session.snapshot['checkpoint'],'gate-model') + self.dataset.models=self.models + pipelines=Pipelines(self.dataset) + shutil.rmtree(self.models.path/value['ref']['sha256']) + snapshot=self.store.snapshot() + payloads={path.name:path.read_bytes() for path in (self.store.path/'artifacts/sha256').iterdir()} + with patch('subprocess.Popen',side_effect=AssertionError('Built-in probe must be fenced')) as processes, \ + patch.object(pipelines,'prepare_builtin_material',side_effect=AssertionError('Owner preparation must not begin')) as prepare: + with self.assertRaises(WorkbenchError) as error: + self.material.prepare_recipe(pipelines,'a'*32,1,value['ref'],snapshot['checkpoint'],'builtin-gate',builtin=True) + self.assertEqual(error.exception.code,'unavailable') + self.assertIn('runtime probe',str(error.exception)) + processes.assert_not_called();prepare.assert_not_called() + self.assertEqual(self.store.snapshot(),snapshot) + self.assertEqual({path.name:path.read_bytes() for path in (self.store.path/'artifacts/sha256').iterdir()},payloads) + self.assertFalse(list(self.models.path.iterdir()));self.assertFalse(list(pipelines.material_path.iterdir())) + + +from test_model_definitions import CapturedModelCase + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'),'Requires disposable PostgreSQL') +class OpaqueCompositionReaderTests(CapturedModelCase): + def test_profile_reader_remains_callable_after_exact_opaque_source_validation(self): + from model_definitions import read_owned_blob + self.dataset.models=self.models + with self.owner.bound(self.dataset): + published=self.publish(self.create()) + prepared=self.dataset.pipelines.prepare_external_material(self.graph['id'],self.graph['revision'],published['ref']) + with self.dataset.db.transaction(): + composition=self.dataset.pipelines._retain_material(prepared) + reads=[] + def read(ref,item,maximum): + reads.append((ref['kind'],item['name'])) + root=self.models.path if ref['kind']=='model' else self.dataset.pipelines.material_path + return read_owned_blob(root,ref['sha256'],item,maximum) + with patch('subprocess.Popen',side_effect=AssertionError('Retained closure cannot execute source')): + closure=verify_recipe_closure(composition,published,read_blob=read) + self.assertEqual(closure['kind'],'external') + self.assertIsNone(closure['static_context']) + self.assertEqual(closure['execution_material'],self.preflight['material']) + self.assertEqual(closure['captured_inputs'],{'input/sample':self.input}) + self.assertEqual(reads[-1],('recipe_material','profile.json')) + self.assertTrue(any(kind=='recipe_material' and name.startswith('capture-') for kind,name in reads[:-1])) diff --git a/tests/test_project_material_fork.py b/tests/test_project_material_fork.py new file mode 100644 index 00000000..89681263 --- /dev/null +++ b/tests/test_project_material_fork.py @@ -0,0 +1,300 @@ +"""Independent editable material identities, exact archived facts and inert intents.""" +import copy +import os +from pathlib import Path +from types import SimpleNamespace +import tempfile +import unittest +from unittest.mock import patch +import uuid + +from project_fork import ARCHIVE_FAMILIES, _digest, _input, active_records, publish_fork +from project_material import ProjectMaterial, FAMILIES, decode_records, _edges, _inventory +from project_material_fork import fork_records, is_intent +from project_store import DIR_FLAGS +from workbench import WorkbenchError +from test_project_material import definition, model +import test_project_material as material_fixture + +NOW = '2026-10-22T12:00:00+00:00' + + +def graph(identity=None, *, release=False, external=False): + value = dict(schema_version=1,mode='real',id=identity or uuid.uuid4().hex,revision=1, + name='Independent graph 山',recipe_version=1, + nodes=[dict(id='publish',type='publish',definition_version=1,parameters={})],edges=[]) + if release:value['nodes'].insert(0,dict(id='release',type='release',definition_version=1,parameters={'release_id':'e'*64})) + if external:value['recipe']='external_recipe' + return value + + +def recipe(catalog): + def ref(kind, identity): + result=dict(kind=kind,catalog_id=catalog,id=identity,sha256='a'*64) + if kind not in ('release','runtime'):result['revision']=1 + return result + return dict(format='recipe_draft_v2',id=uuid.uuid4().hex,revision=1,name='Bound recipe', + task=ref('task','classification'),model=ref('model','b'*32),trainer=ref('trainer','owned'), + evaluation_profile=ref('profile','owned'),runtime=ref('runtime','a'*64), + data=[ref('release','a'*64)],parameters={},layout={}) + + +def overlay(records, made, source, checkpoint, destination): + origin=dict(project_id=source,checkpoint=checkpoint) + return dict(family='project_fork',id=destination,document=dict(format='project_fork_v1',source=origin, + destination_project_id=destination,request_key='material:fork',directory='fork',name='Independent material', + request_sha256=_digest(_input(origin,'fork','Independent material','material:fork')), + mapping=copy.deepcopy(made['mapping']), + archived=sorted([dict(family=r['family'],id=r['id']) for r in records if r['family'] in ARCHIVE_FAMILIES],key=lambda r:(r['family'],r['id'])), + artifacts=dict(copied=[],missing_regenerable=[]))) + + +class MaterialForkCodecs(unittest.TestCase): + def setUp(self): + self.source=uuid.uuid4().hex;self.checkpoint='a'*64 + self.session=SimpleNamespace(snapshot=dict(project_id=self.source,checkpoint=self.checkpoint,records=[]),check=lambda:None) + self.material=ProjectMaterial(self.session) + + def capture(self,family,current): + records=self.session.snapshot['records'] + action={'model_definition':'models.create','pipeline_graph':'pipeline.create','recipe_definition':'recipes.create'}[family] + record=self.material.capture_definition(family,current,records,action=action) + records.append(record) + return record + + def known(self): + import one_fit_evidence,text_training + owner=one_fit_evidence.contract(text_training) + parameters={k:v['default'] for k,v in text_training.PARAMETER_BOUNDS.items()} + parts=owner.model_definition(parameters) + value=definition(self.source,uuid.uuid4().hex) + value.update(task=owner.task_ref(self.source),**{k:parts[k] for k in ('specification','preprocessing_policy','output_policy')}) + return value + + def fork(self,records=None,source=None,checkpoint=None): + records=copy.deepcopy(self.session.snapshot['records'] if records is None else records) + source=source or self.source;checkpoint=checkpoint or self.checkpoint;destination=uuid.uuid4().hex + made=fork_records(records,source,checkpoint,destination,id_factory=lambda *key:uuid.uuid4().hex,created_at=NOW) + combined=records+made['records']+[overlay(records,made,source,checkpoint,destination)] + decode_records(combined) + return made,combined,destination + + def test_known_templates_and_binding_free_graph_are_fresh_without_rewriting_archive(self): + original=self.capture('model_definition',self.known()) + old=self.capture('pipeline_graph',graph()) + value,_=model(self.source) + material=dict(family='model_material',id=self.source+'.'+value['ref']['sha256'],document=dict( + format=FAMILIES['model_material'],origin_catalog_id=self.source,value=value,artifacts=_edges(_inventory('model_material',value),'model_material'),audit=[])) + self.session.snapshot['records'].append(material) + before=copy.deepcopy(self.session.snapshot['records']) + with patch('subprocess.Popen',side_effect=AssertionError('No execution or probe')),patch('builtins.open',side_effect=AssertionError('No origin file reads')): + made,combined,destination=self.fork() + self.assertEqual(self.session.snapshot['records'],before) + self.assertEqual(combined[:len(before)],before) + self.assertEqual(next(r for r in combined if r['family']=='model_material'),material) + newmodel=next(r for r in made['records'] if r['family']=='model_definition')['document'] + self.assertNotEqual(newmodel['current']['id'],original['document']['current']['id']) + self.assertEqual(newmodel['current']['task']['catalog_id'],destination) + self.assertEqual(newmodel['current']['task']['sha256'],original['document']['current']['task']['sha256']) + self.assertEqual(newmodel['current']['specification'],original['document']['current']['specification']) + self.assertEqual(newmodel['audit'],[]) + newgraph=next(r for r in made['records'] if r['family']=='pipeline_graph')['document'] + self.assertEqual(newgraph['current']['nodes'],old['document']['current']['nodes']) + self.assertEqual(newgraph['fork_origin']['created_at'],NOW) + self.assertTrue(all(r['document']['current']['revision']==1 for r in made['records'])) + + def test_bound_drafts_preserve_full_configuration_as_visible_nonexecutable_intents(self): + drafts=[('model_definition',definition(self.source,uuid.uuid4().hex)),('pipeline_graph',graph(release=True)), + ('pipeline_graph',graph(external=True)),('recipe_definition',recipe(self.source))] + captured=[self.capture(f,v) for f,v in drafts] + made,combined,destination=self.fork() + session=SimpleNamespace(snapshot=dict(project_id=destination,records=combined),check=lambda:None) + reader=ProjectMaterial(session) + intents=reader.intents() + self.assertEqual(len(intents),4) + self.assertEqual(reader.intent_capabilities()['editable'],[]) + self.assertEqual(len(reader.intent_capabilities()['requires_reselection']),4) + indexed={(r['family'],r['id']):r for r in captured} + for envelope in made['records']: + doc=envelope['document'];self.assertTrue(is_intent(doc['current'])) + self.assertEqual(doc['current']['definition'],indexed[(envelope['family'],doc['fork_origin']['id'])]['document']['current']) + intent=next(i for i in intents if i['family']=='model_definition') + with self.assertRaises(WorkbenchError) as caught:reader._require_resolved(intent['family'],intent['current']['id']) + self.assertEqual(caught.exception.code,'requires_reselection') + + def test_fork_proof_rejects_missing_overlay_mapping_changed_source_and_fabricated_initial(self): + self.capture('model_definition',self.known());made,combined,_=self.fork() + with self.assertRaises(WorkbenchError):decode_records(made['records']) + changes=[] + bad=copy.deepcopy(combined);bad[-1]['document']['mapping']=[];changes.append(bad) + bad=copy.deepcopy(combined);bad[0]['document']['current']['name']='Changed archive';changes.append(bad) + bad=copy.deepcopy(combined);bad[1]['document']['current']['name']='Invented';bad[1]['document']['revisions'][0]['name']='Invented';changes.append(bad) + bad=copy.deepcopy(combined);bad[1]['document']['fork_origin']['checkpoint']='b'*64;changes.append(bad) + bad=copy.deepcopy(combined);bad[1]['document']['fork_origin']['created_at']='2026-10-22T12:00:00';changes.append(bad) + for index,bad in enumerate(changes): + with self.subTest(case=index),self.assertRaises(WorkbenchError):decode_records(bad) + + def test_nested_fork_retains_exact_prior_origins_and_only_copies_active_definitions(self): + self.capture('model_definition',self.known());self.capture('recipe_definition',recipe(self.source)) + first,combined,intermediate=self.fork();unchanged=copy.deepcopy(combined) + second,nested,destination=self.fork(combined,intermediate,'b'*64) + self.assertEqual(nested[:len(combined)],unchanged) + self.assertEqual(len(second['records']),len(first['records'])) + self.assertTrue(all(r['document']['fork_origin']['project_id']==intermediate for r in second['records'])) + self.assertEqual(len([r for r in active_records(nested,project_id=destination) if r['family'] in ('model_definition','recipe_definition')]),2) + nested_intent=next(r for r in second['records'] if r['family']=='recipe_definition')['document']['current'] + self.assertFalse(is_intent(nested_intent['definition'])) + + def test_owner_history_is_archived_exact_and_mapping_identifies_current_source_revision(self): + first=self.capture('model_definition',self.known()) + second=copy.deepcopy(first['document']['current']);second.update(revision=2,name='Source revision two') + updated=self.material.capture_definition('model_definition',second,[first],action='models.save') + self.session.snapshot['records']=[updated] + made,combined,_=self.fork() + self.assertEqual(combined[0],updated) + self.assertEqual(made['mapping'][0]['source_revision'],2) + doc=made['records'][0]['document'] + self.assertEqual(doc['fork_origin']['revision'],2) + self.assertEqual(doc['current']['name'],'Source revision two') + self.assertEqual(len(doc['revisions']),1) + self.assertEqual(doc['audit'],[]) + + def test_captured_source_selector_stays_in_inert_intent_and_cannot_publish(self): + value=self.known();value.update(kind='captured_source',source_selector=dict( + project_id=uuid.uuid4().hex,recipe_id='training.py',expected_sha256='c'*64)) + self.capture('model_definition',value) + made,combined,destination=self.fork() + doc=made['records'][0]['document'] + self.assertEqual(doc['current']['required_reselection'],['source_selector']) + self.assertEqual(doc['current']['definition']['source_selector'],value['source_selector']) + reader=ProjectMaterial(SimpleNamespace(snapshot=dict(project_id=destination,records=combined),check=lambda:None)) + with self.assertRaises(WorkbenchError) as caught: + reader.publish_model(SimpleNamespace(publish=lambda *args: self.fail('Intent reached owner publication')), + doc['current']['id'],{'revision':1},'a'*64,'forbidden-intent') + self.assertEqual(caught.exception.code,'requires_reselection') + + def test_model_aware_graph_uses_owner_architecture_parameter_decoder(self): + import image_training + value=graph();value['recipe']=image_training.MODEL_RECIPE + parameters={k:v['default'] for k,v in image_training.PARAMETER_BOUNDS.items()} + parameters.update(widths=[16,32,64],input_size=96,head='linear') + value['nodes']=[dict(id='train',type='train',definition_version=1,parameters=parameters)] + self.capture('pipeline_graph',value) + made,_,_=self.fork() + self.assertFalse(is_intent(made['records'][0]['document']['current'])) + self.assertEqual(made['records'][0]['document']['current']['nodes'],value['nodes']) + + def test_known_task_does_not_adopt_unknown_binding_in_template_specification(self): + value=self.known();value['specification']['foreign_binding']=dict(catalog_id=self.source,material='b'*64) + self.capture('model_definition',value) + made,_,_=self.fork() + current=made['records'][0]['document']['current'] + self.assertTrue(is_intent(current)) + self.assertIn('specification',current['required_reselection']) + self.assertEqual(current['definition']['specification'],value['specification']) + + def test_two_overlays_cannot_bind_the_same_new_material_target(self): + self.capture('model_definition',self.known());other=self.capture('model_definition',self.known()) + _,combined,intermediate=self.fork() + _,nested,destination=self.fork(combined,intermediate,'b'*64) + earlier=next(r['document'] for r in nested if r['family']=='project_fork' and r['id']==intermediate) + latest=next(r['document'] for r in nested if r['family']=='project_fork' and r['id']==destination) + earlier['mapping'].append(dict(family='model_definition',source_id=other['id'],source_revision=999, + destination_id=latest['mapping'][0]['destination_id'])) + earlier['mapping'].sort(key=lambda m:(m['family'],m['source_id'],m['source_revision'],m['destination_id'])) + from project_fork import decode_fork + decode_fork(nested) # Structural overlay validity alone does not prove owner origins. + with self.assertRaises(WorkbenchError) as caught:decode_records(nested) + self.assertIn('target repeats',str(caught.exception)) + + def test_allocator_and_timestamp_are_explicit_and_bounded(self): + old=self.capture('model_definition',self.known()) + args=(self.session.snapshot['records'],self.source,self.checkpoint,uuid.uuid4().hex) + for factory,now in [(lambda *k:old['document']['current']['id'],NOW),(lambda *k:'x'*129,NOW),(lambda *k:uuid.uuid4().hex,'yesterday')]: + with self.assertRaises(WorkbenchError):fork_records(*args,id_factory=factory,created_at=now) + + def test_existing_nonfork_codec_still_requires_initial_owner_audit(self): + record=self.capture('model_definition',self.known());record['document']['audit']=[] + with self.assertRaises(WorkbenchError):decode_records([record]) + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'),'Requires disposable PostgreSQL') +class MaterialForkPostgres(unittest.TestCase): + def setUp(self): + self.fixture=material_fixture.CanonicalPublication('test_model_publication_is_canonical_before_ack_and_has_one_session_receipt') + self.fixture.setUp();self.addCleanup(self.fixture.doCleanups) + + def test_cold_fork_restores_only_resolved_drafts_and_real_successor_save_has_exact_audit(self): + from metadata import Metadata + from project_artifacts import ProjectArtifacts + from project_session import ProjectSession + from model_definitions import ModelDefinitions + f=self.fixture + pure=MaterialForkCodecs();pure.setUp();known=pure.known();known['task']['catalog_id']=f.db.dataset_id + body={k:v for k,v in known.items() if k not in ('format','id','revision')} + with f.access.bind(f.owner.token): + source=f.material.mutate_definition('model_definition','models.create',None,body,f.session.snapshot['checkpoint'],'source-known',lambda:f.models.create(body)) + f.create() # Unknown task remains an explicit unresolved fork intent. + original=copy.deepcopy(f.session.snapshot);root_fd=os.open(f.path,DIR_FLAGS) + self.addCleanup(lambda:os.close(root_fd)) + def build(snap,dest): + made=fork_records(snap['records'],snap['project_id'],snap['checkpoint'],dest,id_factory=lambda *k:uuid.uuid4().hex,created_at=NOW) + return dict(records=snap['records']+made['records'],mapping=made['mapping'], + archived=sorted([dict(family=r['family'],id=r['id']) for r in snap['records'] if r['family'] in ARCHIVE_FAMILIES],key=lambda r:(r['family'],r['id'])),artifacts=[]) + result=publish_fork(f.store,f.artifacts,root_fd,'independent',root_path=f.path,name='Independent material', + expected_checkpoint=original['checkpoint'],request_key='fork-real',quiescent=True,build_candidate=build, + validate_candidate=lambda records,identity:decode_records(records)) + from project_store import ProjectStore + store=ProjectStore.open(f.path/'independent');f.resources.append(store) + artifacts=ProjectArtifacts(store);f.resources.append(artifacts) + db=Metadata(f.path/'fork-projection',dataset_id=result['snapshot']['project_id'],projection_id=uuid.uuid4().hex) + f.resources.append(db);f.schemas.append(db.schema) + session=ProjectSession(store,db,f.access,artifacts,validate_records=f.validate) + reader=ProjectMaterial(session);reader.restore() + self.assertEqual(db.execute('SELECT count(*) FROM model_definition_drafts').fetchone()[0],1) + editable=next(r for r in active_records(session.snapshot['records'],project_id=db.dataset_id) + if r['family']=='model_definition' and not is_intent(r['document']['current'])) + draft=editable['document']['current'] + with f.access.bind(f.owner.token):self.assertEqual(len(reader.intents()),1) + dataset=SimpleNamespace(db=db,lock=db.lock,access=reader.owner_access,path=db.path,pipelines=f.dataset.pipelines) + models=ModelDefinitions(dataset) + with f.access.bind(f.owner.token): + changed=dict(draft,name='Edited independent fork');savebody=dict(revision=1,document=changed) + saved=reader.mutate_definition('model_definition','models.save',draft['id'],savebody,session.snapshot['checkpoint'], + 'fork-save',lambda:models.save(draft['id'],savebody)) + self.assertEqual(saved['revision'],2) + doc=next(r['document'] for r in session.snapshot['records'] if r['family']=='model_definition' and r['id']==editable['id']) + self.assertEqual([a['action'] for a in doc['audit']],['models.save']) + self.assertEqual(doc['revisions'][0],draft) + self.assertEqual(f.store.snapshot(),original) + self.assertEqual(next(r for r in session.snapshot['records'] if r['id']==f.db.dataset_id+'.'+source['id']), + next(r for r in original['records'] if r['id']==f.db.dataset_id+'.'+source['id'])) + self.assertEqual(session.snapshot['project_id'],db.dataset_id) + + def test_reconcile_projection_preserves_custody_foreign_keys_and_refuses_immutable_conflicts(self): + f=self.fixture + with f.access.bind(f.owner.token): + draft=f.create() + published=f.material.publish_model(f.models,draft['id'],{'revision':1},f.session.snapshot['checkpoint'],'model-for-custody') + f.db.execute('CREATE TABLE fixture_retained_custody (id TEXT PRIMARY KEY, material_sha256 TEXT REFERENCES model_materials(sha256))') + f.db.execute('INSERT INTO fixture_retained_custody VALUES(%s,%s)',('drained-native-job',published['ref']['sha256'])) + stale=dict(draft,name='SQL change never canonically acknowledged') + from workbench import encode + f.db.execute('UPDATE model_definition_drafts SET document=%s WHERE id=%s',(encode(stale),draft['id'])) + unrelated=dict(draft,id=uuid.uuid4().hex,name='Local custody-owned draft') + f.db.execute('INSERT INTO model_definition_drafts VALUES(%s,%s,%s)',(unrelated['id'],1,encode(unrelated))) + with f.access.lock,f.session.lock,f.db.lock: + f.material.reconcile_projection() + self.assertEqual(f.db.execute('SELECT id FROM fixture_retained_custody').fetchall(),[('drained-native-job',)]) + self.assertEqual(f.db.execute('SELECT count(*) FROM model_definition_drafts').fetchone()[0],2) + from model_definitions import retained_json + self.assertEqual(retained_json(f.db.execute('SELECT document FROM model_definition_drafts WHERE id=%s',(draft['id'],)).fetchone()[0]),draft) + self.assertEqual(retained_json(f.db.execute('SELECT document FROM model_materials WHERE sha256=%s',(published['ref']['sha256'],)).fetchone()[0]),published) + f.db.execute('UPDATE model_definition_drafts SET document=%s WHERE id=%s',(encode(stale),draft['id'])) + f.db.execute('UPDATE model_materials SET document=%s WHERE sha256=%s',(encode({}),published['ref']['sha256'])) + with f.access.lock,f.session.lock,f.db.lock: + with self.assertRaises(WorkbenchError) as caught:f.material.reconcile_projection() + self.assertEqual(caught.exception.code,'projection_unavailable') + self.assertEqual(retained_json(f.db.execute('SELECT document FROM model_definition_drafts WHERE id=%s',(draft['id'],)).fetchone()[0]),stale) + self.assertEqual(f.db.execute('SELECT count(*) FROM fixture_retained_custody').fetchone()[0],1) + self.assertEqual(f.store.snapshot(),f.session.snapshot) diff --git a/tests/test_project_native_clients.py b/tests/test_project_native_clients.py new file mode 100644 index 00000000..0a336d04 --- /dev/null +++ b/tests/test_project_native_clients.py @@ -0,0 +1,131 @@ +"""Finite native transport boundaries; no claim of native process execution.""" +import copy +from types import SimpleNamespace +import unittest +from unittest.mock import Mock +import agent_api as api +import project_cli +import project_dispatch +from tuldok_client import ProjectFilesClient +from workbench import WorkbenchError +from test_project_files_m4_clients import ready,bound,READ,WRITE,PROJECT,ACTOR,CP,NEXT,KEY + +GRAPH='1'*32;RUN='2'*32;PHASE='3'*32 +ACTIONS=('native_preflight','native_submit','native_retry','native_reconcile') +def validation(): + return dict(status='valid',valid=True,errors=[],validation_token=CP,graph_revision=2, + execution_sha256=NEXT,release={'id':CP},runtime={'available':True}) +def phase(): + return dict(format='project_native_phase_v1',origin_catalog_id=PROJECT,phase_id=PHASE, + phase='probe',run_id=None,state='failed',spec_sha256=CP,receipt_sha256=NEXT) +def admission(): + return dict(format='project_run_admission_v1',origin_catalog_id=PROJECT,run_id=RUN,request_sha256=NEXT) +def client(value,owner=True): + raw=ready(dict(status=200,result=bound(value)),owner=owner) + raw._selected.update({'project_files.'+name:1 for name in ACTIONS});return raw + +class NativeClients(unittest.TestCase): + def test_four_closed_operations_current_owner_and_named_consumers(self): + for action in ACTIONS: + name='project_files.'+action + self.assertIn(name,api.PROJECT_FILES_M3);self.assertIn(name,project_dispatch.SUPPORTED_OPERATIONS) + self.assertEqual(api.contract(name)['authority'],'owner') + self.assertEqual(api.contract(name)['scopes'],['inspect','cancel' if action=='native_reconcile' else 'execute']) + self.assertTrue(callable(getattr(ProjectFilesClient(Mock()),action))) + project_cli.parser().parse_args(['--config','/not-read','file-'+action.replace('_','-'),'--arguments','/not-read']) + args=dict(WRITE,graph_id=GRAPH,revision=2) + api.validate_arguments('project_files.native_preflight',args) + for change in ({'runtime_snapshot_sha256':CP},{'cache':{}},{'serialized_validation':validation()},{'command':['python']}): + with self.assertRaises(api.AgentAPIError):api.validate_arguments('project_files.native_preflight',{**args,**change}) + args=dict(binding=READ['binding'],expected_checkpoint=CP,phase_id=PHASE) + api.validate_arguments('project_files.native_reconcile',args) + with self.assertRaises(api.AgentAPIError):api.validate_arguments('project_files.native_reconcile',dict(args,request_key=KEY)) + self.assertEqual(api.contract('project_files.native_reconcile')['retry'],'never') + raw=client(validation(),owner=False);raw._selected.pop('project_files.native_preflight') + with self.assertRaises(api.AgentAPIError):raw.call('project_files.native_preflight',dict(WRITE,graph_id=GRAPH,revision=2)) + raw._request.assert_not_called() + + def test_validation_admission_and_original_phase_identity_are_correlated(self): + raw=client(validation());sdk=ProjectFilesClient(raw);args=dict(WRITE,graph_id=GRAPH,revision=2) + sdk.native_preflight(**args) + for change in ({'graph_revision':3},{'validation_token':None},{'valid':False},{'cache':{}},{'phase_ref':dict(phase(),origin_catalog_id=ACTOR)}): + raw._request.return_value=dict(status=200,result=bound(dict(validation(),**change))) + with self.assertRaises(api.AgentAPIError):sdk.native_preflight(**args) + raw._request.return_value=dict(status=200,result=bound(admission())) + sdk.native_submit(**WRITE,graph_id=GRAPH,graph_revision=2,validation_token=CP) + raw._request.return_value=dict(status=200,result=bound(dict(admission(),origin_catalog_id=ACTOR))) + with self.assertRaises(api.AgentAPIError):sdk.native_retry(**WRITE,run_id=RUN) + raw._request.return_value=dict(status=200,result=bound(admission())) + with self.assertRaises(api.AgentAPIError):sdk.native_retry(**WRITE,run_id=RUN) + raw._request.return_value=dict(status=200,result=bound(phase())) + sdk.native_reconcile(binding=READ['binding'],expected_checkpoint=CP,phase_id=PHASE) + for change in ({'phase_id':GRAPH},{'origin_catalog_id':ACTOR},{'state':'running'},{'grant':{}}): + raw._request.return_value=dict(status=200,result=bound(dict(phase(),**change))) + with self.assertRaises(api.AgentAPIError):sdk.native_reconcile(binding=READ['binding'],expected_checkpoint=CP,phase_id=PHASE) + + def test_preflight_truth_fields_refuse_contradictions_and_nonstring_hashes(self): + args=dict(WRITE,graph_id=GRAPH,revision=2);raw=client(validation());sdk=ProjectFilesClient(raw) + failure=dict(status='unavailable',valid=False,errors=[dict(path='',message='Current CPU is unavailable',code='unavailable')],validation_token=None,release=None,runtime={'available':False}) + invalid=[ + dict(status='invalid',valid=True,errors=[7],release=None,runtime=None), + dict(status='invalid'),dict(errors=[dict(path='runtime',message='Unavailable',code='unavailable')]), + dict(release=None),dict(runtime=None),dict(runtime={'available':False}),dict(runtime={'available':1}), + dict(valid=False,status='invalid',errors=[],validation_token=None), + dict(failure,errors=[7]),dict(failure,status='other'),dict(failure,errors=[dict(path='',message='bad',code='unavailable',extra=True)]), + dict(failure,errors=[dict(path=7,message='bad',code='unavailable')]), + dict(failure,errors=[dict(path='',message='x'*2001,code='unavailable')]), + dict(failure,errors=failure['errors']*101), + dict(execution_sha256=int('7'*64)),dict(validation_token=int('7'*64)), + dict(execution_sha256=True),dict(validation_token=True), + ] + for change in invalid: + with self.subTest(change=change): + raw._request.return_value=dict(status=200,result=bound(dict(validation(),**change))) + with self.assertRaises(api.AgentAPIError) as caught:sdk.native_preflight(**args) + self.assertEqual(caught.exception.code,'transport') + for value in (validation(),dict(validation(),**failure)): + raw._request.return_value=dict(status=200,result=bound(value));self.assertEqual(sdk.native_preflight(**args)['value'],value) + + def test_dispatch_uses_only_four_fixed_current_execution_seams(self): + owner=Mock();projection=SimpleNamespace(session=SimpleNamespace(access=SimpleNamespace(require=Mock())),execution=owner) + project_dispatch.dispatch(projection,'project_files.native_preflight',dict(WRITE,graph_id=GRAPH,revision=2)) + owner.native_preflight.assert_called_once_with(GRAPH,2,CP,KEY) + project_dispatch.dispatch(projection,'project_files.native_submit',dict(WRITE,graph_id=GRAPH,graph_revision=2,validation_token=NEXT)) + owner.native_submit.assert_called_once_with(dict(graph_id=GRAPH,graph_revision=2,validation_token=NEXT),CP,KEY) + project_dispatch.dispatch(projection,'project_files.native_retry',dict(WRITE,run_id=RUN));owner.native_retry.assert_called_once_with(RUN,CP,KEY) + project_dispatch.dispatch(projection,'project_files.native_reconcile',dict(binding=READ['binding'],expected_checkpoint=CP,phase_id=PHASE));owner.native_reconcile.assert_called_once_with(PHASE,CP) + + def test_health_v2_adds_independent_bounded_current_phase_page_preserving_v1(self): + self.assertEqual(api.contract('project_files.execution_health',1)['sha256'],'c3b1d0017f5a49cf69c3a4c5c33e1d3db9846404d12627563d97ef40de7404a5') + legacy=dict(format='project_execution_health_v1',active_run_ids=[],active_count=0,offset=1,limit=1,local_owner_active=False,available=False,drain_required=True) + value=dict(legacy,native_phases=dict(items=[phase()],total=2,offset=1,limit=1)) + raw=client(value);raw._selected['project_files.execution_health']=2;sdk=ProjectFilesClient(raw) + sdk.execution_health(**READ,offset=1,limit=1) + for change in ({'offset':0},{'total':1},{'items':[dict(phase(),origin_catalog_id=ACTOR)]}): + raw._request.return_value=dict(status=200,result=bound(dict(value,native_phases={**value['native_phases'],**change}))) + with self.assertRaises(api.AgentAPIError):sdk.execution_health(**READ,offset=1,limit=1) + raw._selected['project_files.execution_health']=1 + raw._request.return_value=dict(status=200,result=bound(legacy));sdk.execution_health(**READ,offset=1,limit=1) + raw._request.return_value=dict(status=200,result=bound(value)) + with self.assertRaises(api.AgentAPIError):sdk.execution_health(**READ,offset=1,limit=1) + + def test_recipe_kind_is_inferred_and_contradictory_selector_refuses_before_effects(self): + import pipeline + pipelines=Mock();materials=Mock() + projection=SimpleNamespace(session=SimpleNamespace(access=SimpleNamespace(require=Mock())),materials=materials, + context=SimpleNamespace(models=Mock(),pipelines=pipelines),runtime_manifest=Mock()) + args=dict(WRITE,graph_id=GRAPH,revision=2,model={'kind':'model','catalog_id':PROJECT,'id':ACTOR,'revision':1,'sha256':CP}) + for recipe,builtin in (('external_recipe',False),(pipeline.MODEL_RECIPES[0] if isinstance(pipeline.MODEL_RECIPES,tuple) else next(iter(pipeline.MODEL_RECIPES)),True)): + pipelines.get.return_value={'id':GRAPH,'recipe':recipe};materials.reset_mock() + project_dispatch.dispatch(projection,'project_files.recipe_prepare',args) + self.assertIs(materials.prepare_recipe.call_args.kwargs['builtin'],builtin) + materials.reset_mock() + with self.assertRaises(WorkbenchError) as failure:project_dispatch.dispatch(projection,'project_files.recipe_prepare',dict(args,builtin=not builtin)) + self.assertEqual(failure.exception.code,'conflict') + materials.prepare_recipe.assert_not_called() + pipelines.get.return_value={'id':GRAPH,'recipe':'release_validation'};materials.reset_mock() + with self.assertRaises(WorkbenchError) as failure:project_dispatch.dispatch(projection,'project_files.recipe_prepare',args) + self.assertEqual(failure.exception.code,'unsupported') + materials.prepare_recipe.assert_not_called() + +if __name__=='__main__':unittest.main() diff --git a/tests/test_project_native_clients_http.py b/tests/test_project_native_clients_http.py new file mode 100644 index 00000000..eb5d2191 --- /dev/null +++ b/tests/test_project_native_clients_http.py @@ -0,0 +1,297 @@ +"""Actual native public transports, gated by an explicit immutable qualification. + +One real owner CPU probe is reused by SDK/CLI/MCP. A real deferrable SQL ACK +fault starts no worker GO; original local phase reconciliation never repeats GO. +There is no model fit, training Run, provider, or imported execution authority. +""" +from datetime import timedelta +import hashlib +from http.server import ThreadingHTTPServer +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import threading +import time +import unittest +from unittest.mock import patch +import uuid + +# Script-mode browser fixtures must find the repository without PYTHONPATH. +ROOT=Path(__file__).resolve().parents[1] +sys.path.insert(0,str(ROOT)) + +import psycopg +from psycopg import sql + +import agent_api as api +from app import make_handler +from auth_helpers import ExplicitOwnerFixture +from metadata import connection_info +from project_workspace import ProjectWorkspace +import project_workspace +from tuldok_client import ProjectFilesClient + +MANIFEST_ENV='TULDOK_NATIVE_PUBLIC_FREEZE_MANIFEST' + + +def frozen_subject(): + name=os.environ.get(MANIFEST_ENV) + if not name:raise AssertionError('Lead must provide '+MANIFEST_ENV+' before actual native qualification') + path=Path(name);value=json.loads(path.read_text()) + current={name:hashlib.sha256((ROOT/name).read_bytes()).hexdigest() for name in value['files']} + if current!=value['files']:raise AssertionError('Immutable native public subject changed') + return dict(manifest_sha256=hashlib.sha256(path.read_bytes()).hexdigest(),files=current) + + +class NativePublicFixture: + """Test-owned real host and optional ACK fault; production owners stay real.""" + def __init__(self,path,*,dsn=None,cold=False): + if os.environ.get('TULDOK_NATIVE_PROJECT_GO')!='1':raise AssertionError('Explicit native qualification GO required') + if not project_workspace.M3_ENABLED:raise AssertionError('M3 must be explicitly enabled in the qualified subject') + self.path=Path(path);self.path.mkdir(parents=True,exist_ok=True) + self.external=self.path/'external';self.external.mkdir(exist_ok=True) + self.control=self.path/'control';self.dsn=dsn or os.environ['TULDOK_DATABASE_URL'] + self.workspace=ProjectWorkspace(self.control,{'fixture':self.external},dsn=self.dsn) + self.owner=ExplicitOwnerFixture.bootstrap(self.workspace) + self.go_events=[];self.fired=[];self.forbidden=[];self.fault_db=None;self.original_before=None;self.patches=[] + import recipe_process + original_go=recipe_process.Handle.go + def observed(handle,helper): + projection=self.workspace._projection + self.go_events.append(dict(phase_id=handle.spec['attempt_id'],in_transaction=projection.db.in_transaction)) + if projection.db.in_transaction:raise AssertionError('Native GO occurred before outer SQL acknowledgement') + return original_go(handle,helper) + self.patches.append(patch.object(recipe_process.Handle,'go',new=observed));self.patches[-1].start() + if cold: + def forbidden(*args,**kwargs): + self.forbidden.append('native-prepare-or-Runs');raise AssertionError('Cold Open attempted native process or Runs construction') + for target in ('project_native_process.prepare','runs.Runs'): + self.patches.append(patch(target,side_effect=forbidden));self.patches[-1].start() + self.server=ThreadingHTTPServer(('127.0.0.1',0),make_handler(self.workspace)) + self.thread=threading.Thread(target=self.server.serve_forever,daemon=True);self.thread.start() + self.origin='http://127.0.0.1:'+str(self.server.server_port) + + @property + def projection(self):return self.workspace._projection + + def seed(self): + from test_project_native_study import prepare_text_source + with self.owner.bound(self.workspace): + projection=self.projection + if projection is None:raise AssertionError('Create the test project through real HTTP before seeding') + rows,release=prepare_text_source(projection,key_prefix='public-native-'+uuid.uuid4().hex) + body=dict(name='Native public text CPU fixture Ω',recipe='text_classification',release_id=release['id']) + graph=projection.materials.mutate_definition('pipeline_graph','graphs.create',None,body, + projection.session.snapshot['checkpoint'],'public-native-graph-'+uuid.uuid4().hex, + lambda:projection.pipelines.create(body)) + return dict(graph_id=graph['id'],revision=graph['revision'],release_id=release['id'],records=len(rows)) + + def install_fault(self): + if self.original_before is not None:raise AssertionError('Only one explicit fixture fault at a time') + projection=self.projection;db=projection.db;self.fault_db=db + with db.lock: + db.execute('CREATE TEMP TABLE public_native_ack_parent(id INT PRIMARY KEY)') + db.execute('CREATE TEMP TABLE public_native_ack_child(id INT REFERENCES public_native_ack_parent(id) DEFERRABLE INITIALLY DEFERRED)') + self.original_before=db._project_before_commit + original=self.original_before + def before(): + target=any(doc['events'][-1]['state']=='go_intent' for doc in projection.execution.native._dirty.values()) + original() + if target and not self.fired: + self.fired.append(True);db.execute('INSERT INTO public_native_ack_child VALUES(1)') + db._project_before_commit=before + return dict(installed=True,kind='real_deferrable_sql_after_canonical_go_intent') + + def clear_fault(self): + if self.original_before is not None: + self.fault_db._project_before_commit=self.original_before;self.original_before=None + + def facts(self): + p=self.projection + if p is None:return dict(go_events=list(self.go_events),fired=list(self.fired),forbidden=list(self.forbidden),selected=False) + with p.db.lock: + records=p.session.snapshot['records'] + return dict(go_events=list(self.go_events),fired=list(self.fired),forbidden=list(self.forbidden),selected=True, + available=p.session.available,workspace_runs=p.db.execute('SELECT count(*) FROM workspace_runs').fetchone()[0], + portable_phase_count=sum(r['family']=='native_execution_history' for r in records), + pending=p.custody.has_pending if p.custody is not None else False) + + def close(self): + self.clear_fault();self.server.shutdown();self.server.server_close();self.thread.join(5) + close_failure=None + try: + try:self.workspace.close() + except BaseException as error: + close_failure=error + # A failed assertion may leave an original ACK fence. Dispose + # only this fixture's actual bounded children and handles; + # never claim that public Close or reconciliation succeeded. + drainage=[] + for projection in (self.workspace._projection,self.workspace._retained_recovery): + if projection is None:continue + execution=projection.execution + if execution._runs is not None:raise AssertionError('This fixture must never admit a training Run') from error + if execution.native._active:raise AssertionError('Native request still active during fixture disposal') from error + for handle in tuple(execution.native._handles.values()): + receipt=handle.close_control() + if not receipt or not receipt.get('drained'):raise AssertionError('Fixture child drainage unconfirmed') from error + drainage.append(dict(drained=True,go_received=receipt.get('go_received'))) + if projection.sources is not None:projection.sources.close() + projection.artifacts.close();projection.db.close() + if self.workspace._store is not None:self.workspace._store.close() + for _path,descriptor in self.workspace._roots.values():os.close(descriptor) + self.workspace._roots.clear();self.workspace.db.close();self.workspace._closed=True + report=Path(os.environ.get('TULDOK_QA_OUTPUT_ROOT','/workspace/scratch/tuldok-save-load-qa/artifacts'))/'native-public-cleanup' + report.mkdir(parents=True,exist_ok=True) + (report/(uuid.uuid4().hex+'.json')).write_text(json.dumps(dict(public_close_succeeded=False, + close_error=type(error).__name__,fixture_children=drainage),indent=2)+'\n') + finally: + for context in reversed(self.patches):context.stop() + # Cleanup only manifests created below this fixture's own control. + with psycopg.connect(connection_info(self.dsn),autocommit=True) as connection: + for manifest in self.control.rglob('.tuldok.json'): + value=json.loads(manifest.read_bytes()) + schema='projection_'+value['projection_id'] if value['version']==2 else 'catalog_'+value['dataset_id'] + connection.execute(sql.SQL('DROP SCHEMA IF EXISTS {} CASCADE').format(sql.Identifier(schema))) + if close_failure is not None:raise close_failure + + +@unittest.skipUnless(os.environ.get('TULDOK_NATIVE_PROJECT_GO')=='1' and os.environ.get('TULDOK_DATABASE_URL'), + 'Requires explicit native qualification GO and disposable PostgreSQL') +class NativePublicClientsHTTP(unittest.TestCase): + def setUp(self): + self.subject=frozen_subject();self.started=time.monotonic();self.performed=[];self.completed=False + self.assertTrue(os.environ.get('TULDOK_TRAINING_PYTHON'),'Explicit current qualified CPU interpreter required') + self.temp=tempfile.TemporaryDirectory(prefix='tuldok-native-public-');self.addCleanup(self.temp.cleanup) + self.path=Path(self.temp.name);self.fixture=NativePublicFixture(self.path) + self.addCleanup(self.fixture.close);self.addCleanup(self.receipt) + self.owner=ProjectFilesClient(api.ProjectFilesOwnerClient(self.fixture.origin,self.fixture.owner.token,self.fixture.owner.catalog_id,timeout=60)) + self.environment={**os.environ,'PYTHONDONTWRITEBYTECODE':'1'} + + def receipt(self): + end={name:hashlib.sha256((ROOT/name).read_bytes()).hexdigest() for name in self.subject['files']} + directory=Path(os.environ.get('TULDOK_QA_OUTPUT_ROOT','/workspace/scratch/tuldok-save-load-qa/artifacts'))/'native-public-clients';directory.mkdir(parents=True,exist_ok=True) + (directory/(self._testMethodName+'-'+uuid.uuid4().hex+'.json')).write_text(json.dumps(dict(status='pass' if self.completed else 'failed', + test=self.id(),seconds=round(time.monotonic()-self.started,3),performed=self.performed,subject=self.subject,source_stable=end==self.subject['files'], + end_sha256=end,facts=self.fixture.facts(),fixture='Real owner HTTP/SDK/CLI/MCP; one CPU probe, real deferred SQL ACK fault with zero GO, no model training.'),indent=2)+'\n') + self.assertEqual(end,self.subject['files']) + + def bound(self,*,mutation=False,key=None): + state=self.owner.status() + return dict(binding=state['binding'],**({'expected_checkpoint':state['project']['checkpoint'],'request_key':key or uuid.uuid4().hex} + if mutation else {'checkpoint':state['project']['checkpoint']})) + + def config(self,name,token): + token_file=self.path/(name+'.token');token_file.write_text(token+'\n');token_file.chmod(0o600) + path=self.path/(name+'.json');path.write_text(json.dumps(dict(endpoint=self.fixture.origin,catalog_id=self.fixture.owner.catalog_id,token_file=str(token_file),timeout=60)));path.chmod(0o600) + return path + + def cli(self,config,action,args,*,owner=True,success=True): + file=self.path/('arguments-'+uuid.uuid4().hex+'.json');file.write_text(json.dumps(args)) + result=subprocess.run([sys.executable,str(ROOT/'project_cli.py'),'--config',str(config),*(['--owner-project-files'] if owner else []), + 'file-'+action.replace('_','-'),'--arguments',str(file)],cwd=ROOT,env=self.environment,capture_output=True,text=True,timeout=60) + self.assertEqual(result.returncode==0,success,result.stderr) + self.assertNotIn(self.fixture.owner.token,result.stdout+result.stderr) + return json.loads(result.stdout) if success else result + + @unittest.skipUnless(importlib.util.find_spec('mcp') and importlib.util.find_spec('anyio'),'Requires optional locked MCP dependencies') + def test_real_native_preflight_replay_zero_go_failure_and_original_phase_recovery(self): + import anyio + from mcp import ClientSession,StdioServerParameters + from mcp.client.stdio import stdio_client + state=self.owner.status();opened=self.owner.new(binding=state['binding'],expected_checkpoint=None,request_key=uuid.uuid4().hex, + root='fixture',directory='alpha',name='Native public actual transport Ω') + graph=self.fixture.seed();initial=self.bound(mutation=True) + args=dict(initial,graph_id=graph['graph_id'],revision=graph['revision']) + result=self.owner.native_preflight(**args);self.assertTrue(result['value']['valid'],result) + self.assertEqual(len(self.fixture.go_events),1);self.assertEqual(self.fixture.facts()['workspace_runs'],0) + replay=self.cli(self.config('owner',self.fixture.owner.token),'native_preflight',args) + self.assertEqual(replay,result);self.assertEqual(len(self.fixture.go_events),1) + self.performed.append('Real owner SDK CPU preflight; named owner CLI exact request replay without another GO') + with self.fixture.owner.bound(self.fixture.workspace): + agent=self.fixture.workspace.access.issue(dict(label='Native transport ordinary editor',scopes=['inspect','edit'])) + ordinary=ProjectFilesClient(api.AgentClient(self.fixture.origin,agent['token'],self.fixture.owner.catalog_id,timeout=60)) + denied=dict(self.bound(mutation=True),graph_id=graph['graph_id'],graph_revision=graph['revision'],validation_token=result['value']['validation_token']) + before=self.fixture.projection.session.snapshot + with self.assertRaises(api.AgentAPIError) as error:ordinary.native_submit(**denied) + self.assertEqual(error.exception.status,403);self.assertEqual(error.exception.code,'forbidden');self.assertEqual(self.fixture.projection.session.snapshot,before) + self.assertEqual(len(self.fixture.go_events),1);self.assertEqual(self.fixture.facts()['workspace_runs'],0) + negative=self.cli(self.config('agent',agent['token']),'native_submit',denied,owner=False,success=False) + self.assertEqual((json.loads(negative.stderr)['status'],json.loads(negative.stderr)['code']),(403,'forbidden')) + self.performed.append('Actual SDK and named CLI ordinary editor submission denied before probe/admission effects') + owner_config=self.config('mcp-owner',self.fixture.owner.token) + async def exercise(): + params=StdioServerParameters(command=sys.executable,args=[str(ROOT/'mcp_stdio.py'),'--project-files-owner','--config',str(owner_config)],cwd=str(ROOT),env=self.environment) + with (self.path/'mcp.stderr').open('w+') as diagnostics: + async with stdio_client(params,errlog=diagnostics) as (reader,writer): + async with ClientSession(reader,writer,read_timeout_seconds=timedelta(seconds=60)) as session: + await session.initialize();tools={tool.name:tool for tool in (await session.list_tools()).tools} + for action in ('native_preflight','native_submit','native_retry','native_reconcile','execution_health'): + self.assertIn('project_files_'+action,tools) + self.assertEqual(tools['project_files_execution_health'].outputSchema,api.operation('project_files.execution_health',2)['outputSchema']) + async def call(action,arguments,status=200): + response=await session.call_tool('project_files_'+action,arguments);envelope=response.structuredContent + self.assertEqual(envelope['status'],status,envelope);self.assertEqual(response.isError,status>=400) + api.validate_output('project_files.'+action,envelope,2 if action=='execution_health' else 1) + return envelope['result'] + self.assertEqual(await call('native_preflight',args),result);self.assertEqual(len(self.fixture.go_events),1) + stale=dict(args,request_key=uuid.uuid4().hex,graph_revision=graph['revision'],validation_token=result['value']['validation_token']);stale.pop('revision') + rejected=await call('native_submit',stale,status=409);self.assertEqual(rejected['code'],'stale_project') + await call('native_retry',dict(self.bound(mutation=True),run_id='9'*32),status=409) + self.assertEqual(len(self.fixture.go_events),1);self.assertEqual(self.fixture.facts()['workspace_runs'],0) + self.fixture.install_fault() + failed_args=dict(self.bound(mutation=True),graph_id=graph['graph_id'],revision=graph['revision']) + response=await session.call_tool('project_files_native_preflight',failed_args) + self.assertTrue(response.isError,response);self.assertEqual(response.structuredContent['status'],503,response.structuredContent) + api.validate_output('project_files.native_preflight',response.structuredContent) + self.assertEqual(self.fixture.fired,[True]);self.assertEqual(len(self.fixture.go_events),1) + self.fixture.clear_fault();current=self.bound() + health=await call('execution_health',current) + phases=health['value']['native_phases']['items'];failed=[p for p in phases if p['state'] in ('ready','go_intent','unknown')] + self.assertEqual(len(failed),1,phases);phase_id=failed[0]['phase_id'] + self.assertEqual(health['checkpoint'],current['checkpoint']) + cli_health=self.cli(owner_config,'execution_health',current) + self.assertEqual(cli_health,health) + unknown=self.cli(owner_config,'native_reconcile',dict(binding=current['binding'],expected_checkpoint=current['checkpoint'],phase_id='0'*32),success=False) + self.assertEqual(json.loads(unknown.stderr)['status'],409);self.assertEqual(len(self.fixture.go_events),1) + recovered=await call('native_reconcile',dict(binding=current['binding'],expected_checkpoint=current['checkpoint'],phase_id=phase_id)) + self.assertEqual(recovered['value']['state'],'failed');self.assertEqual(len(self.fixture.go_events),1) + self.assertFalse(self.fixture.facts()['pending']);self.assertTrue(self.fixture.projection.session.available) + self.performed.append('Actual owner MCP exact preflight replay, real deferred FK failure with zero additional GO, named CLI health v2 current phase and foreign-phase refusal; original owner MCP reconciliation') + anyio.run(exercise) + self.assertEqual(self.fixture.facts()['workspace_runs'],0) + from test_project_files_http import canonical_bytes + for raw in canonical_bytes(self.fixture.external/'alpha').values(): + self.assertNotIn(self.fixture.owner.token.encode(),raw);self.assertNotIn(agent['token'].encode(),raw) + self.completed=True + + +def serve_fixture(): + import argparse + parser=argparse.ArgumentParser();parser.add_argument('--serve',action='store_true');parser.add_argument('--root',type=Path,required=True);parser.add_argument('--cold',action='store_true');args=parser.parse_args() + frozen_subject();fixture=NativePublicFixture(args.root,cold=args.cold) + print(json.dumps(dict(origin=fixture.origin,control=str(fixture.control),external=str(fixture.external))),flush=True) + try: + for line in sys.stdin: + command=json.loads(line);action=command['action'] + try: + if action=='seed':value=fixture.seed() + elif action=='install_fault':value=fixture.install_fault() + elif action=='clear_fault':fixture.clear_fault();value={'cleared':True} + elif action=='facts':value=fixture.facts() + elif action=='stop':print(json.dumps(dict(id=command['id'],result={'stopped':True})),flush=True);break + else:raise AssertionError('Unknown fixture command') + print(json.dumps(dict(id=command['id'],result=value)),flush=True) + except BaseException as error: + print(json.dumps(dict(id=command['id'],error=type(error).__name__+': '+str(error))),flush=True) + finally:fixture.close() + + +if __name__=='__main__': + if '--serve' in sys.argv:serve_fixture() + else:unittest.main() diff --git a/tests/test_project_native_execution.py b/tests/test_project_native_execution.py new file mode 100644 index 00000000..18979e24 --- /dev/null +++ b/tests/test_project_native_execution.py @@ -0,0 +1,721 @@ +"""Finite native phase evidence and original-host custody; no imported launch.""" +import copy +import hashlib +import json +import os +from pathlib import Path +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch +import uuid +from urllib.error import HTTPError +from urllib.request import Request, urlopen + +import psycopg +import recipe_process +from pipeline import adapter_identity_for_graph, digest +from project_artifacts import ProjectArtifacts +from project_custody import ProjectCustody, NATIVE_PHASE_LIMIT, validate as validate_custody +from project_native_execution import ProjectNativeExecution, decode_records, phase_reference, classify_probe, OBSERVATIONS, FORMAT, FAMILY +from project_native_process import prepare +from project_session import ProjectSessionError +from project_store import ProjectStore +from workbench import WorkbenchError +import test_project_native_preparation as preparation_fixture +import test_project_study_execution as study_fixture +import test_project_files_http as http_fixture + + +class NativePhaseCodecs(unittest.TestCase): + def setUp(self): + self.temporary=tempfile.TemporaryDirectory(prefix='tuldok-native-phase-') + self.addCleanup(self.temporary.cleanup);self.path=Path(self.temporary.name) + self.store=ProjectStore.create(self.path/'project','Native phase fixture','new-native-phase','a'*64) + self.addCleanup(self.store.close) + self.artifacts=ProjectArtifacts(self.store);self.addCleanup(self.artifacts.close) + self.project=self.store.snapshot()['project_id'];self.phase=uuid.uuid4().hex + executable=str(Path('/usr/bin/python3').resolve()) + raw=Path(executable).read_bytes();self.source=b'print("inert codec fixture")\n' + self.prepared=prepare(self.path/'phase',phase='probe',attempt_id=self.phase,request_sha256='b'*64, + runtime=dict(executable=executable,executable_sha256=hashlib.sha256(raw).hexdigest(), + trusted_install_owner_uid=os.stat('/usr').st_uid, + files={executable:dict(sha256=hashlib.sha256(raw).hexdigest(),bytes=len(raw))}), + worker='text_training.py',sources={'text_training.py':self.source}) + definition={'text_training.py':hashlib.sha256(self.source).hexdigest()} + self.document=dict(format=FORMAT,origin_catalog_id=self.project,phase_id=self.phase, + phase='probe',run_id=None,recipe='text_classification',request_key='native-phase-fixture', + request_sha256='b'*64,actor_id='c'*32, + definition_identity=dict(definition_files=definition,adapter_definition_sha256=digest(definition)), + spec=self.prepared.spec,events=[self.event('prepared')],payloads=[], + observations=dict.fromkeys(OBSERVATIONS),probe_result=None) + self.payload('spec.json',recipe_process.encoded(self.prepared.spec),'execution_spec') + self.payload('source/text_training.py',self.source,'execution_source') + + @staticmethod + def event(state,helper=None,receipt_sha256=None): + return dict(state=state,at='2026-10-22T12:00:00+00:00',actor_id='c'*32,helper=helper,receipt_sha256=receipt_sha256) + + def payload(self,name,raw,role): + blob=dict(sha256=hashlib.sha256(raw).hexdigest(),bytes=len(raw)) + self.artifacts.publish_bytes(raw,dict(format='project_artifact_v1',**blob)) + self.document['payloads'].append(dict(name=name,blob=blob,role=role, + preservation='unique',required_for=['execution_proof'])) + for key,(filename,maximum) in OBSERVATIONS.items(): + if name==filename: + if key=='probe_stdout':self.document['probe_result']=classify_probe(self.document['recipe'],raw) + try:self.document['observations'][key]=raw.decode('utf8') if len(raw)<=maximum else None + except UnicodeError:self.document['observations'][key]=None + return blob + + def records(self): + return [dict(family=FAMILY,id=self.project+'.'+self.phase,document=copy.deepcopy(self.document)), + *[dict(family='project_artifact',id=edge['blob']['sha256'], + document=dict(format='project_artifact_v1',**edge['blob'])) for edge in self.document['payloads']]] + + def completed(self,stdout=None): + helper=recipe_process.identity(os.getpid()) + stdout=stdout if stdout is not None else recipe_process.encoded(dict(schema_version=1,available=True, + adapter=adapter_identity_for_graph(dict(recipe='text_classification')), + runtime=dict(python='3.12.fixture',device='cpu',threads=1, + scikit_learn='1.7.2',numpy='fixture',scipy='fixture',Pillow='fixture'),problems=[]))+b'\n' + stderr=b'' + receipt=dict(schema_version=1,attempt_id=self.phase,request_sha256='b'*64, + spec_sha256=hashlib.sha256(recipe_process.encoded(self.document['spec'])).hexdigest(), + helper=helper,go_received=True,spawn_closed=True,drained=True,echild=True, + outcome='completed',reason='completed',leader_returncode=0, + streams={name:dict(bytes=len(raw),sha256=hashlib.sha256(raw).hexdigest()) + for name,raw in (('stdout',stdout),('stderr',stderr))}) + self.payload('stdout.log',stdout,'measured_stream');self.payload('stderr.log',stderr,'measured_stream') + blob=self.payload('receipt.json',recipe_process.encoded(receipt),'custody_proof') + self.document['events']=[self.event('prepared'),self.event('ready',helper), + self.event('go_intent',helper),self.event('completed',helper,blob['sha256'])] + return helper,receipt + + def test_small_canonical_proofs_reject_semantic_tampering_and_classify_malformed_process_output(self): + self.completed();records=self.records() + forged=copy.deepcopy(records) + forged[0]['document']['observations']['drain_receipt']+=' ' + with self.assertRaisesRegex(WorkbenchError,'Canonical native observation differs'): + decode_records(forged) + for raw,outcome in ((b'package warning\n{}','invalid_observation'),(b'\xff','invalid_utf8'),(b'x'*32769,'too_large')): + self.document['payloads']=[edge for edge in self.document['payloads'] + if edge['name'] not in ('stdout.log','stderr.log','receipt.json')] + self.completed(raw) + self.assertEqual(self.document['probe_result']['outcome'],outcome) + self.assertEqual(decode_records(self.records())[self.project+'.'+self.phase],self.document) + + def test_prepared_and_measured_terminal_are_exact_inert_evidence(self): + self.assertEqual(decode_records(self.records(),artifacts=self.artifacts)[self.project+'.'+self.phase],self.document) + helper,receipt=self.completed();records=self.records() + with (patch('subprocess.Popen',side_effect=AssertionError('Historical phase launched')), + patch('project_native_execution.prepare',side_effect=AssertionError('Historical phase prepared'))): + decoded=decode_records(records,artifacts=self.artifacts) + self.assertEqual(decoded[self.project+'.'+self.phase],self.document) + self.assertEqual(phase_reference(self.document)['receipt_sha256'], + hashlib.sha256(recipe_process.encoded(receipt)).hexdigest()) + + def test_environment_source_and_terminal_receipt_tampering_are_rejected(self): + self.completed();records=self.records() + changes=(lambda value:value['spec']['env'].update(ACCESS_TOKEN='never-retain'), + lambda value:value['events'][-1].update(helper=recipe_process.identity(os.getpid())|{'pid':1}), + lambda value:value['payloads'].pop(1), + lambda value:value.update(unclassified=True), + lambda value:value['events'][-1].update(receipt_sha256='d'*64)) + for change in changes: + malformed=copy.deepcopy(records);change(malformed[0]['document']) + with self.assertRaises(WorkbenchError):decode_records(malformed,artifacts=self.artifacts) + + def test_historical_uid_and_original_paths_are_not_current_launch_authority(self): + self.document['spec']['trusted_install_owner_uid']=os.getuid() + self.document['payloads']=[edge for edge in self.document['payloads'] if edge['name']!='spec.json'] + self.payload('spec.json',recipe_process.encoded(self.document['spec']),'execution_spec') + with patch('subprocess.Popen',side_effect=AssertionError('No historical execution')): + self.assertEqual(len(decode_records(self.records(),artifacts=self.artifacts)),1) + with self.assertRaises(recipe_process.ProcessError):recipe_process.validate_spec(self.document['spec']) + + def test_declared_missing_phase_bytes_stay_inert_and_present_corruption_is_rejected(self): + self.completed();records=self.records() + source=next(edge['blob'] for edge in self.document['payloads'] if edge['name']=='source/text_training.py') + path=self.path/'project/artifacts/sha256'/source['sha256'] + path.chmod(0o600);path.write_bytes(b'x'*source['bytes']) + with self.assertRaisesRegex(WorkbenchError,'Present native evidence differs'): + decode_records(records,artifacts=self.artifacts) + for entry in (self.path/'project/artifacts/sha256').iterdir():entry.unlink() + with (patch('subprocess.Popen',side_effect=AssertionError('Missing historical bytes executed')), + patch('project_native_execution.prepare',side_effect=AssertionError('Missing bytes regenerated'))): + decoded=decode_records(records,artifacts=self.artifacts) + self.assertEqual(decoded[self.project+'.'+self.phase],self.document) + self.assertEqual(self.artifacts.status(dict(format='project_artifact_v1',**source))['state'],'missing') + + def test_native_journal_upgrade_binds_physical_phase_and_requires_terminal_drain_ack(self): + control=self.path/'host';control.mkdir() + owner=ProjectCustody(control,'e'*32);journal=owner.for_projection(self.store,'f'*32) + self.assertEqual(journal.snapshot()['format'],'project_local_custody_v1') + journal.native_prepared(self.document) + self.assertEqual(journal.snapshot()['format'],'project_local_custody_v2') + self.assertEqual(len(owner.pending(self.store)),1) + with self.assertRaises(WorkbenchError):journal.native_confirmed(self.phase) + journal.native_launch_intent(self.phase) + helper,receipt=self.completed();journal.native_launched(self.phase,helper) + ready=copy.deepcopy(self.document);ready['events']=ready['events'][:2];journal.native_observed(ready) + go=copy.deepcopy(self.document);go['events']=go['events'][:3];journal.native_observed(go) + fresh=owner.for_projection(self.store,'f'*32) + self.assertEqual(fresh.snapshot()['native_phases'][0]['state'],'go_intent') + bad=copy.deepcopy(self.document);bad['request_sha256']='0'*64 + with self.assertRaises(WorkbenchError):fresh.native_observed(bad) + fresh.native_observed(self.document);fresh.native_confirmed(self.phase) + self.assertFalse(owner.for_projection(self.store,'f'*32).has_pending) + self.assertEqual(owner.pending(self.store),[]) + + def test_native_admission_private_version_preserves_original_binding_without_imported_authority(self): + control=self.path/'host';control.mkdir() + owner=ProjectCustody(control,'e'*32);journal=owner.for_projection(self.store,'f'*32) + run=dict(id='1'*32,admission_id='2'*32,request_sha256='3'*64, + graph_sha256='4'*64,release_sha256='5'*64,runtime={'observed':'fixture'}) + journal.observe_native_admission(run) + current=owner.for_projection(self.store,'f'*32) + self.assertEqual(current.snapshot()['format'],'project_local_custody_v3') + self.assertEqual(current.snapshot()['native_admissions'][0]['phase_started'],False) + self.assertTrue(current.has_pending);self.assertEqual(len(owner.pending(self.store)),1) + changed=dict(run,admission_id='6'*32) + with self.assertRaises(WorkbenchError):current.observe_native_admission(changed) + malformed=current.snapshot();malformed['native_admissions'][0]['grant']='not-authority' + with self.assertRaises(WorkbenchError):validate_custody(malformed) + current.settle_native_admission(run['id']) + self.assertFalse(current.has_pending) + + def test_native_phase_capacity_covers_all_current_worker_phases_and_one_foreground(self): + from run_capacity import MAX_ATTEMPTS + control=self.path/'host';control.mkdir() + journal=ProjectCustody(control,'e'*32).for_projection(self.store,'f'*32) + value=journal.snapshot();value.update(format='project_local_custody_v3',native_admissions=[]) + entry=dict(phase_id='1'*32,phase='probe',run_id=None,request_sha256='2'*64, + spec_sha256='3'*64,state='prepared',helper=None,receipt_sha256=None) + value['native_phases']=[dict(entry,phase_id=f'{index+1:032x}') for index in range(3*MAX_ATTEMPTS+1)] + self.assertEqual(len(validate_custody(value)['native_phases']),NATIVE_PHASE_LIMIT) + value['native_phases'].append(dict(entry,phase_id='f'*32)) + with self.assertRaisesRegex(WorkbenchError,'Too many unresolved native phases'):validate_custody(value) + + def test_native_exposure_observation_is_portable_science_and_rejects_unclassified_fields(self): + from runs import project_run_projection + from test_project_history import legacy_run + run=legacy_run() + observation=dict(exposed=True,events=[dict(id='1'*32,record_id='2'*32,source_hash='3'*64, + reason='Actual prior exposure',run_id=run['id'],release_id=run['release_id'],created_at=run['created_at'])]) + run['execution_test_exposure']=observation + projected=project_run_projection(run) + self.assertEqual(projected['execution_test_exposure'],observation) + self.assertNotIn('worker',projected) + run['execution_test_exposure']['grant']='not-science' + with self.assertRaises(WorkbenchError):project_run_projection(run) + + def test_model_aware_job_evidence_must_match_run_even_when_all_template_hashes_are_consistent(self): + import audio_training + import image_training + import model_evidence + import one_fit_evidence + import text_training + for adapter in (text_training,audio_training,image_training): + parameters={key:rule['default'] for key,rule in adapter.PARAMETER_BOUNDS.items()} + if adapter is image_training:parameters.update(widths=[4,8,16],input_size=32,head='linear') + owner=model_evidence if adapter is image_training else one_fit_evidence.contract(adapter) + context=dict(material_sha256='1'*64,model_sha256='2'*64,adapter_sha256='3'*64, + profile=owner.profile(self.project,'2'*64,parameters,2)) + worker=Path(adapter.__file__).name;source=b'# Exact authored retained worker; never executed.\n' + definition={worker:hashlib.sha256(source).hexdigest()} + run=dict(id='4'*32,dataset_id=self.project,graph=dict(recipe=adapter.MODEL_RECIPE),graph_sha256='5'*64, + release_id='6'*64,release_sha256='6'*64,parameters=parameters,adapter=adapter.MODEL_ADAPTER, + runtime=dict(definition_files=definition),evidence_context=context) + def records_for(change=None): + phase=uuid.uuid4().hex;stage=self.path/('model-context-'+phase);output=stage/'output' + job=dict(schema_version=2,dataset_id=self.project,run_id=run['id'],graph_sha256=run['graph_sha256'], + release_id=run['release_id'],release_sha256=run['release_sha256'],release_path=str(self.path/'retained-release.zip'), + output_dir=str(output),parameters=parameters,parent_pid=1) + if adapter is image_training:job['evidence_context']=copy.deepcopy(context) + else:job.update(one_fit_evidence.job_for_run(run,output),release_path=str(self.path/'retained-release.zip')) + if change is not None:change(job) + # These forgeries pass the live adapter's well-formed job + # decoder. Every captured-file/template/spec SHA is freshly + # rebuilt, isolating the missing scientific relation to Run. + self.assertEqual(adapter._checked_job(job),job) + spec=self.prepared.spec + executable=spec['argv'][0] + runtime=dict(executable=executable,executable_sha256=spec['executable_sha256'], + trusted_install_owner_uid=spec['trusted_install_owner_uid'], + files={row['path']:{key:row[key] for key in ('sha256','bytes')} for row in spec['runtime_files']}) + prepared=prepare(stage,phase='train',attempt_id=phase,request_sha256='7'*64,runtime=runtime, + worker=worker,sources={worker:source},job=job,model_aware=True) + self.document=dict(format=FORMAT,origin_catalog_id=self.project,phase_id=phase,phase='train', + run_id=run['id'],recipe=adapter.MODEL_RECIPE,request_key='model-context-'+phase,request_sha256='7'*64, + actor_id='c'*32,definition_identity=dict(definition_files=definition,adapter_definition_sha256=digest(definition)), + spec=prepared.spec,events=[self.event('prepared')],payloads=[],observations=dict.fromkeys(OBSERVATIONS),probe_result=None) + self.payload('spec.json',recipe_process.encoded(prepared.spec),'execution_spec') + for item in prepared.spec['captured_files']: + name=str(Path(item['path']).relative_to(stage)) + self.payload(name,Path(item['path']).read_bytes(),'execution_spec' if name=='native-job-template.json' else 'execution_source') + records=self.records();records[0]['id']=self.project+'.'+phase + return records+[dict(family='run_history',id=self.project+'.'+run['id'],document={'run':copy.deepcopy(run)})] + with self.subTest(adapter=adapter.MODEL_RECIPE),patch('subprocess.Popen',side_effect=AssertionError('Retained job decoder launched')): + self.assertEqual(len(decode_records(records_for())),1) + if adapter is image_training: + changes=[lambda job:job['evidence_context'].update(model_sha256='8'*64), + lambda job:job['evidence_context']['profile']['probe_selection'].update(count=1)] + else: + changes=[lambda job,key=key:job['evidence'].update({key:'8'*64}) for key in + ('model_sha256','material_sha256','adapter_sha256','profile_sha256','selection_policy_sha256','output_policy_sha256')] + def downgraded(job): + job['schema_version']=1;job.pop('adapter');job.pop('evidence') + changes.append(downgraded) + for change in changes: + with self.assertRaisesRegex(WorkbenchError,'exact scientific Run inputs'):decode_records(records_for(change)) + + +class NativeOutputAnchors(unittest.TestCase): + def test_stage_output_and_leaf_symlinks_and_directory_replacement_never_receive_provenance(self): + with tempfile.TemporaryDirectory(prefix='native-output-anchor-') as temporary: + root=Path(temporary);phase='1'*32;run_id='2'*32 + output=root/'project-native-phases'/phase/'output';output.mkdir(parents=True) + foreign=root/'foreign';foreign.mkdir();(foreign/'model.json').write_bytes(b'foreign') + recorded=[] + native=ProjectNativeExecution(SimpleNamespace(projection=None,session=SimpleNamespace(snapshot={'records':[]}), + db=SimpleNamespace(path=root,dataset_id='3'*32),runtime_access=None)) + native._retain=lambda document,name,raw,role:recorded.append((name,raw,role)) + document=dict(phase_id=phase,phase='train',run_id=run_id,spec=dict(output_dir=str(output),max_output_bytes=1024)) + run=dict(graph=dict(recipe='text_classification')) + output.rmdir();output.symlink_to(foreign,target_is_directory=True) + with self.assertRaisesRegex(WorkbenchError,'stage is unavailable'):native._retain_outputs(document,run=run) + output.unlink();output.mkdir();(output/'model.json').symlink_to(foreign/'model.json') + with self.assertRaisesRegex(WorkbenchError,'stage is unavailable'):native._retain_outputs(document,run=run) + (output/'model.json').unlink();(output/'model.json').write_bytes(b'original') + original_read=os.read;replaced=[] + def read(descriptor,maximum): + raw=original_read(descriptor,maximum) + if not replaced: + replaced.append(True);output.rename(output.parent/'original-output') + output.symlink_to(foreign,target_is_directory=True) + return raw + with patch('project_native_execution.os.read',side_effect=read),self.assertRaises(WorkbenchError): + native._retain_outputs(document,run=run) + self.assertEqual(recorded,[]) + output.unlink();(output.parent/'original-output').rename(output) + native._retain_outputs(document,run=run) + self.assertEqual(recorded,[('output/model.json',b'original','measured_output')]) + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'),'Requires disposable PostgreSQL') +class NativeProbeBoundaryTests(unittest.TestCase): + """Actual child/native probe; authored release summary only, no model fit.""" + setUp=study_fixture.ProjectStudyControlTests.setUp + cleanup=study_fixture.ProjectStudyControlTests.cleanup + metadata=study_fixture.ProjectStudyControlTests.metadata + open_projection=study_fixture.ProjectStudyControlTests.open_projection + graph=preparation_fixture.NativePreparationTests.graph + release=preparation_fixture.NativePreparationTests.release + + def install_journal(self): + projection=self.projection + owner=ProjectCustody(self.host.db.path,self.host.db.dataset_id) + projection.custody=projection.execution.custody=owner.for_projection(self.store,projection.db.projection_id) + projection.session.publication_intent=projection.execution._publication_intent + return owner + + def deferred_failure(self,state): + projection=self.projection;db=projection.db;execution=projection.execution + db.execute('CREATE TEMP TABLE native_ack_parent(id INT PRIMARY KEY)') + db.execute('CREATE TEMP TABLE native_ack_child(id INT REFERENCES native_ack_parent(id) DEFERRABLE INITIALLY DEFERRED)') + original=execution.before_commit;fired=[] + def before(): + target=any(doc['events'][-1]['state']==state for doc in execution.native._dirty.values()) + original() + if target and not fired: + fired.append(True);db.execute('INSERT INTO native_ack_child VALUES(1)') + # Fault injection wraps the installed owner; production correctly + # refuses a second owner through set_project_hooks. + self.enterContext(patch.object(db,'_project_before_commit',new=before)) + return fired + + def test_never_entered_launch_can_settle_but_restarted_ambiguous_spawn_window_remains_unknown(self): + self.install_journal();projection=self.projection;native=projection.execution.native + with self.owner.bound(self.host),patch.dict(os.environ,{'TULDOK_TRAINING_PYTHON':'/usr/bin/python3'}): + graph=self.graph();original=native._dispatch_authorized;stopped=[] + def dispatch(identity): + original(identity) + entry=next((row for row in projection.custody.snapshot().get('native_phases',[]) if row['phase_id']==identity),None) + if entry is not None and entry['state']=='launch_intent': + stopped.append(identity);raise WorkbenchError('Controlled authority loss before entering launch.','unavailable',409) + with patch.object(projection.pipelines,'inspect_release',side_effect=self.release), \ + patch.object(native,'_dispatch_authorized',side_effect=dispatch), \ + patch('subprocess.Popen',side_effect=AssertionError('Never-entered launch constructed a child')): + with self.assertRaisesRegex(WorkbenchError,'Controlled authority loss'): + projection.execution.native_preflight(graph['id'],1,projection.session.snapshot['checkpoint'],'native-never-entered') + phase=stopped[0] + # An independently restarted owner has no private proof that the + # launch call was not entered after the durable intent. + original_native=ProjectNativeExecution(projection.execution) + with patch.object(projection,'reconcile_inert',side_effect=AssertionError('Unknown spawn reconstructed')): + with self.assertRaisesRegex(WorkbenchError,'helper drainage identity'): + original_native.reconcile(phase,projection.session.snapshot['checkpoint']) + # Its original process retains the exact before-call proof. + with patch('subprocess.Popen',side_effect=AssertionError('Never-launched recovery launched')): + result=native.reconcile(phase,projection.session.snapshot['checkpoint']) + self.assertEqual(result['state'],'failed');self.assertIsNone(result['receipt_sha256']) + self.assertFalse(projection.custody.has_pending);projection.execution.assert_quiescent() + + def test_generic_reconciliation_refuses_native_phase_before_recovery_or_reconstruction(self): + self.install_journal();projection=self.projection + with self.owner.bound(self.host): + with patch.object(projection.custody,'snapshot',return_value={'native_phases':[dict(phase_id='1'*32)]}), \ + patch.object(self.store,'recover_checkpoint',side_effect=AssertionError('Generic native recovery touched checkout')), \ + patch.object(projection,'reconcile_inert',side_effect=AssertionError('Generic native recovery reconstructed')): + for action in (projection.execution.reconcile_current, + lambda:projection.execution.reconcile_original(expected_checkpoint=projection.session.snapshot['checkpoint'])): + with self.assertRaisesRegex(WorkbenchError,'native_reconcile') as error:action() + self.assertEqual(error.exception.code,'unavailable') + + def test_never_launched_failed_phase_sql_ack_loss_reconciles_exact_terminal_fact_without_popen(self): + """A second failure must not strand a proven non-process intent.""" + self.install_journal();projection=self.projection;native=projection.execution.native + with self.owner.bound(self.host),patch.dict(os.environ,{'TULDOK_TRAINING_PYTHON':'/usr/bin/python3'}), \ + patch('subprocess.Popen',side_effect=AssertionError('Never-launched double-fault spawned a child')): + graph=self.graph();original=native._dispatch_authorized;stopped=[] + def refuse_before_launch(identity): + original(identity) + entry=next((row for row in projection.custody.snapshot().get('native_phases',[]) if row['phase_id']==identity),None) + if entry is not None and entry['state']=='launch_intent': + stopped.append(identity) + raise WorkbenchError('Controlled refusal before launch entry.','unavailable',409) + with patch.object(projection.pipelines,'inspect_release',side_effect=self.release), \ + patch.object(native,'_dispatch_authorized',side_effect=refuse_before_launch): + with self.assertRaisesRegex(WorkbenchError,'Controlled refusal'): + projection.execution.native_preflight(graph['id'],graph['revision'], + projection.session.snapshot['checkpoint'],'native-never-launched-double-fault') + self.assertEqual(len(stopped),1);phase=stopped[0] + self.assertIn(phase,native._never_launched) + fired=self.deferred_failure('failed') + with self.assertRaises(psycopg.errors.ForeignKeyViolation): + native.reconcile(phase,projection.session.snapshot['checkpoint']) + self.assertEqual(fired,[True]);self.assertFalse(projection.session.available) + terminal=self.store.snapshot() + document=next(row['document'] for row in terminal['records'] if row['family']==FAMILY) + self.assertEqual(document['events'][-1]['state'],'failed') + self.assertIsNone(document['events'][-1]['helper']) + self.assertIsNone(document['events'][-1]['receipt_sha256']) + self.assertTrue(all(value is None for value in document['observations'].values())) + pending=projection.custody.snapshot()['native_phases'] + self.assertEqual(len(pending),1);self.assertEqual(pending[0]['state'],'launch_intent') + self.assertIn(phase,native._never_launched) + projection.db._project_before_commit=projection.execution.before_commit + result=native.reconcile(phase,projection.session.snapshot['checkpoint']) + self.assertEqual(result['state'],'failed');self.assertIsNone(result['receipt_sha256']) + self.assertTrue(projection.session.available);self.assertFalse(projection.custody.has_pending) + self.assertNotIn(phase,native._never_launched) + self.assertEqual(self.store.snapshot(),terminal,'Recovery duplicated or changed the acknowledged terminal science') + self.assertEqual(projection.db.execute('SELECT count(*) FROM workspace_runs').fetchone()[0],0) + self.assertEqual(projection.db.execute('SELECT count(*) FROM access_admissions').fetchone()[0],0) + projection.execution.assert_quiescent() + + def test_second_foreground_probe_refuses_before_preparation_without_blocking_current_owner(self): + native=self.projection.execution.native + native._foreground_phase='f'*32 + try: + with patch('project_native_execution.prepare',side_effect=AssertionError('Second foreground prepared')): + with self.assertRaisesRegex(WorkbenchError,'Another explicit native preparation'): + native._run_phase('probe','text_classification','second-probe',None,{},None) + self.assertEqual(native._foreground_phase,'f'*32) + finally:native._foreground_phase=None + + def _admission_ack_failure(self): + from runs import Runs + from test_project_native_study import prepare_text_source + projection=self.projection;self.install_journal() + _,release=prepare_text_source(projection,key_prefix='native-no-dispatch') + body=dict(name='Never-dispatched current admission',recipe='text_validate_export',release_id=release['id']) + graph=projection.materials.mutate_definition('pipeline_graph','graphs.create',None,body, + projection.session.snapshot['checkpoint'],'no-dispatch-graph',lambda:projection.pipelines.create(body)) + validation=projection.execution.native_preflight(graph['id'],1,projection.session.snapshot['checkpoint'],'no-dispatch-validation') + self.assertTrue(validation['valid'],validation) + projection.db.execute('CREATE TEMP TABLE native_admit_parent(id INT PRIMARY KEY)') + projection.db.execute('CREATE TEMP TABLE native_admit_child(id INT REFERENCES native_admit_parent(id) DEFERRABLE INITIALLY DEFERRED)') + original=Runs._admit + def admit(owner,*args,**kwargs): + result=original(owner,*args,**kwargs) + owner.db.execute('INSERT INTO native_admit_child VALUES(1)') + return result + with patch.object(Runs,'_admit',new=admit),patch('subprocess.Popen',side_effect=AssertionError('Unacknowledged native admission dispatched')): + with self.assertRaises(ProjectSessionError) as failure: + projection.execution.native_submit(dict(graph_id=graph['id'],graph_revision=1, + validation_token=validation['validation_token']),projection.session.snapshot['checkpoint'],'no-dispatch-admit') + self.assertIsInstance(failure.exception.__cause__,psycopg.errors.ForeignKeyViolation) + self.assertEqual(failure.exception.code,'projection_unavailable') + journal=projection.custody.snapshot() + self.assertEqual(journal['format'],'project_local_custody_v3') + self.assertEqual(len(journal['native_admissions']),1);self.assertEqual(journal['native_phases'],[]) + self.assertFalse(journal['native_admissions'][0]['phase_started']) + self.assertEqual(projection.db.execute('SELECT count(*) FROM workspace_runs').fetchone()[0],0) + self.assertEqual(projection.db.execute('SELECT count(*) FROM access_admissions').fetchone()[0],0) + self.assertFalse(projection.session.available) + with self.assertRaises(WorkbenchError):projection.execution.assert_quiescent() + return journal + + def test_native_admission_sql_ack_failure_clears_only_current_non_effect_intent_without_authority(self): + with self.owner.bound(self.host): + self._admission_ack_failure();projection=self.projection + before=self.store.snapshot() + with patch('subprocess.Popen',side_effect=AssertionError('Current admission reconciliation dispatched')): + result=projection.execution.reconcile_current() + self.assertTrue(result['available']);self.assertFalse(projection.custody.has_pending) + self.assertEqual(self.store.snapshot(),before) + retained=next(row['document']['run'] for row in before['records'] if row['family']=='run_history') + self.assertEqual(retained['state'],'queued') + self.assertEqual(projection.db.execute('SELECT count(*) FROM access_admissions').fetchone()[0],0) + projection.execution.assert_quiescent();projection.close() + + def test_native_admission_sql_ack_failure_original_physical_reopen_stays_inert_until_exact_clear(self): + from metadata import Metadata + from project_projection import ProjectProjection + with self.owner.bound(self.host): + self._admission_ack_failure();previous=self.projection + path=previous.db.path;identity=previous.db.projection_id + previous.sources.close();previous.artifacts.close();previous.db.close() + self.projections.remove(previous) + db=Metadata(path,create=False,dataset_id=self.store.snapshot()['project_id'],projection_id=identity) + self.databases.append(db) + journal=ProjectCustody(self.host.db.path,self.host.db.dataset_id).for_projection(self.store,identity) + with patch('runs.Runs',side_effect=AssertionError('Original physical reopen constructed Runs')): + projection=ProjectProjection(self.store,db,self.host.access,{},custody=journal,restore=False) + self.projections.append(projection);self.assertFalse(projection.session.available) + self.assertIsNone(projection.execution._runs) + before=self.store.snapshot() + with patch('subprocess.Popen',side_effect=AssertionError('Original admission clear dispatched')): + result=projection.execution.reconcile_original(expected_checkpoint=projection.session.snapshot['checkpoint']) + self.assertTrue(result['available']);self.assertFalse(journal.has_pending) + self.assertEqual(self.store.snapshot(),before);self.assertIsNone(projection.execution._runs) + self.assertEqual(db.execute('SELECT count(*) FROM access_admissions').fetchone()[0],0) + projection.close() + + def test_actual_go_intent_sql_failure_sends_no_go_then_exact_receipt_reconciles_without_launch(self): + self.install_journal();projection=self.projection;go=[] + with self.owner.bound(self.host),patch.dict(os.environ,{'TULDOK_TRAINING_PYTHON':'/usr/bin/python3'}): + graph=self.graph();fired=self.deferred_failure('go_intent') + with patch.object(projection.pipelines,'inspect_release',side_effect=self.release), \ + patch.object(recipe_process.Handle,'go',side_effect=AssertionError('GO before SQL acknowledgement')): + with self.assertRaises(psycopg.errors.ForeignKeyViolation): + projection.execution.native_preflight(graph['id'],1,projection.session.snapshot['checkpoint'],'go-ack-failure') + self.assertEqual(fired,[True]);self.assertFalse(projection.session.available) + phase=projection.execution.health()['native_phases']['items'][0] + self.assertEqual(phase['state'],'ready') + projection.db._project_before_commit=projection.execution.before_commit + with patch('subprocess.Popen',side_effect=AssertionError('Reconciliation launched')), \ + patch.object(recipe_process.Handle,'go',side_effect=AssertionError('Reconciliation replayed GO')): + result=projection.execution.native_reconcile(phase['phase_id'],projection.session.snapshot['checkpoint']) + self.assertEqual(result['state'],'failed');self.assertTrue(projection.session.available) + self.assertFalse(projection.custody.has_pending);projection.execution.assert_quiescent() + document=next(r['document'] for r in self.store.snapshot()['records'] if r['family']==FAMILY) + receipt=recipe_process.decode(document['observations']['drain_receipt']) + self.assertFalse(receipt['go_received']);self.assertTrue(receipt['drained']) + + def test_non_utf8_probe_with_terminal_sql_failure_reconciles_process_without_valid_preflight_or_reexecution(self): + self.install_journal();projection=self.projection + with self.owner.bound(self.host),patch.dict(os.environ,{'TULDOK_TRAINING_PYTHON':'/usr/bin/python3'}): + graph=self.graph() + source=self.root/'protocol-fixture';source.mkdir() + for name in projection.pipelines.definition_identity(graph['recipe'])['definition_files']: + (source/name).write_bytes((projection.pipelines.source_path/name).read_bytes()) + (source/'text_training.py').write_bytes(b'import sys\nsys.stdout.buffer.write(b"\\xff")\nsys.stdout.flush()\n') + with patch.object(projection.pipelines,'source_path',source): + fired=self.deferred_failure('completed') + with patch.object(projection.pipelines,'inspect_release',side_effect=self.release): + with self.assertRaises(psycopg.errors.ForeignKeyViolation): + projection.execution.native_preflight(graph['id'],1,projection.session.snapshot['checkpoint'],'malformed-ack-failure') + self.assertEqual(fired,[True]);self.assertFalse(projection.session.available) + projection.db._project_before_commit=projection.execution.before_commit + phase=projection.execution.health()['native_phases']['items'][0] + with patch('subprocess.Popen',side_effect=AssertionError('Malformed recovery launched')), \ + patch.object(recipe_process.Handle,'go',side_effect=AssertionError('Malformed recovery replayed GO')): + result=projection.execution.native_reconcile(phase['phase_id'],projection.session.snapshot['checkpoint']) + self.assertEqual(result['state'],'completed') + self.assertFalse(projection.custody.has_pending);self.assertTrue(projection.session.available) + document=next(r['document'] for r in self.store.snapshot()['records'] if r['family']==FAMILY) + self.assertIsNone(document['observations']['probe_stdout']) + self.assertEqual(document['probe_result']['outcome'],'invalid_utf8') + self.assertEqual(recipe_process.decode(document['observations']['drain_receipt'])['leader_returncode'],0) + self.assertFalse(any(r['family']=='project_operation' and r['document']['operation']=='native_preparation.validate' + for r in self.store.snapshot()['records'])) + + def test_real_probe_go_requires_canonical_and_sql_ack_and_retains_exact_raw_observation(self): + control=self.install_journal();projection=self.projection;go=[] + original=recipe_process.Handle.go + def observed(handle,helper): + self.assertFalse(projection.db.in_transaction) + current=self.store.snapshot() + document=next(r['document'] for r in current['records'] if r['family']==FAMILY + and r['document']['phase_id']==handle.spec['attempt_id']) + self.assertEqual(document['events'][-1]['state'],'go_intent') + fresh=control.for_projection(self.store,projection.db.projection_id) + self.assertEqual(fresh.snapshot()['native_phases'][0]['state'],'go_intent') + go.append(handle.spec['attempt_id']);return original(handle,helper) + with self.owner.bound(self.host),patch.dict(os.environ,{'TULDOK_TRAINING_PYTHON':'/usr/bin/python3'}): + graph=self.graph() + with patch.object(projection.pipelines,'inspect_release',side_effect=self.release), \ + patch.object(recipe_process.Handle,'go',new=observed), \ + patch('runs.Runs',side_effect=AssertionError('Probe constructed Runs')): + value=projection.execution.native_preflight(graph['id'],1,projection.session.snapshot['checkpoint'],'actual-probe') + self.assertIs(type(value['valid']),bool);self.assertEqual(len(go),1) + records=self.store.snapshot()['records'];phases=decode_records(records,artifacts=projection.artifacts) + self.assertEqual(len(phases),1) + document=next(iter(phases.values())) + self.assertIn(document['events'][-1]['state'],('completed','failed')) + stdout=next(edge['blob'] for edge in document['payloads'] if edge['name']=='stdout.log') + raw=projection.artifacts.read_blob(stdout,maximum_bytes=32768) + self.assertEqual(recipe_process.decode(raw)['runtime'],value['runtime']['runtime']) + self.assertFalse(projection.custody.has_pending);self.assertFalse(projection.execution.native.has_local_custody) + self.assertEqual(projection.db.execute('SELECT count(*) FROM workspace_runs').fetchone()[0],0) + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'),'Requires disposable PostgreSQL') +class NativePhaseRecoveryHTTP(unittest.TestCase): + setUp=http_fixture.ProjectFilesHTTP.setUp + cleanup_owned=http_fixture.ProjectFilesHTTP.cleanup_owned + select_host=http_fixture.ProjectFilesHTTP.select_host + request=http_fixture.ProjectFilesHTTP.request + state=http_fixture.ProjectFilesHTTP.state + envelope=http_fixture.ProjectFilesHTTP.envelope + action=http_fixture.ProjectFilesHTTP.action + create=http_fixture.ProjectFilesHTTP.create + graph=preparation_fixture.NativePreparationTests.graph + release=preparation_fixture.NativePreparationTests.release + deferred_failure=NativeProbeBoundaryTests.deferred_failure + + def native_http(self,action,arguments,version=1): + import agent_api + name='project_files.'+action + method,path,body=agent_api.project_request(name,arguments,version=version) + request=Request(self.origin+path,method=method, + data=None if body is None else json.dumps(body,ensure_ascii=False).encode(), + headers={'Authorization':'Bearer '+self.owner.token,'Origin':self.origin,'Content-Type':'application/json', + 'Tuldok-Operation-Contract':agent_api.selected_header(name,version)}) + try:response=urlopen(request,timeout=45) + except HTTPError as error:response=error + with response:return response.status,json.load(response) + + def stop_without_effects(self,workspace): + # This fixture has only the canceled, drained probe and no Run owner. + # Preserve its physical SQL/journal for the explicit restarted owner. + for candidate,server,thread in self.hosts: + if candidate is workspace: + server.shutdown();server.server_close();thread.join(3);break + for projection in (workspace._projection,workspace._retained_recovery): + if projection is not None: + self.assertIsNone(projection.execution._runs) + projection.sources.close();projection.artifacts.close();projection.db.close() + workspace._store.close() + for _path,descriptor in workspace._roots.values():os.close(descriptor) + workspace._roots.clear();workspace.db.close();workspace._closed=True + + def test_uncertain_spawn_without_handle_fences_public_preflight_and_never_infers_cessation(self): + import project_workspace + from project_native_process import PreparedNative + self.enterContext(patch.object(project_workspace,'M3_ENABLED',True)) + self.enterContext(patch.dict(os.environ,{'TULDOK_TRAINING_PYTHON':'/usr/bin/python3'})) + self.create();self.projection=self.workspace._projection + with self.owner.bound(self.workspace):graph=self.graph() + acknowledged=self.state();projection=self.projection;entered=[] + def uncertain_launch(prepared,custody_directory,*,_before_spawn=None): + self.assertIsNotNone(_before_spawn) + _before_spawn();entered.append(prepared.spec['attempt_id']) + # The foundation's own real seam tests establish this callback is + # the Popen entry boundary. Simulate only its uncertain exception; + # do not fabricate a child, Handle, drainage, or successful result. + raise OSError('Controlled uncertainty after entering spawn boundary') + arguments=dict(binding=acknowledged['binding'],expected_checkpoint=acknowledged['project']['checkpoint'], + graph_id=graph['id'],revision=graph['revision'],request_key='native-uncertain-spawn') + try: + with patch.object(projection.pipelines,'inspect_release',side_effect=self.release), \ + patch.object(PreparedNative,'launch',new=uncertain_launch), \ + patch('subprocess.Popen',side_effect=AssertionError('Unknown-spawn control created a real child')): + status,error=self.native_http('native_preflight',arguments) + self.assertEqual((status,error['code']),(409,'outcome_unknown')) + self.assertEqual(len(entered),1);self.assertFalse(projection.session.available) + self.assertNotIn(entered[0],projection.execution.native._never_launched) + entry=projection.custody.snapshot()['native_phases'][0] + self.assertEqual(entry['state'],'launch_intent');self.assertIsNone(entry['helper']) + self.assertIsNone(entry['receipt_sha256']) + recovery=self.state();self.assertEqual(recovery['state'],'recovery_required') + public=dict(binding=recovery['binding'],checkpoint=recovery['project']['checkpoint']) + status,health=self.native_http('execution_health',public,2) + self.assertEqual(status,200,health);self.assertFalse(health['value']['available']) + self.assertEqual(health['value']['native_phases']['items'][0]['phase_id'],entered[0]) + before=self.workspace._store.snapshot() + status,error=self.native_http('native_preflight',dict(arguments, + expected_checkpoint=public['checkpoint'],request_key='native-fresh-after-uncertainty')) + self.assertEqual((status,error['code']),(503,'projection_unavailable')) + status,error=self.native_http('native_reconcile',dict(binding=public['binding'], + expected_checkpoint=public['checkpoint'],phase_id=entered[0])) + self.assertEqual((status,error['code']),(409,'unknown')) + self.assertEqual(entered,[entry['phase_id']]) + self.assertFalse(projection.session.available);self.assertTrue(projection.custody.has_pending) + self.assertEqual(self.workspace._store.snapshot(),before) + finally: + # This test explicitly forbids real process creation. Preserve the + # unresolved private record while disposing only fixture handles. + self.stop_without_effects(self.workspace) + + def test_selected_health_versions_discover_original_phase_and_named_recovery_never_replays_go(self): + import agent_api + import project_workspace + self.enterContext(patch.object(project_workspace,'M3_ENABLED',True)) + self.enterContext(patch.dict(os.environ,{'TULDOK_TRAINING_PYTHON':'/usr/bin/python3'})) + self.create();self.projection=self.workspace._projection + with self.owner.bound(self.workspace):graph=self.graph() + acknowledged=self.state();projection=self.projection + fired=self.deferred_failure('go_intent') + arguments=dict(binding=acknowledged['binding'],expected_checkpoint=acknowledged['project']['checkpoint'], + graph_id=graph['id'],revision=graph['revision'],request_key='http-original-native-phase') + with patch.object(projection.pipelines,'inspect_release',side_effect=self.release), \ + patch.object(recipe_process.Handle,'go',side_effect=AssertionError('Unacknowledged HTTP phase sent GO')): + status,error=self.native_http('native_preflight',arguments) + self.assertEqual((status,error['code']),(503,'storage'));self.assertEqual(fired,[True]) + recovery=self.state();self.assertEqual(recovery['state'],'recovery_required') + self.assertEqual(recovery['binding'],acknowledged['binding']) + self.assertEqual(recovery['project']['checkpoint'],acknowledged['project']['checkpoint']) + pinned=dict(binding=recovery['binding'],checkpoint=recovery['project']['checkpoint'],offset=0,limit=1) + for version in (1,2): + status,reply=self.native_http('execution_health',pinned,version) + self.assertEqual(status,200,reply) + agent_api.validate_output('project_files.execution_health',dict(status=200,result=reply),version=version) + self.assertEqual(reply['checkpoint'],pinned['checkpoint']);self.assertFalse(reply['value']['available']) + self.assertEqual('native_phases' in reply['value'],version==2) + if version==2: + phase=reply['value']['native_phases']['items'][0] + self.assertEqual(reply['value']['native_phases']['total'],1) + self.assertIsNone(phase['run_id']);self.assertEqual(phase['state'],'ready') + for changed in (dict(pinned,binding='e'*32),dict(pinned,checkpoint='f'*64)): + self.assertEqual(self.native_http('execution_health',changed,2)[0],409) + with patch.object(projection,'reconcile_inert',side_effect=AssertionError('Generic native reconstruction')): + status,error=self.native_http('execution_reconcile_current', + dict(binding=recovery['binding'],expected_checkpoint=pinned['checkpoint'])) + self.assertEqual((status,error['code']),(409,'unavailable')) + physical=projection.db.projection_id;control=self.workspace.path + self.stop_without_effects(self.workspace) + with patch('runs.Runs',side_effect=AssertionError('Original HTTP Open constructed Runs')), \ + patch('subprocess.Popen',side_effect=AssertionError('Original HTTP recovery launched')), \ + patch.object(recipe_process.Handle,'go',side_effect=AssertionError('Original HTTP recovery replayed GO')): + self.select_host(control,{'fixture':self.external}) + self.action('open',root='fixture',directory='alpha') + state=self.state();self.assertEqual(state['state'],'recovery_required') + current=dict(binding=state['binding'],checkpoint=state['project']['checkpoint'],offset=0,limit=1) + status,reply=self.native_http('execution_health',current,2) + self.assertEqual(status,200,reply);self.assertEqual(reply['value']['native_phases']['items'],[phase]) + self.assertEqual(self.workspace._retained_recovery.db.projection_id,physical) + status,result=self.native_http('native_reconcile',dict(binding=state['binding'], + expected_checkpoint=current['checkpoint'],phase_id=phase['phase_id'])) + self.assertEqual(status,200,result);self.assertEqual(result['binding'],state['binding']) + self.assertEqual(result['value']['state'],'failed') + self.assertEqual(self.workspace._projection.db.projection_id,physical) + self.assertIsNone(self.workspace._projection.execution._runs) + self.assertFalse(self.workspace._projection.custody.has_pending) + self.action('close') + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_project_native_extraction.py b/tests/test_project_native_extraction.py new file mode 100644 index 00000000..c2c3372b --- /dev/null +++ b/tests/test_project_native_extraction.py @@ -0,0 +1,161 @@ +"""Original legacy native source custody, without probing or training. + +The source is an explicitly authored disposable PostgreSQL fixture. Its retained +runtime and staging files are controlled historical observations, not evidence +that a scientific worker ran or a model trained successfully. +""" +import hashlib +import io +import json +import os +from types import SimpleNamespace +import unittest +from unittest.mock import patch +import zipfile + +from pipeline import Pipelines, adapter_identity_for_graph, digest, execution_digest +from project_artifacts import ProjectArtifacts +from project_history import ProjectHistory, decode_records +from project_material import ProjectMaterial +from project_store import ProjectStore +import test_project_extraction as fixture +from test_project_history import legacy_run + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'), 'Requires disposable PostgreSQL') +class NativeExtractionTests(unittest.TestCase): + setUp = fixture.LegacyCapture.setUp + view = fixture.LegacyCapture.view + files = fixture.LegacyCapture.files + capture = fixture.LegacyCapture.capture + text = fixture.LegacyCapture.text + + def author(self, *, integrity=False): + row = self.text('Exact historical native input; no execution claim' + ('; integrity' if integrity else '')) + manifest = dict(schema_version=1, seed=7, split_report={}, + coordinate_contract='Controlled retained-source fixture', + limitations=['No training or numerical validation claim'], vocabulary=[], + records=[dict(row, asset='source.txt', asset_sha256=row['content_hash'], split='train')]) + archive = io.BytesIO() + with zipfile.ZipFile(archive, 'w', zipfile.ZIP_STORED) as stream: + stream.writestr('manifest.json', json.dumps(manifest).encode()) + stream.writestr('source.txt', row['text'].encode()) + release = archive.getvalue(); sha = hashlib.sha256(release).hexdigest() + folder = self.db.path / 'releases'; folder.mkdir(exist_ok=True) + (folder / (sha + '.zip')).write_bytes(release) + graph = Pipelines(self.dataset).create(dict(name='Retained native source fixture', + recipe='text_validate_export' if integrity else 'text_classification', release_id=sha)) + run = legacy_run(self.db.dataset_id) + run.update(graph=graph, graph_revision=graph['revision'], graph_sha256=execution_digest(graph), + graph_document_sha256=digest(graph), adapter=adapter_identity_for_graph(graph), + release_id=sha, release_sha256=sha, release_bytes=len(release), source_records=[dict(row, split='train')], + execution_test_exposure=dict(exposed=False, events=[])) + originals = {'text_training.py': b'# Historical authored worker, never executed\r\nvalue = "original"\n', + 'retained_helper.py': b'# Original unique helper\nraw = b"\\x00\\xff"\n'} + stage = self.db.path / 'workspace-runs' / ('.staging-' + run['id']) + if not integrity: + stage.mkdir(parents=True) + for name, raw in originals.items(): (stage / name).write_bytes(raw) + files = {name: hashlib.sha256(raw).hexdigest() for name, raw in originals.items()} + run['runtime'] = dict(schema_version=1, available=True, adapter=run['adapter'], + runtime=dict(python='3.12.authored', device='cpu', threads=1, scikit_learn='1.7.2', + numpy='authored', scipy='authored', Pillow='authored'), problems=[], + definition_files=files, adapter_definition_sha256=digest(files)) + self.save(run) + return run, originals, stage + + def save(self, run): + with self.db.lock, self.db.transaction(): + self.db.execute('INSERT INTO workspace_runs VALUES(%s,%s,%s,%s) ON CONFLICT(id) DO UPDATE SET document=excluded.document', + (run['id'], run['request_key'], run['request_sha256'], json.dumps(run))) + + def capture_without_host(self, run, source): + resolve = source.resolve_payload + source_hashes = set(run['runtime'].get('definition_files', {}).values()) + def resolve_other(sha, size): + if sha in source_hashes: raise AssertionError('Original source resolver fallback') + return resolve(sha, size) + with patch.object(Pipelines, 'definition_identity', side_effect=AssertionError('Current source substitution')), \ + patch('subprocess.Popen', side_effect=AssertionError('Extraction launched a process')), \ + patch('subprocess.run', side_effect=AssertionError('Extraction probed a runtime')), \ + patch.object(source, 'resolve_payload', side_effect=resolve_other): + return self.capture(source, {'runs': [run['id']]}) + + def test_original_sources_are_unique_canonical_payloads_and_cold_read_byte_exactly(self): + run, originals, stage = self.author(); before = self.files(); source = self.view() + captured = self.capture_without_host(run, source) + self.assertEqual(captured['blockers'], [], captured['blockers']) + materials = ProjectMaterial(SimpleNamespace(snapshot=dict(project_id=self.db.dataset_id, + records=captured['records']), check=lambda: None)) + facts = decode_records(captured['records'], materials=materials) + saved = facts.runs[(self.db.dataset_id, run['id'])] + self.assertEqual(saved['run']['runtime'], run['runtime']) + self.assertEqual(saved['run']['execution_test_exposure'], run['execution_test_exposure']) + for name, raw in originals.items(): + edge = next(item for item in saved['payloads'] if item['name'] == 'source/' + name) + self.assertEqual(edge['blob'], dict(sha256=hashlib.sha256(raw).hexdigest(), bytes=len(raw))) + self.assertEqual((edge['role'], edge['preservation']), ('execution_source', 'unique')) + self.assertIn(dict(source=dict(family='run_history', id=self.db.dataset_id + '.' + run['id']), + target=dict(family='project_artifact', id=edge['blob']['sha256']), + reason='unique:source/' + name), captured['dependencies']) + self.assertEqual(source.artifacts.read_blob(edge['blob']), raw) + self.assertEqual(self.files(), before) + # Publish captured bytes to a disposable canonical store, close it, then + # read through a fresh inert history owner after original files vanish. + destination = self.root / 'cold-extracted' + store = ProjectStore.create(destination, 'Cold captured originals', 'native-extraction-new', '1' * 64) + artifacts = ProjectArtifacts(store) + try: + for record in captured['records']: + if record['family'] == 'project_artifact': + artifacts.publish_bytes(source.artifacts.read_blob(record['document']), record['document']) + store.commit(captured['records'], store.snapshot()['checkpoint'], 'native-extraction-capture', '2' * 64) + finally: + artifacts.close(); store.close() + for name in originals: (stage / name).unlink() + cold = ProjectStore.open(destination); self.addCleanup(cold.close) + cold_artifacts = ProjectArtifacts(cold); self.addCleanup(cold_artifacts.close) + session = SimpleNamespace(snapshot=cold.snapshot(), access=self.access, artifacts=cold_artifacts, check=lambda: None) + history = ProjectHistory(session, authorize_disclosure=lambda *args: None) + for name, raw in originals.items(): + self.assertEqual(history.artifact(run['id'], 'source/' + name, origin_catalog_id=self.db.dataset_id), raw) + + def test_missing_original_is_blocked_without_resolver_or_host_fallback(self): + run, originals, stage = self.author(); (stage / 'text_training.py').unlink() + before = self.files(); captured = self.capture_without_host(run, self.view()) + self.assertEqual([row['code'] for row in captured['blockers']], ['missing_source']) + self.assert_failure_facts(captured, run, originals) + self.assertEqual(self.files(), before) + + def test_tampered_original_is_blocked_without_invented_byte_count_or_host_fallback(self): + run, originals, stage = self.author(); (stage / 'text_training.py').write_bytes(b'# Changed original source\n') + before = self.files(); captured = self.capture_without_host(run, self.view()) + self.assertEqual([row['code'] for row in captured['blockers']], ['corrupt_source']) + self.assert_failure_facts(captured, run, originals) + self.assertEqual(self.files(), before) + + def assert_failure_facts(self, captured, run, originals): + saved = next(row['document'] for row in captured['records'] if row['family'] == 'run_history') + self.assertEqual(saved['run']['runtime'], run['runtime']) + self.assertFalse(any(edge['name'] == 'source/text_training.py' for edge in saved['payloads'])) + # The retained hash has no known original size after loss/corruption. + # Never manufacture an intrinsic declaration from changed/current bytes. + lost = hashlib.sha256(originals['text_training.py']).hexdigest() + self.assertFalse(any(row['family'] == 'project_artifact' and row['id'] == lost for row in captured['records'])) + self.assertTrue(any(edge['name'] == 'source/retained_helper.py' and edge['preservation'] == 'unique' + for edge in saved['payloads'])) + + def test_missing_training_inventory_blocks_but_in_process_integrity_needs_no_staging(self): + run, _, _ = self.author(); run['runtime'] = {}; self.save(run) + captured = self.capture_without_host(run, self.view()) + self.assertEqual([row['code'] for row in captured['blockers']], ['source_identity_unavailable']) + # Author a distinct real in-process recipe observation; no training + # staging or executable source inventory exists for this owner path. + with self.db.lock, self.db.transaction(): self.db.execute('DELETE FROM workspace_runs') + integrity, _, stage = self.author(integrity=True) + captured = self.capture_without_host(integrity, self.view()) + self.assertEqual(captured['blockers'], [], captured['blockers']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_project_native_preparation.py b/tests/test_project_native_preparation.py new file mode 100644 index 00000000..558f3326 --- /dev/null +++ b/tests/test_project_native_preparation.py @@ -0,0 +1,293 @@ +"""Current qualification policy and canonical ordering, without a native child. + +The controlled probe response and frozen-release summary are authored fixtures. +These tests qualify owner composition, not installed runtime or model quality. +The native process/core owners separately qualify the actual custody protocol. +""" +import copy +import os +from pathlib import Path +import sys +import unittest +from unittest.mock import patch +import uuid + +from access import AccessError +from pipeline import adapter_identity_for_graph +from project_native_preparation import ProjectNativePreparation,validate_operation_result +from project_session import operation_records +import test_project_study_execution as study_fixture +from workbench import WorkbenchError + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'),'Requires disposable PostgreSQL') +class NativePreparationTests(unittest.TestCase): + setUp=study_fixture.ProjectStudyControlTests.setUp + cleanup=study_fixture.ProjectStudyControlTests.cleanup + metadata=study_fixture.ProjectStudyControlTests.metadata + open_projection=study_fixture.ProjectStudyControlTests.open_projection + + def test_validated_native_graph_save_as_keeps_original_observation_and_cold_inert_intent(self): + """Real Save As; the probe observation alone is explicitly controlled.""" + from auth_helpers import ExplicitOwnerFixture + from project_material_fork import is_intent + from project_session import operation_documents + from project_workspace import ProjectWorkspace + from test_project_native_study import prepare_text_source + root=self.root/'fork-projects';root.mkdir() + workspace=ProjectWorkspace(self.root/'fork-control',{'fixture':root}) + self.databases.append(workspace.db);self.addCleanup(workspace.close) + owner=ExplicitOwnerFixture.bootstrap(workspace) + def action(operation,**body): + state=workspace.status() + return workspace.dispatch('project_files.'+operation,dict(binding=state['binding'], + expected_checkpoint=state['project']['checkpoint'] if state['project'] else None, + request_key=uuid.uuid4().hex,**body)) + previous=self.projection + with owner.bound(workspace),patch('subprocess.Popen',side_effect=AssertionError('Save As regression must not fit or launch')): + action('new',root='fixture',directory='original',name='Original native qualification') + projection=workspace._projection;self.databases.append(projection.db);self.projection=projection + try: + _,release=prepare_text_source(projection,key_prefix='fork-validation-text') + body=dict(name='Actual model-aware native graph',recipe='text_classification_model_v2',release_id=release['id']) + graph=projection.materials.mutate_definition('pipeline_graph','graphs.create',None,body, + projection.session.snapshot['checkpoint'],'fork-native-graph',lambda:projection.pipelines.create(body)) + calls=[] + with patch.object(projection.execution.native,'probe',side_effect=self.controlled_probe(calls)): + validation=projection.native_preparation.validate_graph(projection.pipelines,graph['id'],graph['revision'], + projection.session.snapshot['checkpoint'],'fork-native-validation') + self.assertTrue(validation['valid'],validation);self.assertEqual(len(calls),1) + source=copy.deepcopy(projection.session.snapshot) + action('save_as',root='fixture',directory='independent',name='Independent native graph intent') + fork=workspace._projection;self.databases.append(fork.db) + self.assertNotEqual(fork.project_id,source['project_id']) + indexed={(record['family'],record['id']):record for record in fork.session.snapshot['records']} + for record in source['records']:self.assertEqual(indexed[(record['family'],record['id'])],record) + intents=[record for record in fork.session.snapshot['records'] + if record['family']=='pipeline_graph' and is_intent(record['document']['current'])] + self.assertEqual(len(intents),1) + self.assertNotEqual(intents[0]['document']['current']['id'],graph['id']) + self.assertTrue(validate_operation_result('native_preparation.validate',validation,fork.session.snapshot['records'])) + without_original=[record for record in fork.session.snapshot['records'] + if record['family']!='pipeline_graph' or is_intent(record['document']['current'])] + with self.assertRaisesRegex(WorkbenchError,'exact retained graph'): + validate_operation_result('native_preparation.validate',validation,without_original) + saved=copy.deepcopy(fork.session.snapshot) + action('close');action('open',root='fixture',directory='independent') + cold=workspace._projection;self.databases.append(cold.db) + self.assertEqual(cold.session.snapshot,saved) + observations=[receipt['result'] for receipt in operation_documents(saved['records']) + if receipt['operation']=='native_preparation.validate'] + self.assertEqual(observations,[validation]);self.assertIsNone(cold.execution._runs) + self.assertEqual(cold.db.execute('SELECT count(*) FROM workspace_runs').fetchone()[0],0) + finally:self.projection=previous + + def graph(self): + pipelines=self.projection.pipelines + body=dict(name='Authored qualification graph',recipe='text_classification',release_id='a'*64) + return self.projection.materials.mutate_definition('pipeline_graph','graphs.create',None,body, + self.projection.session.snapshot['checkpoint'],'native-graph',lambda:pipelines.create(body)) + + def release(self,release_id,*,private=False,recipe=None): + value=dict(id=release_id,sha256=release_id,format='text_classification_v1',recipe=recipe, + allowed_recipes=[recipe],records=6,class_vocabulary=[dict(label='a',index=0),dict(label='b',index=1)],split_counts=dict(train=2,validation=2,test=2), + prior_test_exposure=dict(exposed=False,events=[]),status='available') + if private:value.update(path='/authored/unopened-release.zip',manifest=dict(records=[])) + return value + + def controlled_probe(self,calls): + def probe(recipe,*,expected_checkpoint,request_key,preparation): + self.assertFalse(self.projection.db.in_transaction) + receipts=operation_records(self.projection.session.snapshot['records']) + self.assertTrue(any(v['operation']=='native_preparation.intent' for v in receipts.values())) + self.assertEqual(expected_checkpoint,self.projection.session.snapshot['checkpoint']) + preparation.authorize_probe(recipe) + inputs=preparation.native_inputs(recipe) + self.assertEqual(inputs['worker'],'text_training.py') + self.assertEqual(set(inputs['sources']),set(inputs['definition_identity']['definition_files'])) + calls.append(request_key) + return dict(runtime=dict(schema_version=1,available=True,adapter=adapter_identity_for_graph(dict(recipe=recipe)), + runtime=dict(python='3.12',device='cpu',threads=1,scikit_learn='1.7.2',numpy='authored',scipy='authored',Pillow='authored'), + problems=[],**inputs['definition_identity']),phase=dict(format='project_native_phase_v1', + origin_catalog_id=self.projection.session.project_id,phase_id='b'*32,phase='probe',run_id=None, + state='completed',spec_sha256='c'*64,receipt_sha256='d'*64)) + return probe + + def test_metadata_and_unscoped_probe_never_launch_and_transaction_cannot_start_preparation(self): + with self.owner.bound(self.host),patch('subprocess.Popen',side_effect=AssertionError('No process from metadata')), \ + patch('subprocess.run',side_effect=AssertionError('No legacy direct probe')): + graph=self.graph();adapter=self.projection.native_preparation + before=self.projection.session.snapshot + self.assertEqual(self.projection.pipelines.get(graph['id']),graph) + with self.assertRaisesRegex(WorkbenchError,'explicit current preparation'): + self.projection.pipelines.probe('text_classification') + with self.projection.db.transaction(),self.assertRaisesRegex(WorkbenchError,'precede the metadata transaction'): + adapter.validate_graph(self.projection.pipelines,graph['id'],1,before['checkpoint'],'native-inside-tx') + self.assertEqual(self.projection.session.snapshot,before) + self.assertEqual(self.projection.db.execute('SELECT count(*) FROM workspace_runs').fetchone()[0],0) + + def test_intent_precedes_probe_replay_does_not_launch_and_cache_is_lexical_and_one_use(self): + with self.owner.bound(self.host),patch.dict(os.environ,{'TULDOK_TRAINING_PYTHON':sys.executable}): + graph=self.graph();adapter=self.projection.native_preparation;calls=[] + with patch.object(self.projection.pipelines,'inspect_release',side_effect=self.release), \ + patch.object(self.projection.execution.native,'probe',side_effect=self.controlled_probe(calls)), \ + patch('subprocess.Popen',side_effect=AssertionError('Only controlled probe observation')), \ + patch('subprocess.run',side_effect=AssertionError('No legacy native subprocess')): + value=adapter.validate_graph(self.projection.pipelines,graph['id'],1, + self.projection.session.snapshot['checkpoint'],'native-validation') + self.assertTrue(value['valid']);self.assertEqual(len(calls),1) + stable=self.projection.session.snapshot + for forged in (dict(value,validation_token='e'*64),dict(value,runtime=dict(value['runtime'],token='secret')), + dict(value,release=dict(value['release'],credential={'token':'secret'}))): + with self.assertRaises(WorkbenchError): + validate_operation_result('native_preparation.validate',forged,stable['records']) + self.assertEqual(adapter.validate_graph(self.projection.pipelines,graph['id'],1,'0'*64,'native-validation'),value) + self.assertEqual(self.projection.session.snapshot,stable);self.assertEqual(len(calls),1) + with adapter.current_graph(self.projection.pipelines,graph['id'],1,stable['checkpoint'],'native-admission') as current: + self.assertEqual(current.validation['validation_token'],value['validation_token']) + self.assertEqual(len(calls),2) + with self.projection.db.transaction(): + # Repeated native validation inside admission reads the + # exact current process-local probe, and cannot fork. + self.assertEqual(self.projection.pipelines.validate_graph(graph),current.validation) + current.consume_admission() + with self.assertRaisesRegex(WorkbenchError,'already been consumed'):current.consume_admission() + self.assertEqual(len(calls),2) + source=self.root/'changed-source';source.mkdir() + for name,raw in current.sources.items():(source/name).write_bytes(raw) + entry=source/'text_training.py';entry.write_bytes(entry.read_bytes()+b'\n# authored source drift\n') + with patch.object(self.projection.pipelines,'source_path',source),self.assertRaisesRegex(WorkbenchError,'source changed'): + current.authorize_probe(graph['recipe']) + with self.assertRaisesRegex(WorkbenchError,'explicit current preparation'): + self.projection.pipelines.probe('text_classification') + imported=ProjectNativePreparation(self.projection) + with self.assertRaisesRegex(WorkbenchError,'explicit current preparation'): + imported.current(self.projection.pipelines) + fact=next(v['result'] for v in operation_records(self.projection.session.snapshot['records']).values() + if v['operation']=='native_preparation.intent') + self.assertTrue(validate_operation_result('native_preparation.intent',fact,self.projection.session.snapshot['records'])) + forged=copy.deepcopy(fact);forged['runtime']['token']='secret' + with self.assertRaises(WorkbenchError):validate_operation_result('native_preparation.intent',forged) + forged=copy.deepcopy(fact);forged['graph']['document_sha256']='f'*64 + with self.assertRaises(WorkbenchError): + validate_operation_result('native_preparation.intent',forged,self.projection.session.snapshot['records']) + + def test_pure_integrity_guard_has_no_interpreter_phase_and_is_still_current_one_use(self): + with self.owner.bound(self.host): + pipelines=self.projection.pipelines + body=dict(name='Authored pure validation',recipe='text_validate_export',release_id='a'*64) + graph=self.projection.materials.mutate_definition('pipeline_graph','graphs.create',None,body, + self.projection.session.snapshot['checkpoint'],'pure-graph',lambda:pipelines.create(body)) + # The authored result isolates the guard policy. The real whole + # release reader/in-process report has its own owner acceptance. + value=dict(valid=True,errors=[],release=dict(id='a'*64),validation_token='b'*64) + before=self.projection.session.snapshot + with patch.object(pipelines,'validate_graph',return_value=value), \ + patch.object(pipelines,'inspect_release',side_effect=self.release), \ + patch('project_native_process.current_runtime',side_effect=AssertionError('Pure integrity measured an interpreter')), \ + patch('subprocess.Popen',side_effect=AssertionError('Pure integrity forked')): + with self.projection.native_preparation.current_graph(pipelines,graph['id'],1,before['checkpoint'],'pure-guard') as current: + self.assertEqual(current.validation,value);self.assertFalse(hasattr(current,'native_inputs')) + current.consume_admission() + with self.assertRaisesRegex(WorkbenchError,'already been consumed'):current.consume_admission() + with self.assertRaisesRegex(WorkbenchError,'already been consumed'):current.consume_admission() + self.assertEqual(self.projection.session.snapshot,before) + + def test_agent_cannot_replay_owner_native_validation_or_construct_preparation(self): + with self.owner.bound(self.host): + graph=self.graph() + agent=self.host.access.issue(dict(label='Native metadata editor',scopes=['inspect','edit'])) + before=self.projection.session.snapshot + with self.host.access.bind(agent['token']),patch('subprocess.Popen',side_effect=AssertionError('Denied caller launched')): + with self.assertRaises(AccessError): + self.projection.native_preparation.validate_graph(self.projection.pipelines,graph['id'],1, + before['checkpoint'],'denied-native') + self.assertEqual(self.projection.session.snapshot,before) + + def test_actual_whole_mesh_release_validation_acknowledges_and_cold_reads_without_children(self): + import meshes + from test_meshes import fixture + with self.owner.bound(self.host), \ + patch('subprocess.Popen',side_effect=AssertionError('Whole-asset integrity must not fork')), \ + patch('subprocess.run',side_effect=AssertionError('Whole-asset integrity must not probe')), \ + patch('project_native_process.current_runtime',side_effect=AssertionError('No native interpreter for integrity')): + data=self.projection.data + prepared=meshes.prepare(*fixture('asymmetric-prism')) + row=data.import_bundle('mesh',prepared,dict(groups=['authored-geometry'],rights='Authored repository mesh'), + self.projection.session.snapshot['checkpoint'],'integrity-mesh-source') + row=data.annotate(row['id'],dict(revision=row['revision'],source_revision=row['source_revision'],task='mesh_geometry', + annotation=dict(note='Inspected authored whole geometry'),groups=row['groups'],review='human_reviewed'), + self.projection.session.snapshot['checkpoint'],'integrity-mesh-review') + request=dict(items=[{key:row[key] for key in ('id','revision','source_revision')}], + ratios=dict(train=100,validation=0,test=0),seed=4) + release=data.freeze_release(request,self.projection.session.snapshot['checkpoint'],'integrity-mesh-freeze') + body=dict(name='Actual whole mesh integrity',recipe='mesh_validate_export',release_id=release['id']) + graph=self.projection.materials.mutate_definition('pipeline_graph','graphs.create',None,body, + self.projection.session.snapshot['checkpoint'],'integrity-mesh-graph',lambda:self.projection.pipelines.create(body)) + value=self.projection.native_preparation.validate_graph(self.projection.pipelines,graph['id'],1, + self.projection.session.snapshot['checkpoint'],'integrity-mesh-validation') + self.assertTrue(value['valid'],value['errors']) + observed=value['release'] + self.assertEqual(observed['result_kind'],'release_validation') + self.assertNotIn('class_vocabulary',observed) + self.assertEqual(observed['validation_summary']['mesh_count'],1) + self.assertEqual(observed['validation_summary']['vertex_count'],6) + self.assertEqual(observed['validation_summary']['triangle_count'],8) + for change in ('foreign_bytes','nested_authority','header_vocabulary'): + forged=copy.deepcopy(value) + if change=='foreign_bytes':forged['release']['validation_summary']['release_sha256']='f'*64 + elif change=='nested_authority':forged['release']['validation_summary']['meshes'][0]['provenance']['credential']={'token':'secret'} + else:forged['release']['class_vocabulary']=[] + with self.subTest(change=change),self.assertRaises(WorkbenchError): + validate_operation_result('native_preparation.validate',forged,self.projection.session.snapshot['records']) + import release_validation + manifest=next(record['document']['manifest'] for record in self.projection.session.snapshot['records'] + if record['family']=='dataset_release' and record['id']==release['id']) + historical=copy.deepcopy(observed['validation_summary']) + historical['limitations']=['Original retained explanation remains exact.'] + self.assertEqual(release_validation.decode_summary(historical,'mesh_validate_export',manifest=manifest, + release_id=release['id']),historical) + self.assertEqual(operation_records(self.projection.session.snapshot['records'])['integrity-mesh-validation']['result'],value) + before=self.projection.session.snapshot + self.assertFalse(any(r['family']=='native_execution_history' for r in before['records'])) + self.projection.close();self.open_projection() + self.assertEqual(self.projection.session.snapshot,before) + self.assertEqual(operation_records(before['records'])['integrity-mesh-validation']['result'],value) + self.assertEqual(self.projection.native_preparation.validate_graph(self.projection.pipelines,graph['id'],1, + '0'*64,'integrity-mesh-validation'),value) + self.assertIsNone(self.projection.execution._runs) + self.assertEqual(self.projection.db.execute('SELECT count(*) FROM workspace_runs').fetchone()[0],0) + + def test_actual_classification_export_integrity_dto_keeps_vocabulary_and_summary(self): + with self.owner.bound(self.host),patch('subprocess.Popen',side_effect=AssertionError('Export integrity must not fork')): + data=self.projection.data;rows=[] + for index in range(6): + row=data.import_text(dict(text='Authored independent classification source '+str(index), + groups=['export-source-'+str(index)],rights='Authored test text'), + self.projection.session.snapshot['checkpoint'],'export-source-'+str(index)) + row=data.annotate(row['id'],dict(revision=row['revision'],source_revision=row['source_revision'], + task='text_classification',annotation=dict(label='a' if index%2 else 'b'),groups=row['groups'],review='human_reviewed'), + self.projection.session.snapshot['checkpoint'],'export-review-'+str(index)) + rows.append(row) + request=dict(format='text_classification_v1',items=[{key:row[key] for key in ('id','revision','source_revision')} + for row in rows],ratios=dict(train=34,validation=33,test=33),seed=42) + for seed in range(100): + request['seed']=seed + preview=data.preview_release(request) + if preview['eligible']:break + self.assertTrue(preview['eligible'],preview['blockers']) + request['preview_token']=preview['preview_token'] + release=data.freeze_release(request,self.projection.session.snapshot['checkpoint'],'export-freeze') + body=dict(name='Actual frozen text export integrity',recipe='text_validate_export',release_id=release['id']) + graph=self.projection.materials.mutate_definition('pipeline_graph','graphs.create',None,body, + self.projection.session.snapshot['checkpoint'],'export-graph',lambda:self.projection.pipelines.create(body)) + value=self.projection.native_preparation.validate_graph(self.projection.pipelines,graph['id'],1, + self.projection.session.snapshot['checkpoint'],'export-validation') + self.assertTrue(value['valid'],value['errors']) + self.assertEqual(value['release']['result_kind'],'release_validation') + vocabulary=[dict(label='a',index=0),dict(label='b',index=1)] + self.assertEqual(value['release']['class_vocabulary'],vocabulary) + self.assertEqual(value['release']['validation_summary']['record_count'],6) + self.assertEqual(value['release']['validation_summary']['class_vocabulary'],vocabulary) + self.assertTrue(validate_operation_result('native_preparation.validate',value,self.projection.session.snapshot['records'])) + self.assertFalse(any(r['family']=='native_execution_history' for r in self.projection.session.snapshot['records'])) diff --git a/tests/test_project_native_process.py b/tests/test_project_native_process.py new file mode 100644 index 00000000..287de66a --- /dev/null +++ b/tests/test_project_native_process.py @@ -0,0 +1,508 @@ +"""Actual finite native custody with synthetic stdlib workers, never ML work.""" +import ctypes +import hashlib +import json +import os +from pathlib import Path +import select +import signal +import sys +import tempfile +import textwrap +import time +import unittest +from unittest.mock import patch +import uuid +import venv + +import project_native_process as native +import recipe_process as custody + + +WORKER = textwrap.dedent('''\ + import argparse,ctypes,hashlib,json,os,signal,sys,time + from pathlib import Path + parser=argparse.ArgumentParser() + parser.add_argument('--probe',action='store_true') + parser.add_argument('--model-aware',action='store_true') + parser.add_argument('--job') + parser.add_argument('--verify-evidence') + args=parser.parse_args() + if args.probe: + print(json.dumps({'available':True,'model_aware':args.model_aware, + 'secret_present': 'NATIVE_FIXTURE_SECRET' in os.environ}),flush=True) + else: + path=Path(args.verify_evidence or args.job) + job=json.loads(path.read_bytes()) + assert job['parent_pid']==os.getppid() and job['parent_pid']>1 + assert ctypes.CDLL(None).prctl(1,signal.SIGKILL,0,0,0)==0 + assert job['parent_pid']==os.getppid() + root=Path(job['output_dir']) + if job.get('mode')=='escape': + child=os.fork() + if child==0: + os.setsid();child=os.fork() + if child:os._exit(0) + os.setsid();signal.signal(signal.SIGTERM,signal.SIG_IGN) + (root/'escaped.pid').write_text(str(os.getpid())) + time.sleep(30);os._exit(0) + os.waitpid(child,0);time.sleep(30) + else: + phase='verify' if args.verify_evidence else 'train' + (root/(phase+'.json')).write_text(json.dumps({'parent_pid':os.getppid(), + 'job_sha256':hashlib.sha256(path.read_bytes()).hexdigest(),'text':job['text']})) + print(json.dumps({'phase':phase}),flush=True) + ''').encode() + + +class NativeProcessTests(unittest.TestCase): + def setUp(self): + self.temporary=tempfile.TemporaryDirectory(prefix='tuldok-native-process-') + self.addCleanup(self.temporary.cleanup) + self.root=Path(self.temporary.name);self.handles=[] + self.addCleanup(self.drain) + executable=str(Path('/usr/bin/python3').resolve()) + raw=Path(executable).read_bytes() + self.runtime=dict(executable=executable,executable_sha256=hashlib.sha256(raw).hexdigest(), + trusted_install_owner_uid=os.stat('/usr').st_uid, + files={executable:dict(sha256=hashlib.sha256(raw).hexdigest(),bytes=len(raw))}) + + def drain(self): + for handle in reversed(self.handles): + handle.close_control() + + def prepare(self,phase='train',*,mode='complete',output=None,sources=None): + stage=self.root/(phase+'-'+uuid.uuid4().hex) + job=None if phase=='probe' else dict(parent_pid=1,output_dir=str(output or stage/'output'), + mode=mode,text='exact Unicode 山') + return native.prepare(stage,phase=phase,attempt_id=uuid.uuid4().hex,request_sha256='a'*64, + runtime=self.runtime,worker='text_training.py',sources=sources or {'text_training.py':WORKER}, + job=job,model_aware=True,wall_seconds=8,max_output_bytes=16384) + + def until(self,handle,predicate,*,event_type=None,timeout=10): + deadline=time.monotonic()+timeout;events=[] + while time.monotonic()1 + Path(job['output_dir'],'venv.json').write_text(json.dumps(result)) + print(json.dumps(result),flush=True) + ''').encode() + for phase in ('probe','train'): + stage=self.root/('venv-'+phase) + prepared=native.prepare(stage,phase=phase,attempt_id=uuid.uuid4().hex,request_sha256='b'*64, + runtime=runtime,worker='text_training.py',sources={'text_training.py':source}, + job=None if phase=='probe' else dict(parent_pid=1,output_dir=str(stage/'output')), + wall_seconds=8,max_output_bytes=16384) + self.assertEqual(prepared.spec['argv'][0],executable) + handle=self.launch(prepared);prepared.check_current_runtime();handle.go(handle.ready) + proof=self.finish(prepared,handle);result=json.loads(proof['stdout']) + self.assertEqual(proof['receipt']['outcome'],'completed',proof) + self.assertEqual(result['prefix'],str(root));self.assertEqual(result['executable'],executable) + self.assertEqual(result['value'],'explicit configured venv package') + self.assertTrue(Path(result['module']).is_relative_to(root)) + if phase=='train': + self.assertEqual(json.loads((stage/'output/venv.json').read_bytes()),result) + + def test_verified_executable_fd_rejects_wrong_bytes_without_closing_or_repositioning_callers_fd(self): + prepared=self.prepare();wrong=self.root/'wrong-executable';wrong.write_bytes(b'not the qualified interpreter') + descriptor=os.open(wrong,os.O_RDONLY) + try: + os.lseek(descriptor,7,os.SEEK_SET) + with self.assertRaises(custody.ProcessError) as error: + custody.launch(prepared.spec,prepared.stage/'custody',executable_fd=descriptor) + self.assertEqual(error.exception.code,'stale') + self.assertEqual(os.lseek(descriptor,0,os.SEEK_CUR),7) + self.assertTrue(os.fstat(descriptor)) + self.assertFalse((prepared.stage/'custody').exists()) + finally:os.close(descriptor) + + def test_current_venv_config_drift_before_go_prevents_dispatch_and_still_drains(self): + root,executable=self.configured_venv();runtime=native.current_runtime(executable) + stage=self.root/'venv-drift' + prepared=native.prepare(stage,phase='probe',attempt_id=uuid.uuid4().hex,request_sha256='c'*64, + runtime=runtime,worker='text_training.py',sources={'text_training.py':WORKER},wall_seconds=8) + handle=self.launch(prepared) + with (root/'pyvenv.cfg').open('a') as output:output.write('\n# changed after READY\n') + with self.assertRaises(custody.ProcessError) as error: + try: + prepared.check_current_runtime() + handle.go(handle.ready) + finally:handle.close_control() + self.assertEqual(error.exception.code,'stale') + proof=prepared.evidence(handle) + self.assertFalse(proof['receipt']['go_received']);self.assertTrue(proof['receipt']['drained']) + + def test_current_interpreter_alias_drift_before_launch_cannot_select_another_interpreter(self): + root,executable=self.configured_venv();runtime=native.current_runtime(executable) + stage=self.root/'venv-alias-drift' + prepared=native.prepare(stage,phase='probe',attempt_id=uuid.uuid4().hex,request_sha256='d'*64, + runtime=runtime,worker='text_training.py',sources={'text_training.py':WORKER},wall_seconds=8) + Path(executable).unlink();Path(executable).symlink_to(Path('/usr/bin/python3').resolve()) + with self.assertRaises(custody.ProcessError) as error:prepared.launch(stage/'custody') + self.assertEqual(error.exception.code,'stale');self.assertFalse((stage/'custody').exists()) + + def test_spawn_callback_is_not_reached_for_current_config_alias_or_capture_refusal(self): + root,executable=self.configured_venv();runtime=native.current_runtime(executable) + stage=self.root/'spawn-validation' + prepared=native.prepare(stage,phase='probe',attempt_id=uuid.uuid4().hex,request_sha256='f'*64, + runtime=runtime,worker='text_training.py',sources={'text_training.py':WORKER},wall_seconds=8) + configuration=root/'pyvenv.cfg';original=configuration.read_bytes() + marker={'never_launched'};calls=[] + def before_spawn(): + calls.append('spawn');marker.discard('never_launched') + with patch.object(custody.subprocess,'Popen',side_effect=AssertionError('Validation must precede Popen')) as spawn: + configuration.write_bytes(original+b'\n# changed before launch\n') + with self.assertRaises(custody.ProcessError): + prepared.launch(stage/'custody',_before_spawn=before_spawn) + configuration.write_bytes(original) + Path(executable).unlink();Path(executable).symlink_to('/unavailable-native-interpreter') + with self.assertRaises((OSError,custody.ProcessError)): + prepared.launch(stage/'custody',_before_spawn=before_spawn) + captured=self.prepare('probe');source=captured.stage/'source/text_training.py' + source.chmod(0o600);source.write_bytes(WORKER+b'\n# changed capture\n');source.chmod(0o400) + with self.assertRaises(custody.ProcessError): + captured.launch(captured.stage/'custody',_before_spawn=before_spawn) + spawn.assert_not_called() + self.assertEqual(calls,[]);self.assertEqual(marker,{'never_launched'}) + + def test_real_spawn_callback_runs_once_immediately_before_popen_without_sending_go(self): + prepared=self.prepare('probe');events=[];marker={'never_launched'} + actual_popen=custody.subprocess.Popen + def before_spawn(): + events.append('callback');marker.discard('never_launched') + def spawn(*args,**kwargs): + self.assertEqual(events,['callback']);self.assertEqual(marker,set()) + events.append('popen') + return actual_popen(*args,**kwargs) + with patch.object(custody.subprocess,'Popen',side_effect=spawn): + handle=prepared.launch(prepared.stage/'custody',_before_spawn=before_spawn) + self.handles.append(handle);self.until(handle,lambda:handle.ready is not None) + self.assertEqual(events,['callback','popen']);self.assertFalse(handle.sent_go) + receipt=handle.close_control() + self.assertTrue(receipt['drained']);self.assertFalse(receipt['go_received']) + + def test_popen_entry_failure_cannot_reinstate_never_launched_proof(self): + prepared=self.prepare('probe');marker={'never_launched'};events=[] + def before_spawn(): + events.append('callback');marker.discard('never_launched') + def fail_spawn(*args,**kwargs): + self.assertEqual(events,['callback']);self.assertEqual(marker,set()) + events.append('popen');raise OSError('Synthetic failure after entering Popen') + with patch.object(custody.subprocess,'Popen',side_effect=fail_spawn): + with self.assertRaisesRegex(OSError,'after entering Popen'): + prepared.launch(prepared.stage/'custody',_before_spawn=before_spawn) + self.assertEqual(events,['callback','popen']);self.assertEqual(marker,set()) + + def test_post_spawn_handle_failure_cannot_reinstate_never_launched_proof(self): + prepared=self.prepare('probe');marker={'never_launched'};processes=[] + actual_popen=custody.subprocess.Popen + def before_spawn():marker.discard('never_launched') + def spawn(*args,**kwargs): + process=actual_popen(*args,**kwargs);processes.append(process);return process + with (patch.object(custody.subprocess,'Popen',side_effect=spawn), + patch.object(custody,'Handle',side_effect=OSError('Synthetic post-spawn Handle failure'))): + with self.assertRaisesRegex(OSError,'post-spawn Handle failure'): + prepared.launch(prepared.stage/'custody',_before_spawn=before_spawn) + self.assertEqual(marker,set());self.assertEqual(len(processes),1) + # Owner control closes on failure. Even if the helper exits, no Handle / + # READY identity exists to assert an acknowledged drain for this owner. + processes[0].wait(timeout=custody.CLEANUP_SECONDS+2) + + def test_retained_spec_grammar_does_not_mistake_original_uid_for_current_permission(self): + prepared=self.prepare();spec=prepared.spec;raw=custody.encoded(spec) + with patch.object(custody.os,'getuid',return_value=spec['trusted_install_owner_uid']): + with self.assertRaises(custody.ProcessError):custody.validate_spec(spec) + retained=custody.decode_retained_spec(spec) + checked=native.validate_prepared_spec(spec,phase='train',worker='text_training.py') + self.assertEqual(custody.encoded(retained),raw);self.assertEqual(custody.encoded(checked),raw) + for change in (dict(argv=[spec['argv'][0],'-c','print("unclassified")']),dict(env={'PYTHONPATH':'/outside'}), + dict(wall_seconds=601),dict(schema_version=True)): + with self.subTest(change=tuple(change)),self.assertRaises(custody.ProcessError): + native.validate_prepared_spec(dict(spec,**change),phase='train',worker='text_training.py') + + def test_pure_current_runtime_decoder_rejects_unclassified_observed_files(self): + runtime=native.current_runtime(str(Path('/usr/bin/python3'))) + self.assertEqual(native.validate_current_runtime(runtime),runtime) + outside=dict(runtime,files={**runtime['files'],'/arbitrary/file':{'sha256':'e'*64,'bytes':1}}) + with self.assertRaises(custody.ProcessError):native.validate_current_runtime(outside) + + def test_original_phase_reader_reuses_exact_live_evidence_without_runtime_or_process_authority(self): + prepared=self.prepare();handle=self.launch(prepared);handle.go(handle.ready) + live=self.finish(prepared,handle);handle.close_control() + with (patch.object(custody,'launch',side_effect=AssertionError('No launch from original evidence')), + patch.object(custody,'identity',side_effect=AssertionError('No current process lookup')), + patch.object(native,'current_runtime',side_effect=AssertionError('No runtime lookup'))): + original=native.read_phase_evidence(prepared.stage,prepared.spec,phase='train',helper=handle.ready) + self.assertEqual(original,live) + self.assertEqual(set(original),{'receipt','stdout','stderr','template','effective_job','parent_observation'}) + self.assertEqual(original['template'],prepared.template) + + def test_original_phase_reader_refuses_wrong_physical_stage_spec_helper_and_template(self): + prepared=self.prepare();handle=self.launch(prepared);handle.go(handle.ready) + self.finish(prepared,handle) + wrong_helper={**handle.ready,'pid':handle.ready['pid']+1000000} + for stage,spec,helper in ((self.root/'other-physical-stage',prepared.spec,handle.ready), + (prepared.stage,dict(prepared.spec,wall_seconds=7),handle.ready), + (prepared.stage,prepared.spec,wrong_helper)): + with self.subTest(stage=str(stage),helper=helper['pid']),self.assertRaises(custody.ProcessError): + native.read_phase_evidence(stage,spec,phase='train',helper=helper) + template=prepared.stage/'native-job-template.json' + template.chmod(0o600);template.write_bytes(prepared.template+b' ');template.chmod(0o400) + with self.assertRaises(custody.ProcessError): + native.read_phase_evidence(prepared.stage,prepared.spec,phase='train',helper=handle.ready) + + def test_original_phase_reader_preserves_real_failed_receipt_before_effective_job_exists(self): + stopped=self.root/'native_process_bootstrap.py' + stopped.write_bytes(b'raise SystemExit(17)\n') + with patch.object(native,'BOOTSTRAP',stopped):prepared=self.prepare() + handle=self.launch(prepared);handle.go(handle.ready) + self.until(handle,lambda:handle.terminal);handle.process.wait(timeout=2) + original=native.read_phase_evidence(prepared.stage,prepared.spec,phase='train',helper=handle.ready) + self.assertTrue(original['receipt']['go_received']);self.assertTrue(original['receipt']['drained']) + self.assertEqual(original['receipt']['outcome'],'failed') + self.assertEqual(original['receipt']['leader_returncode'],17) + self.assertEqual(original['template'],prepared.template) + self.assertIsNone(original['effective_job']);self.assertIsNone(original['parent_observation']) + + def test_original_phase_reader_rejects_aliased_or_changed_evidence_bytes(self): + prepared=self.prepare();handle=self.launch(prepared);handle.go(handle.ready) + self.finish(prepared,handle) + observation=prepared.stage/'native-parent-observation.json' + alias=self.root/'evidence-hardlink';os.link(observation,alias) + with self.assertRaises(custody.ProcessError): + native.read_phase_evidence(prepared.stage,prepared.spec,phase='train',helper=handle.ready) + alias.unlink() + log=handle.directory/'stdout.log';log.write_bytes(log.read_bytes()+b'unacknowledged change') + with self.assertRaises(custody.ProcessError): + native.read_phase_evidence(prepared.stage,prepared.spec,phase='train',helper=handle.ready) + + def test_original_phase_reader_cannot_normalize_changed_raw_receipt_bytes(self): + prepared=self.prepare();handle=self.launch(prepared);handle.go(handle.ready) + self.finish(prepared,handle) + receipt=handle.directory/'receipt.json';receipt.write_bytes(receipt.read_bytes()+b'\n') + with self.assertRaises(custody.ProcessError) as error: + native.read_phase_evidence(prepared.stage,prepared.spec,phase='train',helper=handle.ready) + self.assertEqual(error.exception.code,'unknown') + + def pure_job_proof(self): + prepared=self.prepare();helper=custody.identity(os.getpid()) + template=prepared.template;frame=custody.decode(template) + effective=custody.encoded(dict(frame['job'],parent_pid=helper['pid'])) + observation=custody.encoded(dict(format='native_parent_observation_v1', + attempt_id=prepared.spec['attempt_id'],request_sha256=prepared.spec['request_sha256'], + template_sha256=hashlib.sha256(template).hexdigest(), + effective_job_sha256=hashlib.sha256(effective).hexdigest(),helper=helper)) + receipt=dict(schema_version=1,attempt_id=prepared.spec['attempt_id'], + request_sha256=prepared.spec['request_sha256'], + spec_sha256=hashlib.sha256(custody.encoded(prepared.spec)).hexdigest(),helper=helper, + go_received=True,spawn_closed=True,drained=True,echild=True,outcome='completed',reason='exited', + leader_returncode=0,streams={name:dict(bytes=0,sha256=hashlib.sha256(b'').hexdigest()) for name in ('stdout','stderr')}) + return prepared,dict(phase='train',helper=helper,template=template,effective_job=effective, + parent_observation=observation,receipt=receipt) + + def test_inline_job_proof_is_pure_and_rejects_tampered_binding_or_effective_job(self): + prepared,proof=self.pure_job_proof() + with (patch.object(native.os,'open',side_effect=AssertionError('Inline proof never opens a path')), + patch.object(custody,'identity',side_effect=AssertionError('Inline proof never resolves a PID')), + patch.object(custody,'launch',side_effect=AssertionError('Inline proof never launches')), + patch.object(native,'current_runtime',side_effect=AssertionError('Inline proof never resolves runtime'))): + accepted=native.validate_job_evidence(prepared.spec,**proof) + self.assertEqual(accepted,{key:proof[key] for key in ('receipt','template','effective_job','parent_observation')}) + wrong_job=custody.decode(proof['effective_job']);wrong_job['parent_pid']+=1 + wrong_observation=custody.decode(proof['parent_observation']);wrong_observation['helper']['start_ticks']='0' + frame=custody.decode(proof['template']);frame['job']['text']='silently rewritten source' + for change in (dict(effective_job=custody.encoded(wrong_job)), + dict(parent_observation=custody.encoded(wrong_observation)), + dict(template=custody.encoded(frame)), + dict(receipt=dict(proof['receipt'],spec_sha256='0'*64)), + dict(effective_job=None,parent_observation=None), + dict(parent_observation=None), + dict(effective_job=b'x'*(native.TEMPLATE_LIMIT+1))): + with self.subTest(change=tuple(change)),self.assertRaises(custody.ProcessError): + native.validate_job_evidence(prepared.spec,**{**proof,**change}) + + def test_inline_failed_phase_preserves_absent_effective_job_without_fabrication(self): + prepared,proof=self.pure_job_proof() + proof.update(effective_job=None,parent_observation=None, + receipt=dict(proof['receipt'],outcome='failed',leader_returncode=17,reason='nonzero_exit')) + with patch.object(native.os,'open',side_effect=AssertionError('No historical path reads')): + accepted=native.validate_job_evidence(prepared.spec,**proof) + self.assertIsNone(accepted['effective_job']);self.assertIsNone(accepted['parent_observation']) + self.assertEqual(accepted['template'],prepared.template) + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_project_native_run.py b/tests/test_project_native_run.py new file mode 100644 index 00000000..b88b07af --- /dev/null +++ b/tests/test_project_native_run.py @@ -0,0 +1,292 @@ +"""One actual native CPU Run reused for independent Save As and cold Git reads. + +Requires an explicitly qualified immutable source and CPU environment. No model +quality, package closure, or portability of installed dependencies is asserted. +""" +import copy +import hashlib +import json +import os +from pathlib import Path +import subprocess +import tempfile +import time +import unittest +from unittest.mock import patch +import uuid + +import psycopg +from psycopg import sql + +from auth_helpers import ExplicitOwnerFixture +from metadata import connection_info +from project_session import operation_records +from project_artifacts import ProjectArtifactError +from project_store import ProjectStoreError, GITIGNORE, _encode as canonical +from project_workspace import ProjectWorkspace +from workbench import WorkbenchError +import recipe_process +import text_training +from test_project_native_study import (prepare_text_source, retain_project_evidence, + qa_retained_path, native_drain_wait_seconds, NATIVE_PYTHON) + + +@unittest.skipUnless(os.environ.get('TULDOK_NATIVE_PROJECT_GO')=='1' + and os.environ.get('TULDOK_DATABASE_URL') + and os.environ.get('TULDOK_PROJECT_FILES_COLD_DATABASE_URL') and NATIVE_PYTHON is not None and NATIVE_PYTHON.is_file(), + 'Requires explicit native gate, qualified CPU runtime, and separate PostgreSQL cold role') +class NativeRunSaveAsTests(unittest.TestCase): + def setUp(self): + self.temporary=tempfile.TemporaryDirectory(prefix='tuldok-native-run-save-as-') + self.addCleanup(self.temporary.cleanup) + self.root=Path(self.temporary.name);self.external=self.root/'external';self.external.mkdir() + self.hosts=[];self.controls=[] + self.addCleanup(self.cleanup) + self.enterContext(patch.dict(os.environ,{'TULDOK_TRAINING_PYTHON':str(NATIVE_PYTHON)})) + self.select_host('source-control',os.environ['TULDOK_DATABASE_URL']) + self.call('new',root='fixture',directory='source',name='Actual model-aware native Run') + + def select_host(self,name,dsn): + control=self.root/name + workspace=ProjectWorkspace(control,{'fixture':self.external},dsn=dsn) + owner=ExplicitOwnerFixture.bootstrap(workspace) + self.hosts.append((workspace,owner));self.controls.append((control,dsn)) + self.workspace=workspace;self.owner=owner + + def call(self,action,**body): + workspace=self.workspace + with self.owner.bound(workspace): + state=workspace.status() + arguments=dict(binding=state['binding'],expected_checkpoint=(state['project']['checkpoint'] + if state['project'] else None),request_key=uuid.uuid4().hex,**body) + return workspace.dispatch('project_files.'+action,arguments) + + def cleanup(self): + for workspace,owner in reversed(self.hosts): + if workspace._closed:continue + with owner.bound(workspace): + projection=workspace._projection + if projection is not None:retain_project_evidence(projection,'direct-before-cleanup') + if projection is not None and projection.execution._runs is not None: + with projection.execution.runtime_access._reconcile():projection.execution._runs.close() + if projection.session.available and not projection.custody.has_pending: + projection.execution.reconcile_current() + workspace.close() + # Only schemas created below these disposable fixture control roots. + for control,dsn in self.controls: + with psycopg.connect(connection_info(dsn),autocommit=True) as connection: + for manifest in control.rglob('.tuldok.json'): + identity=json.loads(manifest.read_bytes()) + schema=('projection_'+identity['projection_id'] if identity['version']==2 + else 'catalog_'+identity['dataset_id']) + connection.execute(sql.SQL('DROP SCHEMA IF EXISTS {} CASCADE').format(sql.Identifier(schema))) + + def prepare_graph(self): + projection=self.workspace._projection + _,release=prepare_text_source(projection,key_prefix='native-direct-text') + body=dict(name='Actual direct text model evidence',recipe=text_training.MODEL_RECIPE,release_id=release['id']) + graph=projection.materials.mutate_definition('pipeline_graph','graphs.create',None,body, + projection.session.snapshot['checkpoint'],'native-direct-graph',lambda:projection.pipelines.create(body)) + return graph + + def await_drained(self,projection,run_id,timeout=None): + deadline=time.monotonic()+native_drain_wait_seconds(timeout);run=None + while time.monotonic()>'status'='stopping' THEN RAISE EXCEPTION 'authored deferred cancellation failure' USING ERRCODE='23514'; END IF; RETURN NEW; END $$") + db.execute('CREATE CONSTRAINT TRIGGER reject_cancel_commit AFTER UPDATE ON generation_jobs DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION reject_cancel_commit()') + try: + with patch.object(owner,'_signal_cancel',wraps=owner._signal_cancel) as signal,patch.object(owner.dataset.image_requests,'cancel',wraps=owner.dataset.image_requests.cancel) as provider_cancel: + with self.assertRaises(ProjectSessionError) as caught:self.adapter.cancel(self.family,ack['producer_id'],self.cp(),key) + self.assertEqual(caught.exception.code,'projection_unavailable') + self.assertEqual(signal.call_count,0);self.assertEqual(provider_cancel.call_count,0) + self.assertFalse(owner.stop.is_set());self.assertTrue(owner.worker.is_alive()) + self.assertFalse(self.session.available);self.assertTrue(self.adapter._fenced) + self.assertTrue(self.journal.has_pending) + self.assertEqual(owner._jobs()[0]['status'],'generating') + self.assertTrue(any(r['family']=='project_operation' and r['document']['request_key']==key for r in self.store.snapshot()['records'])) + finally:self._cleanup_fenced_fixture_worker(owner) + + def test_cancel_lost_sql_ack_fences_without_signalling_even_when_sql_committed(self): + ack,owner=self._active_image();db=self.session.db;original=psycopg.Connection.transaction;injected=[] + @contextmanager + def transaction(connection,*args,**kwargs): + with original(connection,*args,**kwargs):yield + if connection is db.connection and not injected: + rows=connection.execute("SELECT id FROM generation_jobs WHERE data::jsonb->>'status'='stopping'").fetchall() + if rows: + injected.append('actual-cancel-commit-ack-lost') + raise OSError('Authored lost cancellation commit acknowledgement') + try: + with patch.object(psycopg.Connection,'transaction',transaction),patch.object(owner,'_signal_cancel',wraps=owner._signal_cancel) as signal,patch.object(owner.dataset.image_requests,'cancel',wraps=owner.dataset.image_requests.cancel) as provider_cancel: + with self.assertRaises(ProjectSessionError) as caught:self.adapter.cancel(self.family,ack['producer_id'],self.cp(),uuid.uuid4().hex) + self.assertEqual(caught.exception.code,'projection_unavailable') + self.assertEqual(len(injected),1);self.assertEqual(signal.call_count,0);self.assertEqual(provider_cancel.call_count,0) + self.assertFalse(owner.stop.is_set());self.assertTrue(owner.worker.is_alive()) + self.assertFalse(self.session.available);self.assertTrue(self.adapter._fenced) + self.assertTrue(self.journal.has_pending) + self.assertEqual(owner._jobs()[0]['status'],'stopping') + finally:self._cleanup_fenced_fixture_worker(owner) + + def enable_journal(self): + from project_custody import ProjectCustody + self.custody_index=ProjectCustody(self.host.db.path,self.host.db.dataset_id) + self.journal=self.custody_index.for_projection(self.store,self.session.db.projection_id) + self.projection.custody=self.journal + self.projection.execution.custody=self.journal + self.session.publication_intent=self.journal.expect_publication + return self.journal + + def check_journal_before_http(self): + if self.journal is None:return + try: + values=self.journal.snapshot()['producers'] + if len(values)!=1 or values[0]['family']!=self.family or values[0]['phase']!='effect_intent' or values[0]['provider_outcome']!='unknown' or values[0]['worker_drained']: + raise AssertionError('Actual HTTP preceded durable original-host producer effect intent') + from project_custody import ProjectCustody + reread=ProjectCustody(self.host.db.path,self.host.db.dataset_id) + if not reread.pending(self.store):raise AssertionError('Original host journal was not durably readable before HTTP') + except BaseException as error:self.errors.append(str(error)) + + def test_original_host_journal_precedes_http_and_settles_only_actual_known_drain(self): + journal=self.enable_journal() + ack=self.adapter.start('text_proposal_history',self.body(),self.cp(),uuid.uuid4().hex) + job=self.wait();self.assertEqual(job['status'],'completed',job['error']) + self.assertTrue(journal.has_pending) + self.assertEqual(self.errors,[]) + self.adapter.assert_quiescent() + self.assertFalse(journal.has_pending) + from project_custody import ProjectCustody + self.assertEqual(ProjectCustody(self.host.db.path,self.host.db.dataset_id).pending(self.store),[]) + files=list((self.host.db.path/'.project-custody').glob('*.json')) + self.assertEqual(len(files),1);self.assertEqual(files[0].stat().st_mode & 0o777,0o600) + self.assertFalse((self.root/'project'/'.project-custody').exists()) + self.assertEqual(self.session.db.execute('SELECT count(*) FROM access_credentials').fetchone()[0],0) + + def test_unknown_provider_remains_in_original_host_journal_after_local_drain(self): + journal=self.enable_journal();self.hold=True + ack=self.adapter.start('text_proposal_history',self.body(),self.cp(),uuid.uuid4().hex) + self.assertTrue(self.observed.wait(8)) + self.adapter.cancel('text_proposal_history',ack['producer_id'],self.cp(),uuid.uuid4().hex) + self.assertFalse(self.adapter._owners['text_proposal_history'].worker.is_alive()) + health=self.adapter.status();self.assertTrue(health['fenced']) + retained=journal.snapshot()['producers'][0] + self.assertEqual(retained['provider_outcome'],'unknown');self.assertTrue(retained['worker_drained']) + from project_custody import ProjectCustody + reopened=ProjectCustody(self.host.db.path,self.host.db.dataset_id) + self.assertEqual(len(reopened.pending(self.store)),1) + self.assertEqual(self.errors,[]) + + def test_uncertain_original_host_effect_intent_ack_never_launches_worker(self): + journal=self.enable_journal();original=journal.producer_effect_intent + def lost(family,identity): + original(family,identity) + raise WorkbenchError('Authored lost original-host effect-intent acknowledgement','outcome_unknown',409) + with patch.object(journal,'producer_effect_intent',lost): + with self.assertRaises(WorkbenchError) as caught:self.adapter.start('text_proposal_history',self.body(),self.cp(),uuid.uuid4().hex) + self.assertEqual(caught.exception.code,'outcome_unknown') + self.assertEqual(self.requests,[]);self.assertFalse(self.session.available) + self.assertIsNone(self.adapter._owners['text_proposal_history'].worker) + from project_custody import ProjectCustody + reopened=ProjectCustody(self.host.db.path,self.host.db.dataset_id) + self.assertEqual(len(reopened.pending(self.store)),1) + self.assertEqual(journal.snapshot()['producers'][0]['phase'],'effect_intent') diff --git a/tests/test_project_producers.py b/tests/test_project_producers.py new file mode 100644 index 00000000..7e4f27a6 --- /dev/null +++ b/tests/test_project_producers.py @@ -0,0 +1,414 @@ +"""Real PostgreSQL inert producer codecs; all scientific observations synthetic. + +No model, provider service, Dataset or producer worker runs in these fixtures. +The source observations are authored explicitly and checked against owner wire +validators. Distinct projections prove that imported admissions cannot resume. +""" +import base64 +import copy +import hashlib +import io +import json +import os +from pathlib import Path +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch +import uuid + +from PIL import Image +import psycopg +from psycopg import sql + +from access import Access, AccessError +from auth_helpers import ExplicitOwnerFixture +import caption_proposals as caption +import grounded_candidates as grounded +from metadata import Metadata, connection_info +from project_artifacts import ProjectArtifacts +from project_data import ProjectData, decode_data_records +from project_producers import ProjectProducers, decode_records, validate_target_proposal, validate_record_origin +from project_session import ProjectSession +from project_store import ProjectStore +import pumas_operations as pumas +import pumas_vision +import synthetic +import text_classification_proposals as classification +from workbench import WorkbenchError, encode, timestamp + +ROOT = Path(__file__).resolve().parents[1] + + +def sha(raw): + return hashlib.sha256(raw).hexdigest() + + +def response(annotation): + return encode({'model': 'fixture-model', 'choices': [{'finish_reason': 'stop', + 'message': {'role': 'assistant', 'content': encode(annotation)}}]}).encode() + + +def validate(records): + decode_data_records(records) + decode_records(records) + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'), 'Requires disposable real local PostgreSQL') +class ProjectProducerTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='tuldok-producer-codec-') + self.path = Path(self.temporary.name) + self.addCleanup(self.temporary.cleanup) + self.schemas, self.metadata = [], [] + self.addCleanup(self.cleanup_database) + self.store = ProjectStore.create(self.path/'project', 'Synthetic producer facts', uuid.uuid4().hex, '1'*64) + self.addCleanup(self.store.close) + self.artifacts = ProjectArtifacts(self.store) + self.addCleanup(self.artifacts.close) + self.session, self.host, self.owner = self.new_session('source') + self.producers = ProjectProducers(self.session) + self.data = ProjectData(self.session, generation_entries=self.producers.generation_jobs) + self.binding = self.owner.bound(self.host) + self.binding.__enter__() + self.addCleanup(self.binding.__exit__, None, None, None) + self.text = self.data.import_text({'name':'Captured café 山', 'text':'Café 山 means an exact source.', + 'groups':['fixture'], 'rights':'Owned synthetic test input'}, self.checkpoint(), uuid.uuid4().hex) + image = io.BytesIO(); Image.new('RGB',(16,8),'blue').save(image,'PNG') + self.image = self.data.import_image({'name':'Authored blue source','image':base64.b64encode(image.getvalue()).decode(), + 'groups':['fixture-image'],'rights':'Owned synthetic pixels'}, self.checkpoint(), uuid.uuid4().hex) + + def checkpoint(self): + return self.session.snapshot['checkpoint'] + + def new_session(self, name, dsn=None): + dsn = dsn or os.environ['TULDOK_DATABASE_URL'] + host_db = Metadata(self.path/(name+'-authority'), dsn=dsn) + projection = Metadata(self.path/(name+'-projection'), dsn=dsn, + dataset_id=self.store.snapshot()['project_id'], projection_id=uuid.uuid4().hex) + for db in (host_db, projection): + self.schemas.append((dsn, db.schema)); self.metadata.append(db) + host = SimpleNamespace(db=host_db, access=Access(host_db, host_db.path)) + owner = ExplicitOwnerFixture.bootstrap(host) + session = ProjectSession(self.store, projection, host.access, self.artifacts, validate_records=validate) + return session, host, owner + + def cleanup_database(self): + for db in reversed(self.metadata): + db.close() + for dsn, schema in self.schemas: + with psycopg.connect(connection_info(dsn), autocommit=True) as connection: + connection.execute(sql.SQL('DROP SCHEMA IF EXISTS {} CASCADE').format(sql.Identifier(schema))) + + def seed(self, table, job): + self.session.db.execute('INSERT INTO '+table+'(id,data) VALUES(%s,%s)',(job['id'],encode(job))) + + def proposal(self, kind='text', *, typed=False): + source = copy.deepcopy(self.text if kind=='text' else self.image) + module = classification if kind=='text' else caption + config = {'provider':'pumas_chat_compatible','server_url':'http://127.0.0.1:9', + 'model':'fixture-model','instruction':'Exact guidance café 山','seed':7} + if kind=='text': + config.update(requested_model='fixture-model',requested_server_url='http://127.0.0.1:9', labels=['first','second']) + annotation = {'label':'first'} if kind=='text' else {'caption':'A blue rectangle café 山.'} + raw = response(annotation) + now = timestamp() + job = {'schema_version':1,'id':uuid.uuid4().hex,'revision':3,'status':'completed','error':'', + 'created_at':now,'updated_at':now,'intent_sha256':'2'*64,'source':source,'config':config, + 'system_prompt':module.SYSTEM_PROMPT,'prompt_version':module.PROMPT_VERSION, + 'prompt_sha256':sha(module.SYSTEM_PROMPT.encode()),'annotation':annotation,'application':None, + 'raw_response_base64':base64.b64encode(raw).decode(),'response_sha256':sha(raw), + 'reported_model':'fixture-model','verification':'Synthetic shape observation; no semantic verification.', + 'admission_id':uuid.uuid4().hex,'request_digest':'3'*64} + if kind=='text': + job.update(labels_sha256=sha(encode(config['labels']).encode()),source_text_sha256=sha(source['text'].encode())) + else: + out=io.BytesIO(); Image.new('RGB',(16,8),'blue').save(out,'JPEG',quality=95); image=out.getvalue() + job.update(input_image_base64=base64.b64encode(image).decode(),input_image={'mime':'image/jpeg','size':[16,8], + 'bytes':len(image),'sha256':sha(image),'preparation':'oriented canonical RGB; thumbnail max 1600; JPEG quality 95'}) + if typed: + self.assertEqual(kind,'image') + fixture=ROOT/'tests/fixtures/pumas-image-to-text-v1/controlled-capabilities.json' + job['capability_observation']=pumas.validate_manifest(fixture.read_bytes()) + config.update(provider='pumas_image_to_text',model='controlled-vision',seed=None,protocol=pumas.PROTOCOL,profile='vision-cpu') + advertisement=(ROOT/'tests/fixtures/pumas-http-pr51/advertisement.json').read_bytes() + import pumas_gateway_descriptor + job['build_observation']=dict(pumas_gateway_descriptor.decode(advertisement),server_url='http://127.0.0.1:9') + job['producer_contract_source']=pumas_vision.SOURCE_COMMIT + job['provider_outcome']='result_received'; job['reported_model']=None + raw=encode({'contract_version':1,'request_id':job['id'],'result':{'kind':'text','text':encode(annotation),'finish_reason':'stop'}}).encode() + job.update(raw_response_base64=base64.b64encode(raw).decode(),response_sha256=sha(raw)) + job['canonical_request']=module.payload(job) + job['canonical_request_sha256']=sha(encode(module.payload(job)).encode()) + return job + + def generation(self): + job={'id':uuid.uuid4().hex,'config':{'server_url':'http://127.0.0.1:9','model':'fixture-model','prompt':'A blue source', + 'width':16,'height':8,'count':1,'strategy':'repeat','seed':4},'meta':{'book_id':'','session_id':'fixture','split':'unassigned'}, + 'status':'completed','error':'','completed':1,'admission_id':uuid.uuid4().hex,'request_digest':'4'*64} + entry=synthetic.Jobs._new_entry(job,0,job['config']['prompt']) + entry.update(status='completed',sample_id=self.image['id'],metadata={'seed':4,'duration_seconds':1.25}) + self.seed('generation_jobs',job) + self.session.db.execute('INSERT INTO generation_entries(id,job_id,ordinal,data) VALUES(%s,%s,%s,%s)', + (entry['id'],entry['job_id'],entry['ordinal'],encode(entry))) + return job,entry + + def commit_producers(self): + upserts=self.producers.capture_source() + merged={(r['family'],r['id']):r for r in self.session.snapshot['records']} + merged.update({(r['family'],r['id']):r for r in upserts}) + snapshot=self.store.commit([merged[k] for k in sorted(merged)],self.checkpoint(),uuid.uuid4().hex,'5'*64) + self.session.adopt(snapshot) + return snapshot + + def grounded_proposal(self): + source=self.data.annotate(self.text['id'],dict(revision=self.text['revision'],source_revision=1, + task='text_classification',annotation={'label':'first'},groups=['fixture'],review='human_reviewed'), + self.checkpoint(),uuid.uuid4().hex) + job=self.proposal() + for field in ('intent_sha256','annotation','application','labels_sha256','source_text_sha256'): + job.pop(field) + job.update(source=source,system_prompt=grounded.SYSTEM_PROMPT,prompt_version=grounded.PROMPT_VERSION, + prompt_sha256=sha(grounded.SYSTEM_PROMPT.encode()),config={'server_url':'http://127.0.0.1:9', + 'model':'fixture-model','instruction':'Preserve the reviewed meaning','seed':7,'count':1}) + value={'text':'An exact source means café 山.','label':'first', + 'evidence':[{'start':0,'end':len(source['text']),'quote':source['text']}]} + raw=response({'candidates':[value]}) + job.update(candidates=[dict(value,id=uuid.uuid4().hex,status='pending_review',record_id=None,review_note='')], + raw_response=raw.decode(),raw_response_base64=base64.b64encode(raw).decode(),response_sha256=sha(raw), + canonical_request_sha256=sha(encode(grounded.request_payload(job)).encode())) + return job + + def test_grounded_source_class_quotes_and_exact_response_are_one_closed_history(self): + job=self.grounded_proposal();self.seed('grounded_jobs',job) + snapshot=self.commit_producers() + doc=next(item['document'] for item in snapshot['records'] if item['family']=='grounded_history') + self.assertEqual(doc['job']['source'],job['source']) + self.assertEqual(doc['job']['candidates'],job['candidates']) + self.assertTrue(doc['job']['raw_response_text_available']) + for mutate in (lambda d:d['job']['candidates'][0].update(label='second'), + lambda d:d['job']['candidates'][0]['evidence'][0].update(quote='invented'), + lambda d:d['job']['candidates'][0].update(text='Invented replacement.'), + lambda d:d.update(ordinal=0)): + changed=copy.deepcopy(snapshot['records']) + changed_doc=next(item['document'] for item in changed if item['family']=='grounded_history') + mutate(changed_doc) + with self.assertRaises(WorkbenchError):decode_records(changed,artifacts=self.artifacts) + + def test_applied_target_evidence_cannot_change_frozen_labels_source_or_review(self): + job=self.proposal() + source=job['source'] + job.update(status='applied',application={'record_id':source['id'],'revision':source['revision']+1,'source_revision':1}) + self.seed('text_classification_proposals',job) + snapshot=self.commit_producers() + record=dict(source,revision=source['revision']+1,task='text_classification',annotation=job['annotation'],review='draft') + evidence={'job_id':job['id'],'applied_revision':record['revision'],'captured_revision':source['revision'], + 'source_revision':1,'source_sha256':source['source_sha256'],'source_text_sha256':job['source_text_sha256'], + 'config':job['config'],'labels_sha256':job['labels_sha256'],'prompt_version':job['prompt_version'], + 'prompt_sha256':job['prompt_sha256'],'canonical_request_sha256':job['canonical_request_sha256'], + 'response_sha256':job['response_sha256'],'reported_model':job['reported_model'], + 'verification':'Exact label shape only; applied as draft, never approved.'} + self.assertTrue(validate_target_proposal(evidence,record,snapshot['records'])) + for key,value in (('labels_sha256','0'*64),('captured_revision',8),('source_sha256','0'*64), + ('response_sha256','0'*64),('config',None)): + with self.subTest(key=key),self.assertRaises(WorkbenchError): + validate_target_proposal(dict(evidence,**{key:value}),record,snapshot['records']) + with self.assertRaises(WorkbenchError): + validate_target_proposal(evidence,dict(record,review='human_reviewed'),snapshot['records']) + with self.assertRaises(WorkbenchError): + validate_target_proposal(evidence,record,[item for item in snapshot['records'] if item['family']!='text_proposal_history']) + + def test_typed_generation_attempt_preserves_exact_request_and_refuses_rewritten_intent(self): + job,entry=self.generation() + job['config'].update(model='controlled-image',protocol=pumas.PROTOCOL,profile='controlled-image-cpu') + entry.update(model='controlled-image',request_id=uuid.uuid4().hex) + evidence={'capability_observation':pumas.validate_manifest( + (ROOT/'tests/fixtures/pumas-typed-v1/controlled-image-capabilities.json').read_bytes()), + 'producer_contract_source':pumas.SOURCE_COMMIT} + request={'contract_version':1,'request_id':entry['request_id'],'model':'controlled-image', + 'profile':'controlled-image-cpu','capability':'image_generation','input':{'kind':'text','text':entry['prompt']}, + 'output':'png_base64','options':{'kind':'image_generation','width':16,'height':8,'seed':4},'stream':False} + pumas.request_evidence(evidence,request) + entry['attempt_evidence']=evidence + self.session.db.execute('UPDATE generation_jobs SET data=%s WHERE id=%s',(encode(job),job['id'])) + self.session.db.execute('UPDATE generation_entries SET data=%s WHERE id=%s',(encode(entry),entry['id'])) + snapshot=self.commit_producers() + doc=next(item['document'] for item in snapshot['records'] if item['family']=='generation_history') + wrapper=doc['entries'][0] + self.assertEqual(wrapper['payloads']['attempt_request']['artifact']['sha256'],sha(encode(request).encode())) + self.assertNotIn('canonical_request',wrapper['entry']['attempt_evidence']) + self.assertEqual(doc['ordinal'],1);self.assertEqual(wrapper['storage_order'],1) + changed=copy.deepcopy(snapshot['records']) + changed_doc=next(item['document'] for item in changed if item['family']=='generation_history') + changed_doc['entries'][0]['entry']['prompt']='A silently changed producer prompt' + changed_doc['entries'][0]['history'][0]['prompt']='A silently changed producer prompt' + with self.assertRaises(WorkbenchError):decode_records(changed,artifacts=self.artifacts) + + def test_outer_source_identity_and_credential_bearing_historical_urls_are_rejected(self): + job=self.proposal();self.seed('text_classification_proposals',job) + job['id']=uuid.uuid4().hex + self.session.db.execute('UPDATE text_classification_proposals SET data=%s',(encode(job),)) + with self.assertRaises(WorkbenchError):self.producers.capture_source() + self.session.db.execute('DELETE FROM text_classification_proposals') + job=self.proposal();job['config']['server_url']='http://user:password@127.0.0.1:9' + self.seed('text_classification_proposals',job) + with self.assertRaises(ValueError):self.producers.capture_source() + + def test_generated_origin_requires_exact_entry_metadata_and_source_identity(self): + job,entry=self.generation();snapshot=self.commit_producers() + generation={key:entry[key] for key in ('job_id','ordinal','prompt','model','width','height','requested_seed','metadata')} + origin={'method':'model_generated','rights':'Owned synthetic pixels','source_sha256':self.image['source_sha256'], + 'generation':generation} + self.assertTrue(validate_record_origin(origin,self.image,snapshot['records'])) + changed=copy.deepcopy(origin);changed['generation']['metadata']['duration_seconds']=500 + with self.assertRaises(WorkbenchError):validate_record_origin(changed,self.image,snapshot['records']) + with self.assertRaises(WorkbenchError): + validate_record_origin(origin,dict(self.image,id=uuid.uuid4().hex),snapshot['records']) + self.assertFalse(validate_record_origin({'method':'import'},self.image,snapshot['records'])) + + def test_grounded_origin_retains_reviewed_source_and_candidate_disposition(self): + job=self.grounded_proposal();candidate=job['candidates'][0] + target=self.data.import_text({'name':'Retained rewrite','text':candidate['text'],'groups':['fixture'], + 'rights':'Owned synthetic candidate'},self.checkpoint(),uuid.uuid4().hex) + candidate.update(status='admitted',record_id=target['id'],review_note='Inspected proposal as a draft.') + self.seed('grounded_jobs',job);snapshot=self.commit_producers() + source=job['source'] + origin={'method':'model_grounded_rewrite','rights':'Owned synthetic candidate','normalization':'NFC; LF newlines', + 'provider':'pumas_chat_compatible','server_url':job['config']['server_url'],'requested_model':job['config']['model'], + 'requested_seed':job['config']['seed'],'instruction':job['config']['instruction'],'system_prompt':job['system_prompt'], + 'prompt_version':job['prompt_version'],'prompt_sha256':job['prompt_sha256'],'reported_model':job['reported_model'], + 'canonical_request_sha256':job['canonical_request_sha256'],'requested_count':1,'max_tokens':6000, + 'job_id':job['id'],'candidate_id':candidate['id'],'response_sha256':job['response_sha256'], + 'source':{key:source[key] for key in ('id','revision','source_revision','content_hash','text','annotation')}, + 'evidence':candidate['evidence'],'verification':job['verification'],'admission_note':candidate['review_note']} + target=dict(target,annotation={'label':'first'}) + self.assertTrue(validate_record_origin(origin,target,snapshot['records'])) + for change in ({'source':dict(origin['source'],revision=900)}, {'admission_note':'An unrecorded disposition'}, + {'evidence':[{'start':0,'end':1,'quote':'X'}]}, {'provider':'unrecognized'}): + with self.assertRaises(WorkbenchError):validate_record_origin(dict(origin,**change),target,snapshot['records']) + with self.assertRaises(WorkbenchError): + validate_record_origin(origin,dict(target,review='human_reviewed'),snapshot['records']) + + def test_utf16_response_bytes_stay_exact_and_opaque_secret_bound_responses_fail(self): + job=self.grounded_proposal() + raw=job['raw_response'].encode('utf-16') + job.update(raw_response=None,raw_response_base64=base64.b64encode(raw).decode(),response_sha256=sha(raw)) + self.seed('grounded_jobs',job);snapshot=self.commit_producers() + doc=next(item['document'] for item in snapshot['records'] if item['family']=='grounded_history') + self.assertFalse(doc['job']['raw_response_text_available']) + declaration=next(item['document'] for item in snapshot['records'] if item['family']=='project_artifact' and item['id']==sha(raw)) + self.assertEqual(self.artifacts.read_blob({key:declaration[key] for key in ('sha256','bytes')}),raw) + self.session.db.execute('DELETE FROM grounded_jobs') + bad=self.proposal();badraw=b'{"choices": [incomplete unknown bytes' + bad.update(status='failed',annotation=None,raw_response_base64=base64.b64encode(badraw).decode(),response_sha256=sha(badraw)) + self.seed('text_classification_proposals',bad) + with self.assertRaises(WorkbenchError) as rejected:self.producers.capture_source() + self.assertEqual(rejected.exception.code,'unsupported') + self.assertEqual(self.checkpoint(),snapshot['checkpoint']) + badraw=encode({'api_key':'synthetic-do-not-export','error':'provider error'}).encode('utf-16') + bad.update(raw_response_base64=base64.b64encode(badraw).decode(),response_sha256=sha(badraw)) + self.session.db.execute('UPDATE text_classification_proposals SET data=%s WHERE id=%s',(encode(bad),bad['id'])) + with self.assertRaises(WorkbenchError):self.producers.capture_source() + + def test_public_readers_are_current_host_authorized_bounded_and_explicit_about_missing_times(self): + proposal=self.proposal();self.seed('text_classification_proposals',proposal) + job,entry=self.generation();snapshot=self.commit_producers() + origin=self.session.db.dataset_id + summary=self.producers.list('generation_history',origin_catalog_id=origin,limit=1) + self.assertEqual((summary['total'],summary['offset'],summary['limit']),(1,0,1)) + self.assertEqual(summary['items'][0]['entry_count'],1) + self.assertIsNone(summary['items'][0]['revision']);self.assertIsNone(summary['items'][0]['created_at']) + self.assertEqual(self.producers.list('generation_history',offset=1)['items'],[]) + details=self.producers.get('generation_history',job['id'],origin_catalog_id=origin,entries_limit=1) + self.assertEqual(details['entries']['items'][0]['entry'],entry) + self.assertIsNone(details['entries']['next_offset']) + detail=self.producers.get('text_proposal_history',proposal['id'],origin_catalog_id=origin) + self.assertNotIn('source',detail['job']) + self.assertEqual(detail['source_reference']['id'],self.text['id']) + self.assertEqual(detail['payloads']['raw_response']['availability']['state'],'unverified') + with self.assertRaises(WorkbenchError): + self.producers.get('generation_history',job['id'],origin_catalog_id=uuid.uuid4().hex) + with self.assertRaises(WorkbenchError):self.producers.list('generation_history',limit=101) + _,other_host,other_owner=self.new_session('reader-other-host') + with other_owner.bound(other_host): + with self.assertRaises(AccessError):self.producers.list('generation_history') + with self.assertRaises(AccessError):self.producers.get('generation_history',job['id'],origin_catalog_id=origin) + self.assertEqual(self.checkpoint(),snapshot['checkpoint']) + + def test_real_postgres_cold_inert_history_keeps_exact_bytes_without_live_queues(self): + original=self.proposal(); self.seed('text_classification_proposals',original) + typed=self.proposal('image',typed=True); self.seed('caption_proposals',typed) + generation,entry=self.generation() + snapshot=self.commit_producers() + decoded=decode_records(snapshot['records'],artifacts=self.artifacts) + raw=encode(decoded) + self.assertNotIn(original['admission_id'],raw); self.assertNotIn(original['request_digest'],raw) + self.assertNotIn('raw_response_base64',raw); self.assertNotIn('input_image_base64',raw) + self.assertNotIn('server_url',encode(next(r for r in decoded if r['family']=='text_proposal_history')['document']['job']['config'])) + caption_doc=next(r['document'] for r in decoded if r['family']=='caption_proposal_history') + self.assertEqual(caption_doc['job']['build_observation']['observed_sha256'],typed['build_observation']['observed_sha256']) + self.assertEqual(caption_doc['observation_integrity']['build_observation'],'unverified_missing_original_wire') + dsn=os.environ.get('TULDOK_PROJECT_FILES_COLD_DATABASE_URL',os.environ['TULDOK_DATABASE_URL']) + target,host,owner=self.new_session('cold',dsn) + with patch('synthetic.Jobs',side_effect=AssertionError('No producer constructor')), \ + patch('grounded_candidates.Proposals',side_effect=AssertionError('No grounded constructor')), \ + patch('caption_proposals.CaptionProposals',side_effect=AssertionError('No caption constructor')), \ + patch('text_classification_proposals.TextClassificationProposals',side_effect=AssertionError('No classifier constructor')), \ + patch('http.client.HTTPConnection.connect',side_effect=AssertionError('Historical endpoint must not be followed')), \ + patch('threading.Thread.start',side_effect=AssertionError('Open must not start workers')): + restored=ProjectProducers(target).restore(snapshot) + with owner.bound(host): + seen=restored.generation_jobs.snapshot() + self.assertEqual(seen['entries'],[next(r['document']['entries'][0]['entry'] for r in decoded if r['family']=='generation_history')]) + for table in ('generation_jobs','grounded_jobs','caption_proposals','text_classification_proposals'): + self.assertEqual(target.db.execute('SELECT count(*) FROM '+table).fetchone()[0],0) + composed={(item['family'],item['id']):item for item in snapshot['records']} + composed.update({(item['family'],item['id']):item for item in restored.capture()}) + self.assertEqual(decode_records(list(composed.values())),decoded) + declaration=next(r['document'] for r in snapshot['records'] if r['family']=='project_artifact' and r['id']==original['response_sha256']) + self.assertEqual(self.artifacts.read_blob({k:declaration[k] for k in ('sha256','bytes')}),base64.b64decode(original['raw_response_base64'])) + self.assertFalse(hasattr(restored.generation_jobs,'resume')) + + def test_actual_source_delete_canonically_keeps_original_producer_association(self): + job,entry=self.generation(); snapshot=self.commit_producers() + target,host,owner=self.new_session('delete') + restored=ProjectProducers(target).restore(snapshot) + data=ProjectData(target,generation_entries=restored.generation_jobs) + data.restore(snapshot) + with owner.bound(host): + data.delete_image_source(self.image['id'],{'revision':1},target.snapshot['checkpoint'],uuid.uuid4().hex) + doc=next(r['document'] for r in target.snapshot['records'] if r['family']=='generation_history') + self.assertEqual(doc['entries'][0]['entry']['status'],'deleted') + self.assertIsNone(doc['entries'][0]['entry']['sample_id']) + self.assertEqual(doc['entries'][0]['history'][0]['sample_id'],self.image['id']) + self.assertEqual(doc['entries'][0]['history'][1]['status'],'deleted') + self.assertEqual(doc['job']['completed'],1) + decode_records(target.snapshot['records'],artifacts=self.artifacts) + + def test_unknown_or_secret_source_fields_fail_before_payload_publication(self): + job=self.proposal();job['future_scientific_measurement']={'value':17};self.seed('text_classification_proposals',job) + before={p.relative_to(self.path/'project').as_posix():p.read_bytes() for p in (self.path/'project').rglob('*') if p.is_file()} + with self.assertRaises(WorkbenchError):self.producers.capture_source() + after={p.relative_to(self.path/'project').as_posix():p.read_bytes() for p in (self.path/'project').rglob('*') if p.is_file()} + self.assertEqual(before,after) + job.pop('future_scientific_measurement');job['config']['api_key']='synthetic-do-not-export' + self.session.db.execute('UPDATE text_classification_proposals SET data=%s WHERE id=%s',(encode(job),job['id'])) + with self.assertRaises(WorkbenchError):self.producers.capture_source() + self.assertNotIn('synthetic-do-not-export',encode(self.store.snapshot())) + + def test_missing_evidence_allows_inert_metadata_but_corruption_refuses_restore(self): + job=self.proposal();self.seed('text_classification_proposals',job);snapshot=self.commit_producers() + payload=self.path/'project/artifacts/sha256'/job['response_sha256'] + # Artifact storage owns the exact layout; use its declared payload root. + candidates=[p for p in (self.path/'project/artifacts').rglob(job['response_sha256']) if p.is_file()] + self.assertEqual(len(candidates),1);payload=candidates[0] + original=payload.read_bytes();payload.unlink() + self.assertTrue(decode_records(snapshot['records'],artifacts=self.artifacts)) + payload.write_bytes(b'changed response bytes') + target,host,owner=self.new_session('corrupt') + with self.assertRaises(WorkbenchError):ProjectProducers(target).restore(snapshot) + self.assertEqual(target.db.execute('SELECT count(*) FROM generation_entries').fetchone()[0],0) + payload.write_bytes(original) + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_project_projection.py b/tests/test_project_projection.py new file mode 100644 index 00000000..d08aaadc --- /dev/null +++ b/tests/test_project_projection.py @@ -0,0 +1,164 @@ +"""Complete inert projection boundaries over disposable real PostgreSQL.""" +import copy +import hashlib +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +import uuid + +import psycopg +from psycopg import sql + +from auth_helpers import ExplicitOwnerFixture +from metadata import connection_info +from project_session import OPERATION_FORMAT, operation_documents +from project_workspace import ProjectWorkspace, http_arguments +from workbench import WorkbenchError + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'), 'Requires disposable PostgreSQL') +class ProjectProjectionTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='tuldok-project-projection-') + self.root = Path(self.tmp.name) + self.external = self.root / 'external'; self.external.mkdir() + self.hosts = [] + self.addCleanup(self.tmp.cleanup) + self.addCleanup(self.cleanup) + self.start() + self.call('new', dict(binding=self.state()['binding'], expected_checkpoint=None, + request_key='create-mixed', root='fixture', directory='project', name='Inert mixed project')) + + def start(self, *, cold=False): + control = self.root / ('control-' + uuid.uuid4().hex) + dsn = os.environ.get('TULDOK_PROJECT_FILES_COLD_DATABASE_URL') if cold else os.environ['TULDOK_DATABASE_URL'] + if cold and not dsn: + self.fail('Distinct-role cold database is a required fixture for this selection.') + host = ProjectWorkspace(control, {'fixture': self.external}, dsn=dsn) + owner = ExplicitOwnerFixture.bootstrap(host) + self.hosts.append((host, owner, control, dsn)) + self.host, self.owner = host, owner + + def cleanup(self): + owned = [] + for host, _, control, dsn in self.hosts: + host.close() + for manifest in control.rglob('.tuldok.json'): + value = json.loads(manifest.read_bytes()) + owned.append((dsn, 'projection_' + value['projection_id'] if value['version'] == 2 + else 'catalog_' + value['dataset_id'])) + for dsn, schema in owned: + with psycopg.connect(dsn, autocommit=True) as connection: + connection.execute(sql.SQL('DROP SCHEMA IF EXISTS {} CASCADE').format(sql.Identifier(schema))) + + def call(self, action, body=None): + with self.owner.bound(self.host): + return self.host.dispatch('project_files.' + action, body or {}) + + def state(self): + return self.call('status') + + def mutation(self, **fields): + state = self.state() + return dict(binding=state['binding'], expected_checkpoint=state['project']['checkpoint'], + request_key=uuid.uuid4().hex, **fields) + + def test_mixed_cold_projection_preserves_notes_and_definitions_without_worker_constructors(self): + text = self.call('import_text', self.mutation(asset=dict(text='Unique observation 山', groups=['owned'])))['record'] + with self.owner.bound(self.host): + p = self.host._projection + body = dict(name='Neutral metadata', task=dict(kind='task', catalog_id=p.project_id, + id='neutral', revision=1, sha256='a' * 64), kind='template', + specification={'scientific_decision': 'retained'}, source_selector=None, + preprocessing_policy={'kind': 'identity'}, output_policy={'kind': 'bytes'}) + model = p.materials.mutate_definition('model_definition', 'models.create', None, body, + p.session.snapshot['checkpoint'], 'model-create', lambda: p.models.create(body)) + snapshot = self.host._store.snapshot() + canonical = (self.external / 'project' / '.tuldok' / 'project.json').read_bytes() + sentinel = self.external / 'project' / 'must-not-run' + (self.external / 'project' / 'project_script.py').write_text( + 'from pathlib import Path\nPath(' + repr(str(sentinel)) + ').write_text("EXECUTED")\n') + self.call('close', self.mutation()) + self.start(cold=True) + # These assertions observe actual reconstruction; they do not replace + # the database or filesystem with mocked implementations. + with patch('runs.Runs.__init__', side_effect=AssertionError('live Runs on Open')), \ + patch('app.Dataset.__init__', side_effect=AssertionError('Dataset on Open')), \ + patch('subprocess.Popen', side_effect=AssertionError('process on Open')): + state = self.state() + self.call('open', dict(binding=state['binding'], expected_checkpoint=None, + root='fixture', directory='project', request_key='cold-open')) + self.assertFalse(sentinel.exists()) + self.assertEqual(self.host._store.snapshot(), snapshot) + self.assertEqual((self.external/'project'/'.tuldok'/'project.json').read_bytes(), canonical) + with self.owner.bound(self.host): + p = self.host._projection + self.assertEqual(p.data.get(text['id'])['text'], text['text']) + self.assertEqual(p.models.get(model['id']), model) + self.assertEqual(p.db.execute('SELECT COUNT(*) FROM workspace_projects').fetchone()[0], 0) + self.assertEqual(p.db.execute('SELECT COUNT(*) FROM workspace_runs').fetchone()[0], 0) + self.assertEqual(p.db.execute('SELECT COUNT(*) FROM access_credentials').fetchone()[0], 0) + + def test_unknown_receipt_is_rejected_before_invoke_and_sql_or_checkpoint_changes(self): + p = self.host._projection + before = p.store.snapshot() + key = 'forged-unknown' + project_id = before['project_id'] + record = dict(family='project_operation', id=project_id + '.' + hashlib.sha256(key.encode()).hexdigest(), document=dict( + format=OPERATION_FORMAT, project_id=project_id, operation='unowned.operation', target=None, request_key=key, + input_sha256='b'*64, result={'token': 'must-not-import'})) + self.assertEqual(operation_documents([record]), [record['document']]) + with self.owner.bound(self.host), \ + patch.object(p.session, 'mutate', side_effect=AssertionError('Unknown receipt invoked a mutation')) as mutate, \ + self.assertRaises(WorkbenchError) as caught: + p.validate_records([*before['records'], record]) + mutate.assert_not_called() + self.assertEqual(caught.exception.code, 'unsupported') + self.assertEqual(p.store.snapshot(), before) + self.assertEqual(p.db.execute('SELECT COUNT(*) FROM workbench_records').fetchone()[0], 0) + + def test_explicit_rooted_artifact_supply_is_durable_and_never_fetches_or_executes(self): + p = self.host._projection + raw = b'Unique measured bytes; never regenerate these.\n' + digest = hashlib.sha256(raw).hexdigest() + declaration = dict(format='project_artifact_v1', sha256=digest, bytes=len(raw)) + before = p.session.snapshot + snapshot = p.store.commit([dict(family='project_artifact', id=digest, document=declaration)], + before['checkpoint'], 'declare-fixture', 'c'*64) + p.session.adopt(snapshot) + source = self.external / 'payload.bin'; source.write_bytes(raw) + body = dict(sha256=digest, root='fixture', path='payload.bin') + with self.owner.bound(self.host), patch('subprocess.Popen', side_effect=AssertionError('execution')): + result = p.resolve_artifact(body, snapshot['checkpoint'], 'supply-payload') + self.assertEqual(p.artifacts.status(declaration)['state'], 'verified') + acknowledged = p.session.snapshot + source.unlink() + self.assertEqual(p.resolve_artifact(body, snapshot['checkpoint'], 'supply-payload'), result) + self.assertEqual(p.session.snapshot, acknowledged) + self.assertEqual(p.artifacts.read_blob(dict(sha256=digest, bytes=len(raw))), raw) + source.symlink_to('/dev/null') + with self.assertRaises((WorkbenchError, OSError)): + p.resolve_artifact(body, acknowledged['checkpoint'], 'supply-symlink') + self.assertEqual(p.session.snapshot, acknowledged) + self.call('close', self.mutation()) + state = self.state() + self.call('open', dict(binding=state['binding'], expected_checkpoint=None, root='fixture', + directory='project', request_key='reopen-supplied')) + self.assertEqual(self.host._projection.artifacts.status(declaration)['state'], 'verified') + + def test_finite_http_routes_keep_literal_import_and_reject_duplicate_locations(self): + import agent_api + arguments = self.mutation(text='Owned route body', groups=['owned']) + method, target, body = agent_api.project_request('project_files.data_import_text', arguments) + name, actual = http_arguments(method, target, body) + self.assertEqual(name, 'project_files.data_import_text') + self.assertEqual(actual, arguments) + state = self.state() + arguments = dict(binding=state['binding'], checkpoint=state['project']['checkpoint'], record_id='c'*32) + method, target, body = agent_api.project_request('project_files.data_get', arguments) + self.assertEqual(http_arguments(method, target, body), ('project_files.data_get', arguments)) + with self.assertRaises(agent_api.AgentAPIError): + http_arguments('GET', target + '&record_id=' + 'c'*32) diff --git a/tests/test_project_runtime.py b/tests/test_project_runtime.py new file mode 100644 index 00000000..75938e71 --- /dev/null +++ b/tests/test_project_runtime.py @@ -0,0 +1,264 @@ +"""Actual offline ELF inventory/copy and canonical two-phase runtime custody. + +The selected python binary is /usr/bin/true: representative bytes, never a +runnable Python/model environment. No package install, probe or child is run. +""" +import copy +import errno +import os +from pathlib import Path +import shutil +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from project_custody import ProjectCustody +from project_execution import ProjectExecution +from project_material import decode_records, validate_operation_result as material_result +from project_runtime import ProjectRuntime, validate_operation_result, _intent_key +from project_session import operation_documents, operation_records +from workbench import WorkbenchError +import test_project_material as material_fixture + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'),'Requires disposable PostgreSQL') +class RuntimeCaptureTests(unittest.TestCase): + def setUp(self): + f=self.fixture=material_fixture.CanonicalPublication('test_model_publication_is_canonical_before_ack_and_has_one_session_receipt') + f.setUp();self.addCleanup(f.doCleanups) + self.control=ProjectCustody(f.host.path,f.host.dataset_id) + self.journal=self.control.for_projection(f.store,f.db.projection_id) + projection=SimpleNamespace(session=f.session,db=f.db,access=f.access,materials=f.material,custody=self.journal) + self.execution=projection.execution=ProjectExecution(projection) + f.session._validate=self.validate + self.runtime=ProjectRuntime(projection,resolve_configuration=self.resolve) + python=f.path/'explicit-python';site=f.path/'explicit-packages' + (python/'bin').mkdir(parents=True);(python/'lib/python3.12/lib-dynload').mkdir(parents=True) + shutil.copyfile('/usr/bin/true',python/'bin/python3.12') + (python/'lib/python3.12/lib-dynload/fixture.txt').write_text('fixture only') + entries=[] + for name in ('torch','numpy'): + (site/name).mkdir(parents=True) + (site/name/'__init__.py').write_text('raise AssertionError("Selected package must never be imported")\n') + info=site/(name+'-0.0.dist-info');info.mkdir() + (info/'METADATA').write_text('Name: '+name+'\nVersion: 0.0\n') + (info/'LICENSE').write_text('Owned fixture') + entries.extend([name,info.name]) + self.config=dict(schema_version=1,python_root=str(python),python_version='3.12',site_packages=str(site),packages=entries) + self.request=dict(profile='owned-fixture');self.key='capture:fixture' + self.cp=f.session.snapshot['checkpoint'] + + def resolve(self,request): + if request!=self.request:raise WorkbenchError('Choose a configured runtime source.','unavailable',409) + return copy.deepcopy(self.config) + + def validate(self,records): + decode_records(records) + for receipt in operation_documents(records): + if not (validate_operation_result(receipt['operation'],receipt['result'],records) + or material_result(receipt['operation'],receipt['result'],records)): + raise WorkbenchError('Unowned fixture receipt.') + + def observed(self): + with self.fixture.access.bind(self.fixture.owner.token):return self.runtime.inspect(self.request) + + def capture(self,observed=None,**kwargs): + observed=observed or self.observed() + with self.fixture.access.bind(self.fixture.owner.token): + return self.runtime.capture(self.request,observed['inventory_sha256'],self.cp,self.key,reserve_bytes=0,**kwargs) + + def test_actual_capture_retains_intent_manifest_and_all_declarations_without_running_binaries(self): + f=self.fixture + with patch('subprocess.Popen',side_effect=AssertionError('No execution, probe or install')): + observed=self.observed();result=self.capture(observed) + records=f.session.snapshot['records'];receipts=operation_records(records) + self.assertEqual(set(receipts),{_intent_key(self.key),self.key}) + self.assertEqual(receipts[_intent_key(self.key)]['operation'],'runtime.capture_intent') + self.assertEqual(result['format'],'project_runtime_capture_v1') + self.assertTrue(validate_operation_result('runtime.capture',result,records)) + self.assertEqual(result['actor_id'],f.owner.identity['actor_id']) + material=next(r['document'] for r in records if r['family']=='runtime_material') + declarations={r['id']:r['document'] for r in records if r['family']=='project_artifact'} + self.assertEqual(len(material['artifacts']),observed['file_count']+1) + self.assertEqual({e['preservation'] for e in material['artifacts'] if e['role']=='runtime_file'},{'regenerable'}) + for edge in material['artifacts']: + self.assertEqual(declarations[edge['blob']['sha256']],dict(format='project_artifact_v1',**edge['blob'])) + raw=(Path(material['value']['descriptor']['root'])/'manifest.json').read_bytes() + self.assertEqual(f.artifacts.read_blob(dict(sha256=result['manifest_sha256'],bytes=len(raw))),raw) + unique=[edge for edge in material['artifacts'] if edge['preservation']=='unique'] + self.assertEqual([edge['name'] for edge in unique],['manifest.json']) + file_edge=next(edge for edge in material['artifacts'] if edge['role']=='runtime_file') + self.assertEqual(f.artifacts.inspect(dict(format='project_artifact_v1',**file_edge['blob']))['state'],'missing') + self.assertFalse(self.execution.has_local_custody) + self.assertFalse(self.journal.has_pending) + self.assertEqual(self.control.pending(f.store),[]) + self.assertEqual(f.store.snapshot(),f.session.snapshot) + + def test_completed_replay_verifies_existing_target_without_source_reads_or_duplicate_owner_create(self): + observed=self.observed();result=self.capture(observed) + shutil.rmtree(self.config['python_root']);shutil.rmtree(self.config['site_packages']) + before=self.fixture.store.snapshot() + with (patch.object(self.runtime,'resolve_configuration',side_effect=AssertionError('No source replay')), + patch.object(self.runtime.owner,'create',side_effect=AssertionError('No duplicate publication'))): + replay=self.capture(observed) + self.assertEqual(replay,result);self.assertEqual(self.fixture.store.snapshot(),before) + self.assertFalse(self.journal.has_pending) + with self.fixture.access.bind(self.fixture.owner.token): + status=self.runtime.status(self.key) + self.assertEqual(status['state'],'captured');self.assertEqual(status['result'],result) + + def test_copy_failure_keeps_durable_intent_and_pending_guard_until_explicit_same_key_retry(self): + observed=self.observed();original=self.runtime.owner.create + def stopped(*args,**kwargs): + receipts=operation_records(self.fixture.store.snapshot()['records']) + self.assertIn(_intent_key(self.key),receipts) + self.assertEqual(self.journal.snapshot()['runtime_effects'][0]['request_key'],self.key) + self.assertFalse(self.fixture.db.in_transaction) + raise WorkbenchError('Injected copy failure.','stale',409) + with patch.object(self.runtime.owner,'create',side_effect=stopped): + with self.assertRaises(WorkbenchError) as caught:self.capture(observed) + self.assertTrue(caught.exception.receipt['committed']) + self.assertEqual(caught.exception.receipt['checkpoint'],self.fixture.store.snapshot()['checkpoint']) + self.assertTrue(self.journal.has_pending) + with self.assertRaises(WorkbenchError):self.execution.assert_quiescent() + with self.fixture.access.bind(self.fixture.owner.token):self.assertEqual(self.runtime.status(self.key)['state'],'intent_recorded') + completed=self.capture(observed) + self.assertEqual(completed['manifest_sha256'],observed['inventory_sha256']) + self.assertFalse(self.journal.has_pending);self.execution.assert_quiescent() + + def test_parent_fsync_unknown_does_not_ack_until_existing_target_verified_and_barrier_passes(self): + import runtime_snapshots + observed=self.observed();sync=runtime_snapshots.sync_directory + def fault(directory): + if Path(directory)==self.runtime.owner.path:raise OSError(errno.EIO,'Injected parent fsync failure') + return sync(directory) + with patch('runtime_snapshots.sync_directory',side_effect=fault): + with self.assertRaises(WorkbenchError) as caught:self.capture(observed) + self.assertEqual(caught.exception.code,'outcome_unknown') + self.assertTrue((self.runtime.owner.path/observed['inventory_sha256']).exists()) + self.assertNotIn(self.key,operation_records(self.fixture.store.snapshot()['records'])) + with patch.object(self.runtime.owner,'create',side_effect=AssertionError('Never copy again')): + with self.assertRaises(WorkbenchError):self.capture(observed) + self.assertTrue(self.journal.has_pending) + shutil.rmtree(self.config['python_root']);shutil.rmtree(self.config['site_packages']) + with patch.object(self.runtime.owner,'create',side_effect=AssertionError('Verify published target')): + result=self.capture(observed) + self.assertEqual(result['manifest_sha256'],observed['inventory_sha256']) + self.assertFalse(self.journal.has_pending) + + def test_intent_sql_commit_failure_never_invokes_filesystem_owner(self): + observed=self.observed();db=self.fixture.db + db.execute('CREATE TABLE deferred_runtime_parent(id INTEGER PRIMARY KEY)') + db.execute('CREATE TABLE deferred_runtime_child(id INTEGER REFERENCES deferred_runtime_parent(id) DEFERRABLE INITIALLY DEFERRED)') + mutate=self.fixture.session.mutate + def fault(operation,target,body,checkpoint,key,invoke,capture,**kwargs): + def invoke_bad(): + value=invoke();db.execute('INSERT INTO deferred_runtime_child VALUES(73)');return value + return mutate(operation,target,body,checkpoint,key,invoke_bad,capture,**kwargs) + with patch.object(self.fixture.session,'mutate',side_effect=fault),patch.object(self.runtime.owner,'create',side_effect=AssertionError('No copy before intent ACK')): + with self.assertRaises(Exception):self.capture(observed) + self.assertFalse((self.runtime.owner.path/observed['inventory_sha256']).exists()) + self.assertFalse(self.journal.has_pending) + self.assertIn(_intent_key(self.key),operation_records(self.fixture.store.snapshot()['records'])) + + def test_changed_key_body_tampered_cache_and_active_work_are_refused_without_effects(self): + observed=self.observed();result=self.capture(observed) + before=self.fixture.store.snapshot() + with self.fixture.access.bind(self.fixture.owner.token): + with patch.object(self.runtime.owner,'create',side_effect=AssertionError('No changed-key effects')): + with self.assertRaises(WorkbenchError) as caught: + self.runtime.capture(dict(profile='other'),observed['inventory_sha256'],self.cp,self.key,reserve_bytes=0) + self.assertEqual(caught.exception.code,'conflict') + self.assertEqual(self.fixture.store.snapshot(),before) + target=self.runtime.owner.path/observed['inventory_sha256']/'packages/torch/__init__.py' + target.chmod(0o600);target.write_bytes(b'changed cache');target.chmod(0o400) + with patch.object(self.runtime.owner,'create',side_effect=AssertionError('Never overwrite corrupt target')): + with self.assertRaises(WorkbenchError):self.capture(observed) + self.assertEqual(self.fixture.store.snapshot(),before) + self.execution._active_attempts.add('a'*32) + with self.assertRaises(WorkbenchError):self.observed() + self.execution._active_attempts.clear() + + def test_current_owner_authority_precedes_inspection_and_receipt_replay(self): + from access import AccessError + observed=self.observed();self.capture(observed) + with patch.object(self.runtime.owner,'get',side_effect=AssertionError('No anonymous cache reads')): + with self.assertRaises(AccessError):self.runtime.capture(self.request,observed['inventory_sha256'],self.cp,self.key,reserve_bytes=0) + with self.assertRaises(AccessError):self.runtime.inspect(self.request) + + def test_final_sql_failure_retains_canonical_manifest_and_reconciles_without_recapture(self): + observed=self.observed();f=self.fixture;db=f.db + db.execute('CREATE TABLE final_runtime_parent(id INTEGER PRIMARY KEY)') + db.execute('CREATE TABLE final_runtime_child(id INTEGER REFERENCES final_runtime_parent(id) DEFERRABLE INITIALLY DEFERRED)') + mutate=f.session.mutate + def fault(operation,target,body,checkpoint,key,invoke,capture,**kwargs): + if operation!='runtime.capture':return mutate(operation,target,body,checkpoint,key,invoke,capture,**kwargs) + def invoke_bad(): + result=invoke();db.execute('INSERT INTO final_runtime_child VALUES(84)');return result + return mutate(operation,target,body,checkpoint,key,invoke_bad,capture,**kwargs) + with patch.object(f.session,'mutate',side_effect=fault): + with self.assertRaises(Exception) as caught:self.capture(observed) + self.assertTrue(caught.exception.receipt['committed']) + self.assertFalse(f.session.available);self.assertTrue(self.journal.has_pending) + snapshot=f.store.snapshot();self.journal.validate_selection(snapshot) + result=operation_records(snapshot['records'])[self.key]['result'] + doc=next(r['document'] for r in snapshot['records'] if r['family']=='runtime_material') + manifest_edge=next(edge for edge in doc['artifacts'] if edge['role']=='runtime_manifest') + self.assertEqual(f.artifacts.status(dict(format='project_artifact_v1',**manifest_edge['blob']))['state'],'verified') + # Explicit current-outcome reconstruction, not an automatic mutation retry. + with f.access.lock,f.session.lock,db.lock:f.session.reconcile_selection(snapshot) + with patch.object(self.runtime.owner,'create',side_effect=AssertionError('Never recapture committed bytes')): + replay=self.capture(observed) + self.assertEqual(replay,result);self.assertFalse(self.journal.has_pending) + self.assertTrue(f.session.available) + + def test_receipt_requires_manifest_declarations_and_exact_durable_intent(self): + result=self.capture(self.observed());records=self.fixture.session.snapshot['records'] + variants=[] + variants.append([r for r in records if r['family']!='project_operation' or r['document']['operation']!='runtime.capture_intent']) + variants.append([r for r in records if r['family']!='project_artifact' or r['id']!=result['manifest_sha256']]) + changed=copy.deepcopy(result);changed['total_bytes']+=1 + with self.assertRaises(WorkbenchError):validate_operation_result('runtime.capture',changed,records) + for candidate in variants: + with self.assertRaises(WorkbenchError):validate_operation_result('runtime.capture',result,candidate) + + def test_sigkill_after_native_publication_retains_exact_private_intent_without_final_ack(self): + import json,subprocess,sys + from project_store import ProjectStore + report=self.fixture.path/'child-capture-report.json' + script=r''' +import json,os,signal,sys +from pathlib import Path +root=Path(sys.argv[2]) +sys.path[:0]=[str(root),str(root/'tests')] +from test_project_runtime import RuntimeCaptureTests +case=RuntimeCaptureTests('test_actual_capture_retains_intent_manifest_and_all_declarations_without_running_binaries') +case.setUp();f=case.fixture;observed=case.observed();create=case.runtime.owner.create +def stop(*args,**kwargs): + value=create(*args,**kwargs) + facts=dict(path=str(f.path),project=str(f.store.path),host_path=str(f.host.path),host_id=f.host.dataset_id, + projection_id=f.db.projection_id,schemas=[f.host.schema,f.db.schema],manifest_sha256=value['manifest_sha256'], + runtime_root=value['root'],checkpoint=f.store.snapshot()['checkpoint'],request_key=case.key) + with open(sys.argv[1],'w') as handle: + json.dump(facts,handle);handle.flush();os.fsync(handle.fileno()) + os.kill(os.getpid(),signal.SIGKILL) +case.runtime.owner.create=stop +case.capture(observed) +''' + root=Path(__file__).resolve().parents[1] + completed=subprocess.run([sys.executable,'-c',script,str(report),str(root)],cwd=root,timeout=20,capture_output=True,text=True) + self.assertEqual(completed.returncode,-9,completed.stderr) + facts=json.loads(report.read_text());self.fixture.schemas.extend(facts['schemas']) + self.addCleanup(shutil.rmtree,facts['path'],True) + with ProjectStore.open(facts['project']) as store: + snapshot=store.snapshot();self.assertEqual(snapshot['checkpoint'],facts['checkpoint']) + receipts=operation_records(snapshot['records']) + self.assertIn(_intent_key(facts['request_key']),receipts);self.assertNotIn(facts['request_key'],receipts) + private=ProjectCustody(facts['host_path'],facts['host_id']) + self.assertEqual([r['projection_id'] for r in private.pending(store)],[facts['projection_id']]) + journal=private.for_projection(store,facts['projection_id']);journal.validate_selection(snapshot) + self.assertEqual(journal.snapshot()['runtime_effects'][0]['manifest_sha256'],facts['manifest_sha256']) + self.assertFalse(any(r['family']=='runtime_material' for r in snapshot['records'])) + raw=(Path(facts['runtime_root'])/'manifest.json').read_bytes() + import hashlib + self.assertEqual(hashlib.sha256(raw).hexdigest(),facts['manifest_sha256']) diff --git a/tests/test_project_runtime_clients.py b/tests/test_project_runtime_clients.py new file mode 100644 index 00000000..e81b06d0 --- /dev/null +++ b/tests/test_project_runtime_clients.py @@ -0,0 +1,119 @@ +"""Finite runtime transport checks; inventory/copy custody is owned elsewhere.""" +import copy +import unittest +from types import SimpleNamespace +from unittest.mock import Mock + +import agent_api as api +import project_cli +import project_dispatch +from tuldok_client import ProjectFilesClient +from test_project_files_m4_clients import ready,bound,READ,WRITE,PROJECT,ACTOR,CP,NEXT,KEY,STAMP + +ACTIONS=('runtime_sources','runtime_inspect','runtime_capture','runtime_status','runtime_abandon') + + +def inspection(): + return dict(format='project_runtime_inspection_v1',source='configured-cpu',inventory_sha256=CP, + profile='captured_cpu_python_v1',python_version='3.12',file_count=1,total_bytes=1,manifest_bytes=400,free_bytes=1000, + packages=[{'name':'numpy','version':'0.0'},{'name':'torch','version':'0.0'}], + launch=dict(python='python/bin/python3.12',loader='system/lib/ld.so',home='python',stdlib='python/lib/python3.12', + dynload='python/lib/python3.12/lib-dynload',packages='packages',library_dirs=['system/lib']), + files=dict(items=[dict(path='python/bin/python3.12',bytes=1,sha256=CP,mode=0o500,kind='elf')],offset=0,limit=40,total=1)) + + +def summary(complete=False): + value=dict(format='project_runtime_capture_v1' if complete else 'project_runtime_capture_intent_v1', + project_id=PROJECT,manifest_sha256=CP,file_count=1,total_bytes=1,actor_id=ACTOR,observed_at=STAMP,request_key=KEY) + if complete:value['runtime_ref']=dict(kind='runtime',catalog_id=PROJECT,id=NEXT,sha256=NEXT) + return value + + +def client(value): + result=ready(dict(status=200,result=bound(value)),owner=True) + result._selected={**result._selected,**{'project_files.'+action:1 for action in ACTIONS}} + return result + + +class RuntimeClients(unittest.TestCase): + def test_native_bounds_fixed_consumers_and_alias_only_current_configuration(self): + import runtime_snapshots as native + import recipe_process + self.assertEqual((native.MAX_FILES,native.MAX_FILE_BYTES,native.MAX_BYTES,native.MAX_MANIFEST), + (40000,512*1024*1024,2*1024*1024*1024,16*1024*1024)) + op=api.operation('project_files.runtime_inspect')['outputSchema']['oneOf'][0]['properties']['result']['properties']['value']['properties'] + self.assertEqual(op['file_count']['maximum'],native.MAX_FILES) + self.assertEqual(op['total_bytes']['maximum'],native.MAX_BYTES) + self.assertEqual(op['manifest_bytes']['maximum'],native.MAX_MANIFEST) + self.assertEqual(op['files']['properties']['items']['items']['properties']['bytes']['maximum'],native.MAX_FILE_BYTES) + self.assertEqual(op['files']['properties']['items']['items']['properties']['path']['maxLength'],1024) + self.assertTrue(recipe_process._cpu_relative('x'*1024));self.assertFalse(recipe_process._cpu_relative('x'*1025)) + for action in ACTIONS: + self.assertIn('project_files.'+action,api.PROJECT_FILES_M3) + self.assertTrue(callable(getattr(ProjectFilesClient(Mock()),action))) + project_cli.parser().parse_args(['--config','/not-read','file-'+action.replace('_','-'),'--arguments','/not-read']) + for change in ({'source':'/absolute/path'},{'source':'x'*65},{'configuration':{'python_root':'/tmp'}}): + with self.assertRaises(api.AgentAPIError): + api.validate_arguments('project_files.runtime_capture',{**WRITE,'source':'configured-cpu','expected_inventory_sha256':CP,**change}) + self.assertEqual(api.contract('project_files.runtime_capture')['authority'],'owner') + self.assertEqual(api.contract('project_files.runtime_status')['authority'],'agent') + + def test_inventory_nested_page_and_capture_exact_receipt_are_correlated(self): + value=inspection();raw=client(value);sdk=ProjectFilesClient(raw) + sdk.runtime_inspect(**READ,source='configured-cpu') + for change in ({'source':'another'},{'file_count':2}): + raw._request.return_value=dict(status=200,result=bound(dict(value,**change))) + with self.assertRaises(api.AgentAPIError):sdk.runtime_inspect(**READ,source='configured-cpu') + corrupt=copy.deepcopy(value);corrupt['files']['offset']=1 + raw._request.return_value=dict(status=200,result=bound(corrupt)) + with self.assertRaises(api.AgentAPIError):sdk.runtime_inspect(**READ,source='configured-cpu') + raw._request.return_value=dict(status=200,result=bound(summary(True),NEXT)) + sdk.runtime_capture(**WRITE,source='configured-cpu',expected_inventory_sha256=CP,reserve_bytes=0) + for change in ({'request_key':'another-key'},{'manifest_sha256':NEXT},{'project_id':ACTOR}): + raw._request.return_value=dict(status=200,result=bound(dict(summary(True),**change),NEXT)) + with self.assertRaises(api.AgentAPIError):sdk.runtime_capture(**WRITE,source='configured-cpu',expected_inventory_sha256=CP) + + def test_retained_status_never_conflates_capture_with_current_readiness(self): + value=dict(state='captured',intent=summary(),result=summary(True));raw=client(value);sdk=ProjectFilesClient(raw) + self.assertEqual(sdk.runtime_status(**READ,capture_key=KEY)['value'],value) + for change in ({'state':'absent'},{'intent':None},{'result':dict(summary(True),request_key='other-key')}, + {'result':dict(summary(True),file_count=2)},{'local_ready':True}): + raw._request.return_value=dict(status=200,result=bound(dict(value,**change))) + with self.assertRaises(api.AgentAPIError):sdk.runtime_status(**READ,capture_key=KEY) + + def test_abandonment_is_exact_current_intent_and_status_keeps_positive_shape(self): + abandoned=dict(format='project_runtime_capture_abandon_v1',project_id=PROJECT,capture_key=KEY, + input_sha256=NEXT,manifest_sha256=CP,actor_id=ACTOR,reason='Source disappeared; preserve partial science', + observed_checkpoint=CP,observed_at=STAMP) + raw=client(abandoned);sdk=ProjectFilesClient(raw) + sdk.runtime_abandon(**dict(WRITE,request_key='new-abandon-key'),capture_key=KEY,reason=abandoned['reason']) + for change in ({'capture_key':'other-key'},{'reason':'different'},{'project_id':ACTOR}): + raw._request.return_value=dict(status=200,result=bound(dict(abandoned,**change))) + with self.assertRaises(api.AgentAPIError):sdk.runtime_abandon(**dict(WRITE,request_key='new-abandon-key'),capture_key=KEY,reason=abandoned['reason']) + value=dict(state='abandoned',intent=summary(),result=None,abandonment=abandoned) + raw._request.return_value=dict(status=200,result=bound(value));sdk.runtime_status(**READ,capture_key=KEY) + for change in ({'intent':None},{'result':summary(True)},{'abandonment':dict(abandoned,manifest_sha256=NEXT)}): + raw._request.return_value=dict(status=200,result=bound(dict(value,**change))) + with self.assertRaises(api.AgentAPIError):sdk.runtime_status(**READ,capture_key=KEY) + for positive in (dict(state='absent',intent=None,result=None),dict(state='intent_recorded',intent=summary(),result=None),dict(state='captured',intent=summary(),result=summary(True))): + raw._request.return_value=dict(status=200,result=bound(positive));sdk.runtime_status(**READ,capture_key=KEY) + owner=Mock();projection=SimpleNamespace(session=SimpleNamespace(access=SimpleNamespace(require=Mock())),runtime=owner) + project_dispatch.dispatch(projection,'project_files.runtime_abandon',dict(WRITE,request_key='new-abandon-key',capture_key=KEY,reason=abandoned['reason'])) + owner.abandon.assert_called_once_with(KEY,abandoned['reason'],CP,'new-abandon-key') + + def test_dispatch_preserves_alias_only_owner_request_and_bounded_native_projection(self): + runtime=Mock();current=inspection();runtime.inspect.return_value={ + **{field:current[field] for field in ('inventory_sha256','file_count','total_bytes','manifest_bytes','free_bytes')}, + 'manifest':{'profile':current['profile'],'python_version':current['python_version'],'packages':{'numpy':'0.0','torch':'0.0'}, + 'launch':current['launch'],'files':current['files']['items']}} + projection=SimpleNamespace(session=SimpleNamespace(access=SimpleNamespace(require=Mock())),runtime=runtime, + runtime_sources=Mock(return_value={'sources':['configured-cpu']})) + result=project_dispatch.dispatch(projection,'project_files.runtime_inspect',dict(READ,source='configured-cpu')) + self.assertEqual(result,current);runtime.inspect.assert_called_once_with({'source':'configured-cpu'}) + project_dispatch.dispatch(projection,'project_files.runtime_capture',dict(WRITE,source='configured-cpu',expected_inventory_sha256=CP)) + runtime.capture.assert_called_once_with({'source':'configured-cpu'},CP,CP,KEY) + project_dispatch.dispatch(projection,'project_files.runtime_status',dict(READ,capture_key=KEY)) + runtime.status.assert_called_once_with(KEY) + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_project_runtime_http.py b/tests/test_project_runtime_http.py new file mode 100644 index 00000000..844ba5da --- /dev/null +++ b/tests/test_project_runtime_http.py @@ -0,0 +1,81 @@ +"""Configured current-host runtime capture through real HTTP/PG, without execution.""" +import hashlib +from http.server import ThreadingHTTPServer +import os +import shutil +import threading +import unittest +from unittest.mock import patch + +from app import make_handler +from auth_helpers import ExplicitOwnerFixture +from project_workspace import ProjectWorkspace +import project_workspace +import test_project_files_http as m1 +import test_project_files_m2_http as m2 + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'),'Requires disposable PostgreSQL') +class ProjectRuntimeHTTP(unittest.TestCase): + cleanup_owned=m1.ProjectFilesHTTP.cleanup_owned + select_host=m1.ProjectFilesHTTP.select_host + request=m1.ProjectFilesHTTP.request + state=m1.ProjectFilesHTTP.state + envelope=m1.ProjectFilesHTTP.envelope + action=m1.ProjectFilesHTTP.action + create=m1.ProjectFilesHTTP.create + arguments=m2.ProjectFilesM2HTTP.arguments + transport=m2.ProjectFilesM2HTTP.transport + call=m2.ProjectFilesM2HTTP.call + + def setUp(self): + self.operations=[];m1.ProjectFilesHTTP.setUp(self) + self.enterContext(patch.object(project_workspace,'M3_ENABLED',True)) + python=self.path/'explicit-python';site=self.path/'explicit-packages' + (python/'bin').mkdir(parents=True);(python/'lib/python3.12/lib-dynload').mkdir(parents=True) + shutil.copyfile('/usr/bin/true',python/'bin/python3.12') + (python/'lib/python3.12/lib-dynload/fixture.txt').write_text('Owned inert bytes') + packages=[] + for name in ('torch','numpy'): + (site/name).mkdir(parents=True);(site/name/'__init__.py').write_text('raise AssertionError("Never import this fixture")\n') + info=site/(name+'-0.0.dist-info');info.mkdir();(info/'METADATA').write_text('Name: '+name+'\nVersion: 0.0\n') + (info/'LICENSE').write_text('Owned fixture');packages.extend([name,info.name]) + config=dict(schema_version=1,python_root=str(python),python_version='3.12',site_packages=str(site),packages=packages) + dsn=os.environ['TULDOK_DATABASE_URL'];control=self.path/'configured-control' + workspace=ProjectWorkspace(control,{'fixture':self.external},dsn=dsn,runtime_sources={'inert-fixture':config}) + self.schemas.append((dsn,workspace.db.schema));self.controls.append((dsn,control)) + owner=ExplicitOwnerFixture.bootstrap(workspace) + server=ThreadingHTTPServer(('127.0.0.1',0),make_handler(workspace));thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() + self.hosts.append((workspace,server,thread));self.workspace,self.owner=workspace,owner + self.origin='http://127.0.0.1:'+str(server.server_port) + + def test_configured_alias_actual_capture_and_cold_historical_status_never_execute(self): + self.create() + with patch('subprocess.Popen',side_effect=AssertionError('Runtime capture executed a child')): + sources=self.call('runtime_sources');self.assertEqual(sources['items'],[{'source':'inert-fixture'}]) + inventory=self.call('runtime_inspect',source='inert-fixture',limit=1) + self.assertEqual(len(inventory['files']['items']),1) + arguments=self.arguments('runtime_capture',source='inert-fixture', + expected_inventory_sha256=inventory['inventory_sha256'],reserve_bytes=0,request_key='actual-runtime-capture') + status,response=self.transport('runtime_capture',arguments);self.assertEqual(status,200,response) + captured=response['value'];self.assertEqual(captured['format'],'project_runtime_capture_v1') + projection=self.workspace._projection + receipts=[r['document'] for r in projection.session.snapshot['records'] if r['family']=='project_operation'] + self.assertTrue(any(r['operation']=='runtime.capture_intent' for r in receipts)) + self.assertTrue(any(r['operation']=='runtime.capture' for r in receipts)) + status,replay=self.transport('runtime_capture',arguments);self.assertEqual(status,200,replay) + self.assertEqual(replay['value'],captured) + self.action('close') + self.select_host(self.path/'cold-control',{'fixture':self.external}) + self.action('open',root='fixture',directory='alpha') + historical=self.call('runtime_status',capture_key='actual-runtime-capture') + self.assertEqual(historical['state'],'captured');self.assertEqual(historical['result'],captured) + self.assertEqual(self.call('runtime_sources')['items'],[]) + self.assertIsNone(self.workspace._projection.execution._runs) + self.assertFalse((self.workspace._projection.db.path/'runtime-snapshots'/inventory['inventory_sha256']).exists()) + before=self.workspace._projection.session.snapshot + status,error=self.transport('runtime_inspect',self.arguments('runtime_inspect',source='inert-fixture')) + self.assertEqual(status,409,error);self.assertEqual(self.workspace._projection.session.snapshot,before) + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_project_runtime_recovery_http.py b/tests/test_project_runtime_recovery_http.py new file mode 100644 index 00000000..f22b0b8e --- /dev/null +++ b/tests/test_project_runtime_recovery_http.py @@ -0,0 +1,236 @@ +"""Real HTTP/PG recovery for stopped, nonexecuting runtime copy owners. + +Reuse the authored /usr/bin/true ELF and inert package fixture. No runtime is +installed, imported, probed or executed. Abrupt fixture disposal closes only +known workerless handles; it does not claim successful public Close. +""" +import copy +import hashlib +from http.server import ThreadingHTTPServer +import json +import os +from pathlib import Path +import shutil +import threading +import unittest +from unittest.mock import patch + +import psycopg +from psycopg import sql + +from app import make_handler +from auth_helpers import ExplicitOwnerFixture +from metadata import connection_info +from project_runtime import _intent_key, validate_operation_result +from project_session import operation_documents, operation_records +from project_workspace import ProjectWorkspace +from workbench import WorkbenchError +import test_project_runtime_http as runtime_fixture + + +class RuntimeAbandonmentCodecTests(unittest.TestCase): + def test_completed_capture_for_same_project_and_key_cannot_be_abandoned(self): + project='1'*32;actor='2'*32;key='capture-codec-fixture';manifest='3'*64 + instant='2026-10-22T10:00:00+00:00';input_sha='4'*64 + intent=dict(format='project_runtime_capture_intent_v1',project_id=project, + manifest_sha256=manifest,file_count=1,total_bytes=1,actor_id=actor, + observed_at=instant,request_key=key) + abandoned=dict(format='project_runtime_capture_abandon_v1',project_id=project, + capture_key=key,input_sha256=input_sha,manifest_sha256=manifest, + actor_id=actor,reason='Explicitly stopped copy',observed_checkpoint='5'*64, + observed_at=instant) + def receipt(operation,request_key,result): + return dict(family='project_operation', + id=project+'.'+hashlib.sha256(request_key.encode()).hexdigest(), + document=dict(format='project_operation_v2',project_id=project, + operation=operation,target=manifest,request_key=request_key, + input_sha256=input_sha,result=result)) + records=[receipt('runtime.capture_intent',_intent_key(key),intent)] + operation_documents(records) + self.assertTrue(validate_operation_result('runtime.abandon',abandoned,records)) + complete=dict(intent,format='project_runtime_capture_v1', + runtime_ref=dict(kind='runtime',catalog_id=project,id=manifest,sha256=manifest)) + self.assertTrue(validate_operation_result('runtime.capture',complete)) + records.append(receipt('runtime.capture',key,complete)) + operation_documents(records) + with self.assertRaisesRegex(WorkbenchError,'Completed runtime capture cannot be abandoned'): + validate_operation_result('runtime.abandon',abandoned,records) + # Independent historical project keys cannot contradict this intent. + other=copy.deepcopy(records[-1]);other['document']['project_id']='6'*32 + other['document']['result']['project_id']='6'*32 + other['document']['result']['runtime_ref']['catalog_id']='6'*32 + other['id']='6'*32+'.'+hashlib.sha256(key.encode()).hexdigest() + records[-1]=other + operation_documents(records) + self.assertTrue(validate_operation_result('runtime.abandon',abandoned,records)) + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'),'Requires disposable PostgreSQL') +class ProjectRuntimeRecoveryHTTP(unittest.TestCase): + select_host=runtime_fixture.ProjectRuntimeHTTP.select_host + request=runtime_fixture.ProjectRuntimeHTTP.request + state=runtime_fixture.ProjectRuntimeHTTP.state + envelope=runtime_fixture.ProjectRuntimeHTTP.envelope + action=runtime_fixture.ProjectRuntimeHTTP.action + create=runtime_fixture.ProjectRuntimeHTTP.create + arguments=runtime_fixture.ProjectRuntimeHTTP.arguments + transport=runtime_fixture.ProjectRuntimeHTTP.transport + call=runtime_fixture.ProjectRuntimeHTTP.call + + def setUp(self): + runtime_fixture.ProjectRuntimeHTTP.setUp(self) + self.enterContext(patch('runs.Runs',side_effect=AssertionError('Recovery constructed Runs'))) + self.enterContext(patch('subprocess.Popen',side_effect=AssertionError('Runtime recovery executed a child'))) + self.config=copy.deepcopy(self.workspace._runtime_sources['inert-fixture']) + self.create() + self.inventory=self.call('runtime_inspect',source='inert-fixture',limit=1) + + def cleanup_owned(self): + schemas=set(self.schemas) + for workspace,server,thread in reversed(self.hosts): + server.shutdown();server.server_close();thread.join(3) + if workspace._closed:continue + self.dispose_handles(workspace) + for dsn,control in self.controls: + for manifest in control.rglob('.tuldok.json'): + value=json.loads(manifest.read_bytes()) + schemas.add((dsn,'projection_'+value['projection_id'] if value['version']==2 else 'catalog_'+value['dataset_id'])) + for dsn,schema in schemas: + with psycopg.connect(connection_info(dsn),autocommit=True) as connection: + connection.execute(sql.SQL('DROP SCHEMA IF EXISTS {} CASCADE').format(sql.Identifier(schema))) + + @staticmethod + def dispose_handles(workspace): + # Every tested operation is synchronous and Popen is forbidden. Leave + # journals/canonical files intact while disposing test-owned handles. + for projection in (workspace._projection,workspace._retained_recovery): + if projection is None:continue + if projection.execution._runs is not None:raise AssertionError('Unexpected live Run owner') + if projection.sources is not None:projection.sources.close() + projection.artifacts.close();projection.db.close() + if workspace._store is not None:workspace._store.close() + for _path,descriptor in workspace._roots.values():os.close(descriptor) + workspace._roots.clear();workspace.db.close();workspace._closed=True + + def stop_inert_application(self,workspace): + for candidate,server,thread in self.hosts: + if candidate is workspace: + server.shutdown();server.server_close();thread.join(3) + break + self.dispose_handles(workspace) + + def start_application(self,control,*,sources=None): + dsn=os.environ['TULDOK_DATABASE_URL'] + workspace=ProjectWorkspace(control,{'fixture':self.external},dsn=dsn,runtime_sources=sources) + self.schemas.append((dsn,workspace.db.schema));self.controls.append((dsn,Path(control))) + owner=ExplicitOwnerFixture.bootstrap(workspace) + server=ThreadingHTTPServer(('127.0.0.1',0),make_handler(workspace)) + thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() + self.hosts.append((workspace,server,thread));self.workspace,self.owner=workspace,owner + self.origin='http://127.0.0.1:'+str(server.server_port) + + def capture_arguments(self,key,*,reserve=0): + return self.arguments('runtime_capture',source='inert-fixture', + expected_inventory_sha256=self.inventory['inventory_sha256'],reserve_bytes=reserve,request_key=key) + + def partial(self,key): + status,error=self.transport('runtime_capture',self.capture_arguments(key,reserve=2**53-1)) + self.assertEqual(status,409,error);self.assertEqual(error['code'],'unavailable') + projection=self.workspace._projection + records=projection.session.snapshot['records'];intent=operation_records(records)[_intent_key(key)] + marker=projection.custody.snapshot()['runtime_effects'][0] + self.assertEqual(marker['input_sha256'],intent['input_sha256']) + self.assertNotIn(key,operation_records(records));self.assertTrue(projection.custody.has_pending) + return projection + + def test_actual_partial_copy_abandon_needs_no_remaining_source_and_retains_binding(self): + key='partial-source-lost';projection=self.partial(key);before=self.state();binding=before['binding'] + status,error=self.transport('close',self.envelope(request_key='close-pending-runtime')) + self.assertEqual(status,409,error);self.assertTrue(projection.custody.has_pending) + recovery=self.state();self.assertEqual(recovery['state'],'recovery_required') + shutil.rmtree(self.config['python_root']);shutil.rmtree(self.config['site_packages']) + with patch.object(projection.runtime,'resolve_configuration',side_effect=AssertionError('Abandon read missing source')): + abandoned=self.call('runtime_abandon',capture_key=key,reason='Explicit owner retains partial copy as abandoned') + self.assertEqual(abandoned['capture_key'],key) + self.assertTrue(validate_operation_result('runtime.abandon',abandoned,projection.session.snapshot['records'])) + self.assertEqual(self.state()['binding'],binding);self.assertEqual(self.state()['state'],'open') + self.assertFalse(projection.custody.has_pending) + self.assertEqual(self.call('runtime_status',capture_key=key)['state'],'abandoned') + self.action('save');self.action('close') + + def test_final_sql_failure_retry_requires_exact_public_view_and_never_recopies(self): + projection=self.workspace._projection;db=projection.db;key='lost-final-sql' + db.execute('CREATE TABLE runtime_ack_parent(id INTEGER PRIMARY KEY)') + db.execute('CREATE TABLE runtime_ack_child(id INTEGER REFERENCES runtime_ack_parent(id) DEFERRABLE INITIALLY DEFERRED)') + mutate=projection.session.mutate + def fault(operation,target,body,checkpoint,request_key,invoke,capture,**kwargs): + if operation!='runtime.capture':return mutate(operation,target,body,checkpoint,request_key,invoke,capture,**kwargs) + def bad(): + result=invoke();db.execute('INSERT INTO runtime_ack_child VALUES(84)');return result + return mutate(operation,target,body,checkpoint,request_key,bad,capture,**kwargs) + with patch.object(projection.session,'mutate',side_effect=fault): + status,error=self.transport('runtime_capture',self.capture_arguments(key)) + self.assertEqual(status,503,error);self.assertFalse(projection.session.available) + state=self.state();self.assertEqual(state['state'],'recovery_required') + arguments=self.capture_arguments(key);before=projection.session.snapshot + for change in (dict(binding='f'*32),dict(expected_checkpoint='f'*64)): + with patch.object(projection.runtime,'recover_capture',side_effect=AssertionError('Rejected public view reached recovery')): + status,error=self.transport('runtime_capture',dict(arguments,**change)) + self.assertEqual(status,409,error);self.assertEqual(error['code'],'stale_project') + self.assertEqual(projection.session.snapshot,before);self.assertTrue(projection.custody.has_pending) + with patch.object(projection.runtime.owner,'create',side_effect=AssertionError('Do not recopy committed runtime')): + status,recovered=self.transport('runtime_capture',arguments) + self.assertEqual(status,200,recovered);self.assertEqual(recovered['binding'],state['binding']) + self.assertEqual(self.state()['state'],'open');self.assertTrue(projection.session.available) + self.assertFalse(projection.custody.has_pending) + self.assertEqual(self.call('runtime_status',capture_key=key)['state'],'captured') + self.action('close') + + def test_original_physical_cache_recovery_and_foreign_intent_cannot_regenerate(self): + key='published-before-final';original=self.workspace;projection=original._projection;create=projection.runtime.owner.create + def after_copy(*args,**kwargs): + value=create(*args,**kwargs) + raise WorkbenchError('Injected loss after actual native publication.','outcome_unknown',503) + with patch.object(projection.runtime.owner,'create',side_effect=after_copy): + status,error=self.transport('runtime_capture',self.capture_arguments(key)) + self.assertEqual(status,503,error);physical=projection.db.projection_id + self.assertTrue((projection.runtime.owner.path/self.inventory['inventory_sha256']).is_dir()) + shutil.copytree(self.external/'alpha',self.external/'clone',ignore=shutil.ignore_patterns('.tuldok-local')) + self.start_application(self.path/'foreign-control',sources={'inert-fixture':self.config}) + self.action('open',root='fixture',directory='clone') + foreign=self.workspace._projection;before=foreign.session.snapshot + with patch.object(foreign.runtime.owner,'create',side_effect=AssertionError('Imported intent regenerated a runtime')): + status,error=self.transport('runtime_capture',self.capture_arguments(key)) + self.assertEqual(status,409,error);self.assertEqual(error['code'],'unavailable') + self.assertEqual(foreign.session.snapshot,before);self.assertFalse(foreign.custody.has_pending) + self.action('close') + self.stop_inert_application(original) + shutil.rmtree(self.config['python_root']);shutil.rmtree(self.config['site_packages']) + self.start_application(original.path,sources={}) + opened=self.action('open',root='fixture',directory='alpha') + self.assertEqual(opened['state'],'recovery_required');binding=opened['binding'] + self.assertNotEqual(self.workspace._projection.db.projection_id,physical) + with patch('runtime_snapshots.RuntimeSnapshots.create',side_effect=AssertionError('Reopened recovery recopied runtime')): + status,recovered=self.transport('runtime_capture',self.capture_arguments(key)) + self.assertEqual(status,200,recovered);self.assertEqual(recovered['binding'],binding) + self.assertEqual(self.workspace._projection.db.projection_id,physical) + self.assertEqual(self.workspace.custody.pending(self.workspace._store),[]) + self.assertEqual(self.state()['state'],'open');self.assertEqual(self.call('runtime_sources')['items'],[]) + self.action('close') + + def test_original_stopped_copy_can_be_abandoned_after_fresh_open_without_aliases(self): + key='original-partial';projection=self.partial(key);original=self.workspace;physical=projection.db.projection_id + self.stop_inert_application(original) + shutil.rmtree(self.config['python_root']);shutil.rmtree(self.config['site_packages']) + self.start_application(original.path,sources={}) + opened=self.action('open',root='fixture',directory='alpha') + self.assertEqual(opened['state'],'recovery_required');binding=opened['binding'] + abandoned=self.call('runtime_abandon',capture_key=key,reason='Original owner acknowledges stopped partial copy') + self.assertEqual(abandoned['capture_key'],key);self.assertEqual(self.state()['binding'],binding) + self.assertEqual(self.workspace._projection.db.projection_id,physical) + self.assertEqual(self.workspace.custody.pending(self.workspace._store),[]) + self.assertEqual(self.call('runtime_status',capture_key=key)['state'],'abandoned') + self.action('save');self.action('close') + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_project_session.py b/tests/test_project_session.py new file mode 100644 index 00000000..f9356c44 --- /dev/null +++ b/tests/test_project_session.py @@ -0,0 +1,344 @@ +"""Shared publication ordering over real PostgreSQL and canonical artifacts. + +Callbacks here deliberately project artifact metadata into a temporary SQL +table. Scientific policy is exercised by the separate domain codec fixtures. +""" +import copy +import hashlib +import json +import os +from pathlib import Path +from types import SimpleNamespace +import tempfile +import unittest +from unittest.mock import patch +import uuid + +import psycopg +from psycopg import sql + +from access import Access, AccessError +from auth_helpers import ExplicitOwnerFixture +from metadata import Metadata, connection_info +from project_artifacts import ProjectArtifacts +from project_session import ProjectSession, ProjectSessionError, operation_documents, operation_records +from project_locks import ProjectLock +from project_store import ProjectStore, ProjectStoreError + + +class OperationCodecTests(unittest.TestCase): + def test_namespaced_receipts_preserve_legacy_identity_and_independent_keys(self): + key = 'independent-edit' + document = dict(format='project_operation_v1', operation='fixture.capture', target=None, + request_key=key, input_sha256='a'*64, result={}) + legacy = dict(family='project_operation', id=hashlib.sha256(key.encode()).hexdigest(), document=document) + current = copy.deepcopy(legacy) + current['document'].update(format='project_operation_v2', project_id='b'*32) + current['id'] = 'b'*32 + '.' + legacy['id'] + self.assertEqual(len(operation_documents([legacy,current])), 2) + self.assertEqual(operation_records([current],project_id='b'*32)[key], current['document']) + with self.assertRaises(ProjectSessionError): + operation_records([current],project_id='c'*32) + with self.assertRaises(ProjectSessionError): + operation_records([legacy,current],project_id='b'*32) + future = copy.deepcopy(current); future['document']['format']='project_operation_v3' + with self.assertRaises(ProjectSessionError): + operation_documents([future]) + + def test_fork_cannot_reactivate_unnamespaced_source_receipt_by_editing_archive(self): + from project_fork import _digest, _input + key='legacy-edit' + legacy=dict(family='project_operation',id=hashlib.sha256(key.encode()).hexdigest(),document=dict( + format='project_operation_v1',operation='fixture.capture',target=None,request_key=key, + input_sha256='a'*64,result={})) + source=dict(project_id='b'*32,checkpoint='c'*64) + fork=dict(format='project_fork_v1',source=source,destination_project_id='d'*32, + request_key='fork-request',request_sha256=_digest(_input(source,'fork','Fork','fork-request')), + directory='fork',name='Fork',mapping=[],archived=[dict(family=legacy['family'],id=legacy['id'])], + artifacts=dict(copied=[],missing_regenerable=[])) + records=[legacy,dict(family='project_fork',id='d'*32,document=fork)] + self.assertEqual(operation_records(records,project_id='d'*32),{}) + self.assertEqual(operation_documents(records),[legacy['document']]) + fork['archived']=[] + with self.assertRaisesRegex(ProjectSessionError,'archived'): + operation_records(records,project_id='d'*32) + self.assertEqual(operation_records([legacy])[key],legacy['document']) + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'), 'Requires disposable local PostgreSQL') +class ProjectSessionTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='tuldok-project-session-') + self.path = Path(self.temporary.name) + self.resources, self.schemas = [], [] + self.addCleanup(self.temporary.cleanup) + self.addCleanup(self.cleanup_owned) + self.store = ProjectStore.create(self.path / 'project', 'Session fixture', 'new-session', 'a' * 64) + self.resources.append(self.store) + self.artifacts = ProjectArtifacts(self.store) + self.resources.append(self.artifacts) + host = self.metadata('host') + self.access = Access(host, host.path) + self.owner = ExplicitOwnerFixture.bootstrap(SimpleNamespace(db=host, access=self.access)) + self.projection = self.metadata('projection', projection=True) + self.create_projection(self.projection) + self.session = self.bind(self.projection) + + def metadata(self, name, *, projection=False): + arguments = dict(dataset_id=self.store.snapshot()['project_id'], projection_id=uuid.uuid4().hex) if projection else {} + value = Metadata(self.path / (name + '-' + uuid.uuid4().hex), **arguments) + self.schemas.append(value.schema) + self.resources.append(value) + return value + + def cleanup_owned(self): + for resource in reversed(self.resources): + resource.close() + with psycopg.connect(connection_info(), autocommit=True) as connection: + for schema in self.schemas: + connection.execute(sql.SQL('DROP SCHEMA IF EXISTS {} CASCADE').format(sql.Identifier(schema))) + + def create_projection(self, db): + db.execute('CREATE TEMP TABLE session_projection (sha256 TEXT PRIMARY KEY, bytes BIGINT NOT NULL)') + + def validate(self, records): + for item in records: + if item['family'] == 'project_artifact': + value = item['document'] + if (set(value) != {'format', 'sha256', 'bytes'} or value['format'] != 'project_artifact_v1' + or value['sha256'] != item['id'] or type(value['bytes']) is not int or value['bytes'] < 0): + raise ProjectSessionError('Invalid fixture artifact declaration.') + elif item['family'] != 'project_operation': + raise ProjectSessionError('Unsupported fixture family.') + + def bind(self, db): + with self.access.bind(self.owner.token): + return ProjectSession(self.store, db, self.access, self.artifacts, validate_records=self.validate) + + def mutation(self, raw=None, *, key=None, expected=None, capture=None, invoke=None): + raw = 'Irreplaceable observation 山'.encode() if raw is None else raw + declaration = dict(format='project_artifact_v1', sha256=hashlib.sha256(raw).hexdigest(), bytes=len(raw)) + key = key or uuid.uuid4().hex + calls = [] + + def project(): + calls.append('invoke') + self.session.db.execute('INSERT INTO session_projection VALUES (%s,%s)', + (declaration['sha256'], declaration['bytes'])) + return dict(sha256=declaration['sha256'], bytes=declaration['bytes']) + + def retain(result, before): + self.assertEqual(before, self.session.snapshot['records']) + self.artifacts.publish_bytes(raw, declaration) + return dict(records=[dict(family='project_artifact', id=declaration['sha256'], document=declaration)], result=result) + + arguments = ('fixture.capture', None, dict(sha256=declaration['sha256']), + expected or self.session.snapshot['checkpoint'], key, invoke or project, capture or retain) + return arguments, declaration, calls + + def call(self, arguments): + with self.access.bind(self.owner.token): + return self.session.mutate(*arguments) + + def test_complete_merge_and_original_result_replay_before_stale_cas(self): + first, declaration, calls = self.mutation() + first_result = self.call(first) + self.assertEqual(calls, ['invoke']) + second, other, _ = self.mutation(b'Other independently authored observation') + self.call(second) + stable = self.store.snapshot() + self.assertEqual(self.call(first), first_result) + self.assertEqual(calls, ['invoke']) + self.assertEqual(self.store.snapshot(), stable) + ids = {(item['family'], item['id']) for item in stable['records']} + self.assertIn(('project_artifact', declaration['sha256']), ids) + self.assertIn(('project_artifact', other['sha256']), ids) + self.assertEqual(self.artifacts.read_blob({key: declaration[key] for key in ('sha256', 'bytes')}), 'Irreplaceable observation 山'.encode()) + + def test_key_conflict_stale_and_current_auth_refuse_before_invoke(self): + first, _declaration, _calls = self.mutation() + self.call(first) + stable = self.store.snapshot() + changed = list(first); changed[2] = dict(sha256='b' * 64) + with self.assertRaises(ProjectSessionError) as error: + self.call(changed) + self.assertEqual(error.exception.code, 'conflict') + stale, _declaration, calls = self.mutation(b'Stale', expected='b' * 64) + with self.assertRaises(ProjectSessionError) as error: + self.call(stale) + self.assertEqual(error.exception.code, 'stale_project') + with self.assertRaises(AccessError): + self.session.mutate(*stale) + self.assertFalse(calls) + self.assertEqual(self.store.snapshot(), stable) + + def test_composed_validation_failure_rolls_back_sql_before_canonical_publication(self): + stable = self.store.snapshot() + + def invalid(result, before): + return dict(records=[dict(family='project_artifact', id='a' * 64, + document=dict(format='project_artifact_v1', sha256='a' * 64, bytes=-1))], result=result) + + arguments, _declaration, _calls = self.mutation(capture=invalid) + with self.assertRaises(ProjectSessionError): + self.call(arguments) + self.assertEqual(self.store.snapshot(), stable) + self.assertEqual(self.projection.execute('SELECT count(*) FROM session_projection').fetchone()[0], 0) + + def test_real_deferred_commit_failure_retains_unique_payload_and_receipt(self): + self.projection.execute("""CREATE FUNCTION pg_temp.reject_session_commit() RETURNS trigger + LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'Owned fixture deferred commit refusal'; END $$""") + self.projection.execute('''CREATE CONSTRAINT TRIGGER session_commit_refusal + AFTER INSERT ON session_projection DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION pg_temp.reject_session_commit()''') + arguments, declaration, calls = self.mutation() + with self.assertRaises(ProjectSessionError) as error: + self.call(arguments) + receipt = error.exception.receipt + self.assertEqual(receipt, dict(committed=True, selected=True, project_id=self.store.snapshot()['project_id'], + checkpoint=self.store.snapshot()['checkpoint'], request_key=arguments[4])) + self.assertFalse(self.session.available) + self.assertEqual(self.projection.execute('SELECT count(*) FROM session_projection').fetchone()[0], 0) + self.assertEqual(self.artifacts.read_blob({key: declaration[key] for key in ('sha256', 'bytes')}), 'Irreplaceable observation 山'.encode()) + restored = self.metadata('fresh-projection', projection=True) + self.create_projection(restored) + for item in self.store.snapshot()['records']: + if item['family'] == 'project_artifact': + restored.execute('INSERT INTO session_projection VALUES (%s,%s)', + (item['id'], item['document']['bytes'])) + self.session = self.bind(restored) + result = self.call(arguments) + self.assertEqual(result, dict(sha256=declaration['sha256'], bytes=declaration['bytes'])) + self.assertEqual(calls, ['invoke']) + self.assertEqual(restored.execute('SELECT count(*) FROM session_projection').fetchone()[0], 1) + + def test_nested_transaction_cannot_acknowledge_uncommitted_outer_sql(self): + arguments, _declaration, calls = self.mutation() + stable = self.store.snapshot() + with self.projection.transaction(), self.assertRaises(ProjectSessionError): + self.call(arguments) + self.assertFalse(calls) + self.assertEqual(self.store.snapshot(), stable) + + def test_outer_hook_precedes_commit_and_wake_can_own_a_new_transaction(self): + events = [] + db = self.projection + def before(): + self.assertTrue(db.in_transaction) + second = bool(events) + self.assertEqual(db.execute('SELECT count(*) FROM session_projection').fetchone()[0], 2 if second else 1) + events.append('before-second' if second else 'before') + def outcome(value): + self.assertFalse(db.in_transaction) + events.append(value) + def wake(): + self.assertFalse(db.in_transaction) + with db.transaction(): + db.execute('INSERT INTO session_projection VALUES (%s,%s)', ('b'*64, 2)) + events.append('wake') + db.set_project_hooks(before, outcome) + with db.transaction(): + db.execute('INSERT INTO session_projection VALUES (%s,%s)', ('a'*64, 1)) + with db.transaction(): + db.on_commit(wake) + self.assertEqual(events, []) + self.assertEqual(events, ['before', 'committed', 'before-second', 'committed', 'wake']) + self.assertEqual(db.execute('SELECT count(*) FROM session_projection').fetchone()[0], 2) + + def test_before_commit_rejection_and_savepoint_rollback_never_wake(self): + events = [] + db = self.projection + def reject(): + events.append('before') + raise RuntimeError('Owned canonical capture failed') + db.set_project_hooks(reject, lambda value: events.append(value)) + with self.assertRaisesRegex(RuntimeError, 'canonical capture failed'): + with db.transaction(): + db.execute('INSERT INTO session_projection VALUES (%s,%s)', ('a'*64, 1)) + with self.assertRaisesRegex(RuntimeError, 'savepoint'): + with db.transaction(): + db.on_commit(lambda: events.append('bad-wake')) + raise RuntimeError('savepoint') + db.on_commit(lambda: events.append('wake')) + self.assertEqual(events, ['before', 'rolled_back']) + self.assertEqual(db.execute('SELECT count(*) FROM session_projection').fetchone()[0], 0) + + def test_background_canonical_before_sql_failure_retains_observation_and_fences(self): + raw = b'Unique background measurement' + declaration = dict(format='project_artifact_v1', sha256=hashlib.sha256(raw).hexdigest(), bytes=len(raw)) + self.artifacts.publish_bytes(raw, declaration) + records = [dict(family='project_artifact', id=declaration['sha256'], document=declaration)] + db = self.projection + db.execute('CREATE TEMP TABLE reject_background (id INTEGER, parent INTEGER REFERENCES reject_background(id) DEFERRABLE INITIALLY DEFERRED, UNIQUE(id))') + def before(): + self.session._publish_observation(records, 'background-one', 'c'*64, authorize=lambda: None) + db.set_project_hooks(before, self.session._observation_outcome) + with self.access.bind(self.owner.token), self.assertRaises(psycopg.IntegrityError): + with db.transaction(): + db.execute('INSERT INTO reject_background VALUES (1,2)') + self.assertFalse(self.session.available) + self.assertEqual(self.store.snapshot()['records'], records) + self.assertEqual(self.artifacts.read_blob({k:declaration[k] for k in ('sha256','bytes')}), raw) + self.assertEqual(db.execute('SELECT count(*) FROM reject_background').fetchone()[0], 0) + + def test_foreground_extra_capture_is_durable_without_second_hook_publication(self): + extra = dict(format='project_artifact_v1', sha256=hashlib.sha256(b'extra').hexdigest(), bytes=5) + self.artifacts.publish_bytes(b'extra', extra) + self.session.extra_capture = lambda before: [dict(family='project_artifact', id=extra['sha256'], document=extra)] + flags = [] + self.projection.set_project_hooks(lambda: flags.append(self.session.foreground_publication), + self.session._observation_outcome) + arguments, declaration, _ = self.mutation() + self.call(arguments) + self.assertEqual(flags, [True]) + self.assertFalse(self.session.foreground_publication) + self.assertEqual({i['id'] for i in self.store.snapshot()['records'] if i['family']=='project_artifact'}, + {extra['sha256'], declaration['sha256']}) + + def test_ordered_projection_lock_owns_host_and_session_for_background_entry(self): + db = self.projection + original = db.lock + db.lock = ProjectLock(self.access.lock, self.session.lock, original) + with db.lock: + self.assertGreater(self.access.lock.depth, 0) + self.assertTrue(self.session.lock._is_owned()) + with db.transaction(): + db.execute('INSERT INTO session_projection VALUES (%s,%s)', ('a'*64, 1)) + self.assertEqual(original.depth, 0) + self.assertEqual(self.access.lock.depth, 0) + + def test_background_pointer_fsync_uncertainty_fences_even_when_sql_rolls_back(self): + declaration = dict(format='project_artifact_v1', sha256=hashlib.sha256(b'unknown').hexdigest(), bytes=7) + self.artifacts.publish_bytes(b'unknown', declaration) + records = [dict(family='project_artifact', id=declaration['sha256'], document=declaration)] + previous = self.store.snapshot()['checkpoint'] + original_fsync = os.fsync + def fail_after_replace(fd): + if fd == self.store._meta_fd and json.loads((self.path/'project/.tuldok/project.json').read_bytes())['checkpoint'] != previous: + raise OSError('Owned fixture checkpoint directory fsync failure') + return original_fsync(fd) + self.projection.set_project_hooks( + lambda: self.session._publish_observation(records, 'background-unknown', 'd'*64, authorize=lambda: None), + self.session._observation_outcome) + with self.access.bind(self.owner.token), patch('project_store.os.fsync', fail_after_replace): + with self.assertRaises(ProjectStoreError) as error: + with self.projection.transaction(): + self.projection.execute('INSERT INTO session_projection VALUES (%s,%s)', ('e'*64, 1)) + self.assertEqual(error.exception.code, 'unknown') + self.assertFalse(self.session.available) + self.assertEqual(self.projection.execute('SELECT count(*) FROM session_projection').fetchone()[0], 0) + recovered = self.store.recover_checkpoint() + self.session.reconcile_selection(recovered) + self.assertTrue(self.session.available) + other = dict(format='project_artifact_v1', sha256=hashlib.sha256(b'other').hexdigest(), bytes=5) + unrelated = records + [dict(family='project_artifact', id=other['sha256'], document=other)] + changed = self.store.commit(unrelated, recovered['checkpoint'], 'unrelated-checkout', 'f'*64) + self.session.available = False + with self.assertRaises(ProjectSessionError) as conflict: + self.session.reconcile_selection(changed) + self.assertEqual(conflict.exception.code, 'stale_project') + self.assertFalse(self.session.available) + self.store.close() + reopened = ProjectStore.open(self.path/'project') + self.resources.append(reopened) + self.assertEqual(reopened.snapshot()['records'], unrelated) diff --git a/tests/test_project_sources.py b/tests/test_project_sources.py new file mode 100644 index 00000000..ca7b3556 --- /dev/null +++ b/tests/test_project_sources.py @@ -0,0 +1,220 @@ +"""Explicit rooted source registration/import using disposable PostgreSQL. + +Fixtures are authored native scientific bytes. No source script, worker, +training runtime, dependency installer, network fetch or laboratory is used. +""" +import copy +import hashlib +import json +import os +from pathlib import Path +import shutil +from types import SimpleNamespace +import tempfile +import unittest +from unittest.mock import patch +import uuid + +from access import Access, AccessError +from metadata import Metadata +from project_artifacts import ProjectArtifacts, validate_declaration +from project_data import ProjectData, decode_data_records, validate_operation_result as data_result +from project_material import ProjectMaterial, decode_records as material_records +from project_material import validate_operation_result as material_result +from project_session import ProjectSession, operation_records +from project_sources import SourceAdapter, validate_operation_result +from project_store import ProjectStore +from projects import Projects +from qualify_scientific_fields import neutral_bundle +from workbench import WorkbenchError + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'),'Requires disposable PostgreSQL') +class SourceAdapterTests(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory(prefix='tuldok-project-source-');self.root=Path(self.tmp.name) + self.addCleanup(self.tmp.cleanup);self.resources=[];self.schemas=[];self.addCleanup(self.cleanup) + self.source_root=self.root/'source';self.source_root.mkdir() + self.raw=neutral_bundle();self.digest=hashlib.sha256(self.raw).hexdigest() + (self.source_root/'field.zip').write_bytes(self.raw) + (self.source_root/'never-run.py').write_text('raise AssertionError("Source registration executed a script")\n') + self.manifest=dict(schema_version=1,id='a'*32,name='Authored source λ', + data=[dict(id='fields',label='Authored native fields',path='field.zip',sha256=self.digest)], + recipes=[],evidence=[],requirements=[]) + self.manifest_raw=(json.dumps(self.manifest,ensure_ascii=False,indent=3)+'\n\n').encode() + (self.source_root/'source.json').write_bytes(self.manifest_raw) + self.host=self.metadata('host') + self.access=Access(self.host,self.host.path) + self.token=self.access.exchange(self.access._local_owner_token())['token'] + self.enterContext(self.access.bind(self.token)) + self.store=ProjectStore.create(self.root/'project','Source fixture','new-source','a'*64) + self.resources.append(self.store) + self.artifacts=ProjectArtifacts(self.store);self.resources.append(self.artifacts) + self.projection,self.adapter=self.project('projection',{'fixture':self.source_root}) + self.session=self.projection.session;self.db=self.session.db + + def metadata(self,name,*,projection=False): + kwargs=dict(dataset_id=self.store.snapshot()['project_id'],projection_id=uuid.uuid4().hex) if projection else {} + db=Metadata(self.root/name,**kwargs);self.resources.append(db);self.schemas.append(db.schema) + return db + + def project(self,name,roots): + db=self.metadata(name,projection=True) + session=ProjectSession(self.store,db,self.access,self.artifacts,validate_records=self.validate) + data=ProjectData(session);data.restore();materials=ProjectMaterial(session);materials.restore() + context=SimpleNamespace(db=db,lock=db.lock,path=db.path,access=materials.owner_access) + sources=Projects(context,roots);self.resources.append(sources) + projection=SimpleNamespace(session=session,db=db,access=self.access,data=data,materials=materials,sources=sources) + return projection,SourceAdapter(projection) + + def cleanup(self): + import psycopg + from psycopg import sql + from metadata import connection_info + for resource in reversed(self.resources):resource.close() + with psycopg.connect(connection_info(),autocommit=True) as db: + for schema in self.schemas:db.execute(sql.SQL('DROP SCHEMA IF EXISTS {} CASCADE').format(sql.Identifier(schema))) + + def validate(self,records): + decode_data_records(records);material_records(records) + for envelope in records: + if envelope['family']=='project_artifact':validate_declaration(envelope['document']) + for receipt in operation_records(records).values(): + operation,result=receipt['operation'],receipt['result'] + if not (validate_operation_result(operation,result,records) or data_result(operation,result,records) or material_result(operation,result,records)): + raise AssertionError('Unexpected fixture operation') + + def register(self,adapter=None,key='register-source',body=None,checkpoint=None): + adapter=adapter or self.adapter + return adapter.register(body or dict(root='fixture',manifest_path='source.json'), + checkpoint or adapter.session.snapshot['checkpoint'],key) + + def import_body(self,registration): + return dict(snapshot={key:registration[key] for key in ('origin_catalog_id','project_id','manifest_sha256')}, + reference_id='fields',expected_sha256=self.digest, + document=dict(name='Authored scientific source',groups=['source-fixture'],rights='Authored fixture; no scientific qualification claim.')) + + def test_registration_retains_original_bytes_attribution_and_one_canonical_operation(self): + with patch('subprocess.Popen',side_effect=AssertionError('No script process')): + result=self.register() + self.assertEqual(result['actor_id'],self.access._context().actor_id) + self.assertTrue(validate_operation_result('source.register',result,self.store.snapshot()['records'])) + snapshot=self.store.snapshot();records=snapshot['records'] + self.assertEqual({r['family'] for r in records},{'project_snapshot','project_artifact','project_operation'}) + retained=next(r for r in records if r['family']=='project_snapshot') + self.assertEqual(retained['document']['value']['manifest_sha256'],hashlib.sha256(self.manifest_raw).hexdigest()) + self.assertEqual(self.artifacts.read_blob(retained['document']['artifacts'][0]['blob']),self.manifest_raw) + self.assertEqual(self.db.execute('SELECT count(*) FROM workspace_projects').fetchone()[0],1) + again=self.register(key='register-again') + self.assertEqual(again['manifest_sha256'],result['manifest_sha256']) + self.assertEqual(next(r for r in self.store.snapshot()['records'] if r['family']=='project_snapshot'),retained) + for forged in (dict(result,name='Well-formed forged snapshot'),dict(result,root='fixture'),dict(result,registered_at='2026-10-22T12:00:00')): + with self.assertRaises(WorkbenchError):validate_operation_result('source.register',forged,records) + self.assertFalse(validate_operation_result('data.import_scientific',{},records)) + + def test_cold_open_has_no_bindings_and_explicit_replay_can_rebind_relocated_root(self): + first=self.register();canonical=self.store.snapshot() + original=next(r for r in canonical['records'] if r['family']=='project_snapshot') + moved=self.root/'relocated';shutil.copytree(self.source_root,moved) + projection,adapter=self.project('cold',{'fixture':moved}) + self.assertEqual(projection.db.execute('SELECT count(*) FROM workspace_projects').fetchone()[0],0) + with self.assertRaises(WorkbenchError):projection.sources.read_snapshot_manifest(first['project_id'],first['manifest_sha256']) + self.assertEqual(self.register(adapter,checkpoint='0'*64),first) + self.assertEqual(self.store.snapshot(),canonical) + current=json.loads(projection.db.execute('SELECT document FROM workspace_projects').fetchone()[0]) + self.assertNotEqual(current['root_identity'],original['document']['value']['root_identity']) + self.assertEqual(next(r for r in self.store.snapshot()['records'] if r['family']=='project_snapshot'),original) + self.assertEqual(projection.sources.read_snapshot_manifest(first['project_id'],first['manifest_sha256'])[1],self.manifest_raw) + + def test_receipt_cannot_bind_absent_current_root_and_agent_is_denied_before_read(self): + first=self.register();canonical=self.store.snapshot() + projection,adapter=self.project('without-roots',{}) + with self.assertRaises(WorkbenchError):self.register(adapter,checkpoint='0'*64) + self.assertEqual(projection.db.execute('SELECT count(*) FROM workspace_projects').fetchone()[0],0) + agent=self.access.issue(dict(label='Source editor',scopes=['inspect','edit'])) + with self.access.bind(agent['token']),patch.object(self.projection.sources,'_read_snapshot_file',side_effect=AssertionError('Owner check must precede I/O')): + with self.assertRaises(AccessError):self.register(checkpoint='0'*64) + self.assertEqual(self.store.snapshot(),canonical) + + def test_changed_manifest_stale_cas_and_key_conflict_never_renew_binding(self): + first=self.register();canonical=self.store.snapshot() + current=self.db.execute('SELECT document FROM workspace_projects').fetchone()[0] + (self.source_root/'other.json').write_bytes(self.manifest_raw) + with self.assertRaises(WorkbenchError) as error:self.register(body=dict(root='fixture',manifest_path='other.json')) + self.assertEqual(error.exception.code,'conflict') + with self.assertRaises(WorkbenchError) as error:self.register(key='stale-source',checkpoint='0'*64) + self.assertEqual(error.exception.code,'stale_project') + changed=dict(self.manifest,name='Changed source');(self.source_root/'source.json').write_text(json.dumps(changed)) + with self.assertRaises(WorkbenchError) as error:self.register(checkpoint='0'*64) + self.assertEqual(error.exception.code,'stale') + self.assertEqual(self.db.execute('SELECT document FROM workspace_projects').fetchone()[0],current) + self.assertEqual(self.store.snapshot(),canonical) + + def test_symlink_and_special_source_reads_refuse_before_any_canonical_import(self): + outside=self.root/'outside.json';outside.write_bytes(self.manifest_raw) + (self.source_root/'linked.json').symlink_to(outside) + with self.assertRaises(WorkbenchError):self.register(body=dict(root='fixture',manifest_path='linked.json')) + first=self.register();canonical=self.store.snapshot();body=self.import_body(first) + (self.source_root/'field.zip').unlink();(self.source_root/'field.zip').symlink_to(outside) + with self.assertRaises(WorkbenchError):self.adapter.import_scientific(body,self.session.snapshot['checkpoint'],'symlink-import') + (self.source_root/'field.zip').unlink();os.mkfifo(self.source_root/'field.zip') + with self.assertRaises(WorkbenchError):self.adapter.import_scientific(body,self.session.snapshot['checkpoint'],'fifo-import') + self.assertEqual(self.store.snapshot(),canonical) + + def test_real_scientific_import_uses_one_data_session_and_cold_source_access_stays_unbound(self): + registration=self.register();body=self.import_body(registration) + with patch.object(self.session,'mutate',wraps=self.session.mutate) as observed: + row=self.adapter.import_scientific(body,self.session.snapshot['checkpoint'],'import-science') + self.assertEqual(observed.call_count,1) + self.assertEqual(observed.call_args.args[0],'data.import_scientific') + self.assertEqual(self.projection.data.workbench.asset(row['id'])[0],self.raw) + canonical=self.store.snapshot() + projection,adapter=self.project('cold-import',{'fixture':self.source_root}) + self.assertEqual(projection.data.get(row['id']),row) + self.assertEqual(projection.data.workbench.asset(row['id'])[0],self.raw) + with self.assertRaises(WorkbenchError):adapter.import_scientific(body,'0'*64,'import-science') + self.assertEqual(self.register(adapter,checkpoint='0'*64),registration) + self.assertEqual(adapter.import_scientific(body,'0'*64,'import-science'),row) + forged=copy.deepcopy(body);forged['snapshot']['origin_catalog_id']='f'*32 + with self.assertRaises(WorkbenchError):adapter.import_scientific(forged,projection.session.snapshot['checkpoint'],'foreign-unbound') + self.assertEqual(self.store.snapshot(),canonical) + + def test_deferred_sql_failure_retains_snapshot_receipt_and_explicit_cold_replay_rebinds(self): + self.db.execute("CREATE FUNCTION fixture_refuse_source() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'controlled source SQL failure' USING ERRCODE='23514'; END; $$") + self.db.execute('CREATE CONSTRAINT TRIGGER fixture_source_failure AFTER INSERT ON workspace_projects DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION fixture_refuse_source()') + with self.assertRaises(WorkbenchError) as error:self.register() + self.assertEqual(error.exception.code,'projection_unavailable') + self.assertTrue(error.exception.receipt['committed']) + canonical=self.store.snapshot();original=operation_records(canonical['records'])['register-source']['result'] + self.assertEqual(self.db.execute('SELECT count(*) FROM workspace_projects').fetchone()[0],0) + projection,adapter=self.project('cold-after-sql-failure',{'fixture':self.source_root}) + self.assertEqual(self.register(adapter,checkpoint='0'*64),original) + self.assertEqual(self.store.snapshot(),canonical) + self.assertEqual(projection.sources.read_snapshot_manifest(original['project_id'],original['manifest_sha256'])[1],self.manifest_raw) + + def test_reference_page_measures_only_selected_current_data_without_changing_registration(self): + self.manifest['data'].extend([dict(id='missing',label='Missing source',path='missing.zip',sha256='0'*64), + dict(id='later',label='Unselected source',path='never-read.bin',sha256='1'*64)]) + self.manifest['recipes']=[dict(id='script',label='Never inspect or run',path='never-run.py')] + self.manifest_raw=json.dumps(self.manifest,ensure_ascii=False).encode() + (self.source_root/'source.json').write_bytes(self.manifest_raw) + registration=self.register();snapshot={key:registration[key] for key in ('origin_catalog_id','project_id','manifest_sha256')} + canonical=self.store.snapshot() + with patch.object(self.projection.sources,'_inspect',wraps=self.projection.sources._inspect) as inspected, \ + patch.object(self.projection.sources,'_get',side_effect=AssertionError('No whole-project inspection')), \ + patch('extension_contract.catalog',side_effect=AssertionError('No extension callbacks')), \ + patch('project_activity.activity',side_effect=AssertionError('No activity callbacks')): + page=self.adapter.references(snapshot,offset=0,limit=1) + self.assertEqual((page['total'],page['offset'],page['limit']),(3,0,1)) + self.assertEqual(len(page['items']),1) + self.assertEqual(page['items'][0]['observed_sha256'],self.digest) + self.assertEqual(page['items'][0]['integrity'],'verified') + self.assertEqual(page['items'][0]['label'],'Authored native fields') + missing=self.adapter.references(snapshot,offset=1,limit=1) + self.assertEqual(missing['items'][0]['status'],'missing') + self.assertEqual([call.args[1]['id'] for call in inspected.call_args_list],['fields','missing']) + projection,adapter=self.project('cold-references',{'fixture':self.source_root}) + with patch.object(projection.sources,'_read',side_effect=AssertionError('No I/O before explicit binding')): + with self.assertRaises(WorkbenchError) as error:adapter.references(snapshot,limit=1) + self.assertEqual(error.exception.code,'unavailable') + self.assertEqual(self.store.snapshot(),canonical) diff --git a/tests/test_project_store.py b/tests/test_project_store.py new file mode 100644 index 00000000..9c1c6271 --- /dev/null +++ b/tests/test_project_store.py @@ -0,0 +1,535 @@ +"""Canonical checkpoint guarantees on real POSIX files and separate processes.""" +import copy +import errno +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import project_store as store_module +from project_store import ProjectStore, ProjectStoreError + + +def sha(raw): + return hashlib.sha256(raw).hexdigest() + + +def body(value): + return sha(json.dumps(value, sort_keys=True).encode()) + + +def record(identity='a', text='Original authored text\n漢字'): + return dict(family='workbench_text', id=identity, + document=dict(format='workbench_text_v1', text=text, + history=[dict(note='Unique annotation', reviewed=False)])) + + +def canonical(value): + return (json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2, + allow_nan=False) + '\n').encode() + + +class ProjectStoreTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) / 'project' + self.store = ProjectStore.create(self.root, 'Authored project', 'new', body('new')) + self.addCleanup(lambda: self.store.close()) + + def commit(self, records=None, key='edit'): + records = [record()] if records is None else records + return self.store.commit(records, self.store.snapshot()['checkpoint'], key, body(records)) + + def reopen(self, root=None): + self.store.close() + self.store = ProjectStore.open(root or self.root) + return self.store.snapshot() + + def assert_code(self, code, fn, *args, **kwargs): + with self.assertRaises(ProjectStoreError) as caught: + fn(*args, **kwargs) + self.assertEqual(caught.exception.code, code) + return caught.exception + + def bytes_tree(self): + return {str(p.relative_to(self.root)): p.read_bytes() + for p in self.root.rglob('*') if p.is_file()} + + def manifest(self): + return json.loads((self.root / '.tuldok/project.json').read_text()) + + def write_manifest(self, value): + value.pop('checkpoint', None) + value['checkpoint'] = sha(canonical(value)) + (self.root / '.tuldok/project.json').write_bytes(canonical(value)) + + def test_create_commit_save_cold_reopen_and_independent_snapshots(self): + initial = self.store.snapshot() + self.assertEqual(initial['records'], []) + self.assertEqual(len(initial['project_id']), 32) + values = [record('b'), record('a', 'Notes preserve \r\n authored values')] + committed = self.commit(values) + self.assertEqual([r['id'] for r in committed['records']], ['a', 'b']) + before = self.bytes_tree() + self.assertEqual(self.store.save(committed['checkpoint']), committed) + self.assertEqual(before, self.bytes_tree()) + self.assertEqual(self.reopen(), committed) + values[0]['document']['text'] = 'Unchecked caller mutation' + committed['records'][0]['document']['text'] = 'Unchecked returned mutation' + self.assertEqual(self.store.snapshot()['records'][0]['document']['text'], 'Notes preserve \r\n authored values') + + def test_fresh_copy_and_moved_folder_keep_identity_and_values(self): + committed = self.commit() + self.store.close() + copied = Path(self.tmp.name) / 'clone' + shutil.copytree(self.root, copied) + moved = Path(self.tmp.name) / 'moved' + self.root.rename(moved) + with ProjectStore.open(copied) as other: + self.assertEqual(other.snapshot(), committed) + self.assertEqual(other.save(committed['checkpoint']), committed) + self.assertEqual(self.reopen(moved), committed) + + def test_new_never_overwrites_existing_directory_or_file(self): + before = self.bytes_tree() + self.assert_code('invalid', ProjectStore.open, 42) + self.assert_code('invalid', ProjectStore.create, 42, 'Other', 'new', body('new')) + self.assert_code('conflict', ProjectStore.create, self.root, 'Other', 'new', body('new')) + self.assertEqual(self.bytes_tree(), before) + destination = Path(self.tmp.name) / 'existing' + destination.write_bytes(b'Untouched') + self.assert_code('conflict', ProjectStore.create, destination, 'Other', 'new', body('new')) + self.assertEqual(destination.read_bytes(), b'Untouched') + + def test_replay_reconciles_before_stale_check_and_binds_body_and_values(self): + old = self.store.snapshot()['checkpoint'] + records = [record()] + committed = self.commit(records) + before = self.bytes_tree() + self.assertEqual(self.store.commit(records, old, 'edit', body(records)), committed) + self.assertEqual(self.store.reconcile('edit', body(records)), committed) + self.assertIsNone(self.store.reconcile('not-submitted', body(None))) + self.assert_code('conflict', self.store.reconcile, 'edit', body('changed')) + self.assert_code('conflict', self.store.commit, [record(text='changed')], old, 'edit', body(records)) + self.assertEqual(self.bytes_tree(), before) + self.reopen() + self.assertEqual(self.store.reconcile('edit', body(records)), committed) + self.assertEqual(self.store.reconcile('new', body('new')), committed) + + def test_colon_request_keys_roundtrip_reconcile_and_reject_changed_body(self): + before = self.store.snapshot()['checkpoint'] + records = [record()] + committed = self.store.commit(records, before, 'edit:fixture', body(records)) + canonical = self.bytes_tree() + self.assertEqual(self.store.commit(records, before, 'edit:fixture', body(records)), committed) + self.assertEqual(self.reopen(), committed) + self.assertEqual(self.store.reconcile('edit:fixture', body(records)), committed) + self.assert_code('conflict', self.store.reconcile, 'edit:fixture', body('different input')) + self.assert_code('conflict', self.store.commit, records, before, 'edit:fixture', body('different input')) + self.assertEqual(self.bytes_tree(), canonical) + + def test_stale_checkpoint_cannot_commit_or_save(self): + old = self.store.snapshot()['checkpoint'] + self.commit() + before = self.bytes_tree() + self.assert_code('stale', self.store.commit, [record(text='lost update')], old, 'late', body('late')) + self.assert_code('stale', self.store.save, old) + self.assertEqual(before, self.bytes_tree()) + + def test_second_process_writer_is_nonblocking_and_can_open_after_close(self): + code = """ +import sys +from project_store import ProjectStore, ProjectStoreError +try: + with ProjectStore.open(sys.argv[1]) as store: + print(store.snapshot()['project_id']) +except ProjectStoreError as exc: + print(exc.code) + sys.exit(7) +""" + child = subprocess.run([sys.executable, '-c', code, str(self.root)], + cwd=Path(store_module.__file__).parent, capture_output=True, text=True, timeout=10) + self.assertEqual((child.returncode, child.stdout.strip()), (7, 'busy'), child.stderr) + self.store.close() + child = subprocess.run([sys.executable, '-c', code, str(self.root)], + cwd=Path(store_module.__file__).parent, capture_output=True, text=True, timeout=10) + self.assertEqual(child.returncode, 0, child.stderr) + self.assertEqual(len(child.stdout.strip()), 32) + + def test_same_process_duplicate_and_symlink_root_do_not_evade_writer_lock(self): + self.assert_code('busy', ProjectStore.open, self.root) + alias = Path(self.tmp.name) / 'alias' + alias.symlink_to(self.root, target_is_directory=True) + self.assert_code('invalid', ProjectStore.open, alias) + self.assert_code('invalid', ProjectStore.create, alias / 'new', 'Other', 'new', body('new')) + self.assert_code('invalid', ProjectStore.open, self.root / '..' / 'project') + + def test_replaced_physical_root_or_metadata_directory_is_rejected(self): + displaced = self.root.with_name('displaced') + self.root.rename(displaced) + self.root.mkdir() + self.assert_code('stale', self.store.snapshot) + self.root.rmdir() + displaced.rename(self.root) + meta = self.root / '.tuldok' + meta.rename(self.root / '.old-tuldok') + meta.mkdir() + self.assert_code('stale', self.store.snapshot) + + def test_changed_checkpoint_is_rejected_before_mutation(self): + initial = self.store.snapshot() + manifest = self.manifest() + manifest['name'] = 'Changed outside open writer' + self.write_manifest(manifest) + self.assert_code('stale', self.store.snapshot) + self.assert_code('stale', self.store.commit, [record()], initial['checkpoint'], 'edit', body('edit')) + self.assertEqual(self.reopen()['name'], 'Changed outside open writer') + + def test_missing_or_changed_canonical_record_never_becomes_empty_project(self): + self.commit() + entry = self.manifest()['inventory'][0] + path = self.root / '.tuldok' / entry['path'] + raw = path.read_bytes() + path.write_bytes(raw.replace(b'Unique', b'Broken')) + self.assert_code('stale', self.store.snapshot) + self.store.close() + self.assert_code('invalid', ProjectStore.open, self.root) + path.unlink() + self.assert_code('unavailable', ProjectStore.open, self.root) + + def test_symlinks_specials_and_hardlink_aliases_are_rejected(self): + self.commit() + path = self.root / '.tuldok' / self.manifest()['inventory'][0]['path'] + raw = path.read_bytes() + other = Path(self.tmp.name) / 'other.json' + other.write_bytes(raw) + self.store.close() + path.unlink() + path.symlink_to(other) + self.assert_code('invalid', ProjectStore.open, self.root) + path.unlink() + os.mkfifo(path) + self.assert_code('invalid', ProjectStore.open, self.root) + path.unlink() + os.link(other, path) + self.assert_code('invalid', ProjectStore.open, self.root) + path.unlink() + path.write_bytes(raw) + metadata = self.root / '.tuldok/project.json' + os.link(metadata, Path(self.tmp.name) / 'checkpoint-alias') + self.assert_code('invalid', ProjectStore.open, self.root) + + def test_inventory_cannot_replace_root_path_or_namespace(self): + self.commit() + self.store.close() + original = self.manifest() + for field, value in [('path', '../escape.json'), ('path', '/tmp/escape.json'), + ('namespace', 'a' * 32), ('extra', 'unrecognized')]: + with self.subTest(field=field, value=value): + manifest = copy.deepcopy(original) + manifest['inventory'][0][field] = value + self.write_manifest(manifest) + self.assert_code('invalid', ProjectStore.open, self.root) + + def test_malformed_duplicate_nonfinite_oversized_and_future_input(self): + self.store.close() + path = self.root / '.tuldok/project.json' + original = path.read_bytes() + for raw in [b'{"format":"x","format":"y"}', b'\xff', b'{', + b'{"x": NaN}', b'{"x":' + b'1' * 5000 + b'}', b'[]\n', original.replace(b' ', b' ')]: + with self.subTest(raw=raw[:30]): + path.write_bytes(raw) + self.assert_code('invalid', ProjectStore.open, self.root) + manifest = json.loads(original) + manifest['format'] = 'tuldok_project_v2' + path.write_bytes(canonical(manifest)) + self.assert_code('unsupported', ProjectStore.open, self.root) + path.write_bytes(original) + with patch.object(store_module, 'MAX_MANIFEST_BYTES', 20): + self.assert_code('invalid', ProjectStore.open, self.root) + + def test_closed_document_envelopes_and_supported_family_format(self): + checkpoint = self.store.snapshot()['checkpoint'] + invalid = [dict(record(), extra='x'), dict(record(), family='../escape'), + dict(record(), id='/absolute'), dict(record(), document=[]), + dict(record(), document={'format': 'workbench_text_v1', 'x': float('inf')}), + dict(record(), document={'format': 'workbench_text_v1', 'x': '\ud800'}), + dict(record(), document={'format': 'workbench_text_v1', 'x': 2 ** 63})] + for value in invalid: + with self.subTest(value=repr(value)): + self.assert_code('invalid', self.store.commit, [value], checkpoint, 'bad', body('bad')) + for value in [dict(record(), family='access_credentials'), + dict(record(), document={'format': 'workbench_text_v2'}), + dict(record(), document={'format': 'unknown_v1'})]: + self.assert_code('unsupported', self.store.commit, [value], checkpoint, 'bad', body('bad')) + self.assert_code('invalid', self.store.commit, [record(), record()], checkpoint, 'bad', body('bad')) + + def test_future_record_format_rejected_on_open(self): + self.commit() + self.store.close() + manifest = self.manifest() + manifest['inventory'][0]['format'] = 'workbench_text_v2' + self.write_manifest(manifest) + self.assert_code('unsupported', ProjectStore.open, self.root) + + def test_unreferenced_records_and_script_sentinels_are_inert(self): + script = self.root / 'execute.py' + script.write_text('raise RuntimeError("Never execute on Open or Save")\n') + orphan = self.root / '.tuldok/records/orphan.json' + orphan.write_bytes(b'Malformed orphan does not confer authority') + with patch.object(subprocess, 'Popen', side_effect=AssertionError('No execution')): + before = self.bytes_tree() + snapshot = self.reopen() + self.store.save(snapshot['checkpoint']) + self.assertEqual(before, self.bytes_tree()) + + def test_prior_immutable_revisions_are_retained_after_replace_and_delete(self): + self.commit() + first = self.root / '.tuldok' / self.manifest()['inventory'][0]['path'] + raw = first.read_bytes() + self.commit([record(text='Changed note')], 'change') + self.commit([], 'delete') + self.assertEqual(first.read_bytes(), raw) + self.assertEqual(self.reopen()['records'], []) + + def test_write_failure_keeps_old_checkpoint_and_reopen(self): + old = self.store.snapshot() + with patch.object(store_module.os, 'write', side_effect=OSError(errno.ENOSPC, 'disk full')): + self.assert_code('unavailable', self.commit) + self.assertEqual(self.reopen(), old) + self.assertIsNone(self.store.reconcile('edit', body([record()]))) + self.assertEqual(self.commit()['records'], [record()], 'An incomplete staging object must not poison retries') + + def test_checkpoint_file_sync_failure_leaves_old_checkpoint(self): + old = self.store.snapshot() + original = store_module._write_file + def fail_checkpoint(directory, name, raw): + if name.startswith('.checkpoint-'): + raise OSError(errno.EIO, 'checkpoint sync failure') + return original(directory, name, raw) + with patch.object(store_module, '_write_file', side_effect=fail_checkpoint): + self.assert_code('unavailable', self.commit) + self.assertEqual(self.reopen(), old) + self.assertTrue(list((self.root / '.tuldok/records').rglob('*.json'))) + self.assertEqual(self.commit()['records'], [record()]) + + def test_failure_after_checkpoint_replacement_is_unknown_then_reconciled(self): + original = store_module.os.fsync + replace = store_module.os.replace + published = False + def after_replace(*args, **kwargs): + nonlocal published + replace(*args, **kwargs) + published = True + def fail_meta(fd): + if published and fd == self.store._meta_fd: + raise OSError(errno.EIO, 'directory sync failure') + return original(fd) + with patch.object(store_module.os, 'fsync', side_effect=fail_meta), patch.object( + store_module.os, 'replace', side_effect=after_replace): + self.assert_code('unknown', self.commit) + self.assert_code('unknown', self.store.snapshot) + reopened = self.reopen() + self.assertEqual(reopened['records'], [record()]) + self.assertEqual(self.store.reconcile('edit', body([record()])), reopened) + + def test_new_final_destination_is_absent_until_complete_atomic_publication(self): + target = Path(self.tmp.name) / 'atomic-new' + publish = store_module._publish_directory + # A staged directory remains physically locked, so inspect bytes directly. + def inspect_bytes(parent_fd, staging, destination): + self.assertFalse(target.exists()) + manifest = json.loads((Path(self.tmp.name) / staging / '.tuldok/project.json').read_text()) + self.assertEqual(manifest['format'], 'tuldok_project_v1') + self.assertEqual(manifest['receipts'][0]['request_key'], 'atomic') + publish(parent_fd, staging, destination) + with patch.object(store_module, '_publish_directory', side_effect=inspect_bytes): + with ProjectStore.create(target, 'Atomic New', 'atomic', body('atomic')) as created: + self.assertEqual(created.path, target) + self.assertEqual(created.snapshot()['records'], []) + self.assertTrue(target.is_dir()) + + def test_new_gitignore_is_durable_before_publication_and_survives_cold_copy(self): + expected = b'/artifacts/\n/.tuldok-local/\n/.venv/\n/__pycache__/\n/PGDATA/\n/output/\n' + target = Path(self.tmp.name) / 'ignored-new' + publish = store_module._publish_directory + fsync = store_module.os.fsync + synced = set() + def observe_sync(fd): + fsync(fd) + info = os.fstat(fd) + synced.add((info.st_dev, info.st_ino)) + def inspect_before_publish(parent_fd, staging, destination): + staged = Path(self.tmp.name) / staging + self.assertFalse(target.exists()) + self.assertEqual((staged / '.gitignore').read_bytes(), expected) + for path in (staged / '.gitignore', staged): + info = path.stat() + self.assertIn((info.st_dev, info.st_ino), synced) + publish(parent_fd, staging, destination) + self.assertEqual((target / '.gitignore').read_bytes(), expected) + with patch.object(store_module.os, 'fsync', side_effect=observe_sync), patch.object( + store_module, '_publish_directory', side_effect=inspect_before_publish): + with ProjectStore.create(target, 'Ignore defaults', 'ignore', body('ignore')) as created: + checkpoint = created.snapshot() + copied = Path(self.tmp.name) / 'ignored-copy' + shutil.copytree(target, copied) + with ProjectStore.open(copied) as reopened: + self.assertEqual(reopened.snapshot(), checkpoint) + reopened.save(checkpoint['checkpoint']) + self.assertEqual((copied / '.gitignore').read_bytes(), expected) + self.assertFalse((target / '.git').exists()) + self.assertFalse((copied / '.git').exists()) + + def test_new_publish_race_does_not_overwrite_an_existing_destination(self): + target = Path(self.tmp.name) / 'raced-new' + publish = store_module._publish_directory + def race(parent_fd, staging, destination): + target.mkdir() + (target / 'owned.txt').write_bytes(b'Existing owner') + publish(parent_fd, staging, destination) + with patch.object(store_module, '_publish_directory', side_effect=race): + self.assert_code('conflict', ProjectStore.create, target, 'New', 'race', body('race')) + self.assertEqual(list(target.iterdir()), [target / 'owned.txt']) + self.assertEqual((target / 'owned.txt').read_bytes(), b'Existing owner') + + def test_interrupted_new_retains_staging_and_never_exposes_partial_final_root(self): + target = Path(self.tmp.name) / 'interrupted-new' + code = r''' +import os,sys +import project_store as m +def interrupt(*args): os._exit(74) +m._publish_directory=interrupt +m.ProjectStore.create(sys.argv[1],'Interrupted','new','a'*64) +''' + child = subprocess.run([sys.executable, '-c', code, str(target)], + cwd=Path(store_module.__file__).parent, capture_output=True, text=True, timeout=10) + self.assertEqual(child.returncode, 74, child.stderr) + self.assertFalse(target.exists()) + staging = list(Path(self.tmp.name).glob('.tuldok-new-*')) + self.assertEqual(len(staging), 1) + with ProjectStore.open(staging[0]) as retained: + self.assertIsNotNone(retained.reconcile('new', 'a' * 64)) + with ProjectStore.create(target, 'Retry explicit new', 'retry', body('retry')) as fresh: + self.assertEqual(fresh.snapshot()['records'], []) + + def test_new_parent_sync_failure_reports_unknown_and_reopens_complete_destination(self): + target = Path(self.tmp.name) / 'new-unknown' + publish = store_module._publish_directory + fsync = store_module.os.fsync + published = False + def after_publish(*args): + nonlocal published + publish(*args) + published = True + def fail_parent(fd): + if published: + raise OSError(errno.EIO, 'new parent sync failure') + return fsync(fd) + with patch.object(store_module, '_publish_directory', side_effect=after_publish), patch.object( + store_module.os, 'fsync', side_effect=fail_parent): + self.assert_code('unknown', ProjectStore.create, target, 'Unknown New', 'unknown-new', body('unknown')) + with ProjectStore.open(target) as reopened: + self.assertIsNotNone(reopened.reconcile('unknown-new', body('unknown'))) + self.assertEqual(reopened.snapshot()['records'], []) + + def test_anchored_open_create_nested_and_replaced_configured_root(self): + configured = Path(self.tmp.name) / 'configured' + configured.mkdir() + (configured / 'nested').mkdir() + trusted = os.open(configured, store_module.DIR_FLAGS) + self.addCleanup(lambda: os.close(trusted)) + with ProjectStore.create_at(trusted, 'nested/project', 'Anchored', 'anchored', body('anchored'), + root_path=configured) as project: + expected = project.snapshot() + with ProjectStore.open_at(trusted, 'nested/project', root_path=configured) as project: + self.assertEqual(project.snapshot(), expected) + displaced = configured.with_name('moved-configured') + configured.rename(displaced) + victim = Path(self.tmp.name) / 'victim' + victim.mkdir() + configured.symlink_to(victim, target_is_directory=True) + self.assert_code('invalid', project.snapshot) + self.assert_code('invalid', ProjectStore.open_at, trusted, 'nested/project', root_path=configured) + self.assert_code('invalid', ProjectStore.create_at, trusted, 'unexpected', 'New', 'no', body('no'), + root_path=configured) + self.assertEqual(list(victim.iterdir()), []) + configured.unlink() + configured.mkdir() + self.assert_code('stale', ProjectStore.create_at, trusted, 'unexpected', 'New', 'no', body('no'), + root_path=configured) + self.assertFalse((configured / 'unexpected').exists()) + self.assertFalse((displaced / 'unexpected').exists()) + + def test_anchored_descendant_symlink_and_traversal_never_write_target(self): + configured = Path(self.tmp.name) / 'configured' + configured.mkdir() + victim = Path(self.tmp.name) / 'victim' + victim.mkdir() + (configured / 'alias').symlink_to(victim, target_is_directory=True) + trusted = os.open(configured, store_module.DIR_FLAGS) + self.addCleanup(lambda: os.close(trusted)) + for relative in ('alias/project', '../victim/project', '/absolute', 'a//b', 'a/./b'): + self.assert_code('invalid', ProjectStore.create_at, trusted, relative, 'New', 'new', body('new'), + root_path=configured) + self.assertEqual(list(victim.iterdir()), []) + + def test_real_process_kills_across_publication_windows_reopen_coherently(self): + self.store.close() + original = self.bytes_tree() + code = r''' +import hashlib,json,os,sys +import project_store as m +from project_store import ProjectStore +root, window = sys.argv[1:] +store = ProjectStore.open(root) +records=[{'family':'workbench_text','id':'a','document':{'format':'workbench_text_v1','text':'acknowledged-or-old'}}] +key='crash-edit' +digest=hashlib.sha256(json.dumps(records,sort_keys=True).encode()).hexdigest() +write=m._write_file +replace=m.os.replace +publish=store._publish +def crash_write(directory,name,raw): + write(directory,name,raw) + if (window=='record' and not name.startswith('.checkpoint-')) or (window=='checkpoint' and name.startswith('.checkpoint-')): + os._exit(73) +def crash_replace(*args,**kwargs): + replace(*args,**kwargs) + if window=='replace': os._exit(73) +def crash_publish(*args,**kwargs): + publish(*args,**kwargs) + if window=='durable': os._exit(73) +m._write_file=crash_write +m.os.replace=crash_replace +store._publish=crash_publish +store.commit(records,store.snapshot()['checkpoint'],key,digest) +raise AssertionError('Fault window was not reached') +''' + for window in ('record', 'checkpoint', 'replace', 'durable'): + with self.subTest(window=window): + destination = Path(self.tmp.name) / ('kill-' + window) + shutil.copytree(self.root, destination) + child = subprocess.run([sys.executable, '-c', code, str(destination), window], + cwd=Path(store_module.__file__).parent, capture_output=True, text=True, timeout=10) + self.assertEqual(child.returncode, 73, child.stderr) + with ProjectStore.open(destination) as reopened: + snapshot = reopened.snapshot() + committed = window in ('replace', 'durable') + self.assertEqual(bool(snapshot['records']), committed) + records = [dict(family='workbench_text', id='a', document=dict( + format='workbench_text_v1', text='acknowledged-or-old'))] + self.assertEqual(reopened.reconcile('crash-edit', body(records)) is not None, committed) + reopened.save(snapshot['checkpoint']) + self.assertEqual(self.bytes_tree(), original) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_project_study_execution.py b/tests/test_project_study_execution.py new file mode 100644 index 00000000..321cae09 --- /dev/null +++ b/tests/test_project_study_execution.py @@ -0,0 +1,691 @@ +"""Current Study epochs and retained accounting without imported runtime rows. + +Control tests use real disposable PostgreSQL and the actual canonical owners. +Host execution cases, when added, run only under a coordinated source freeze. +""" +import copy +import hashlib +import json +import os +from pathlib import Path +from types import SimpleNamespace +import tempfile +import threading +import unittest +from unittest.mock import patch +import uuid + +from access import Access +from auth_helpers import ExplicitOwnerFixture +from experiments import Experiments, validate_retained_reservation +from metadata import Metadata, connection_info, CATALOG_LOCAL_TIMEOUT +from project_history import capture_run, declarations +from project_projection import ProjectProjection +from project_session import operation_records +from project_store import ProjectStore +from project_study_execution import ProjectStudyExecution, validate_operation_result +from test_project_history import legacy_run, policy, study, trial, Materials, ORIGIN +from workbench import WorkbenchError, encode + + +class CandidatePreparationContractTests(unittest.TestCase): + def test_intent_binds_exact_candidate_and_study_revision_without_local_authority(self): + from project_history import HistoryFacts + from pipeline import digest + candidate=dict(id='6'*32,study_id='3'*32,diagnostic='Exact authored selection') + fact=dict(format='project_candidate_preparation_intent_v1',origin_catalog_id=ORIGIN,study_id='3'*32, + study_revision=4,candidate=dict(kind='candidate',catalog_id=ORIGIN,id=candidate['id'],sha256=digest(candidate)), + actor_id='5'*32,request_sha256='a'*64,root='explicit',directory='prepared',runtime_snapshot_sha256='b'*64) + facts=HistoryFacts();facts.candidates[(ORIGIN,candidate['id'])]=dict(candidate=candidate) + facts.studies[(ORIGIN,'3'*32)]=dict(revisions=[dict(revision=4)]) + operation='study_execution.prepare_package_intent' + with patch('project_study_execution._facts',return_value=facts): + self.assertTrue(validate_operation_result(operation,fact,[])) + for change in (dict(fact,study_revision=5),dict(fact,candidate=dict(fact['candidate'],sha256='c'*64)), + dict(fact,current_local=True),dict(fact,grant={'token':'secret'})): + with self.subTest(change=change),self.assertRaises(WorkbenchError): + validate_operation_result(operation,change,[]) + + def test_archived_input_facade_requires_new_disclosure_and_never_constructs_runs(self): + from project_history import ProjectHistory,HistoryFacts + from project_study_execution import _PreparationRuns + run=legacy_run();run['exposure_events']=[{'historical':'This fact grants nothing'}] + facts=HistoryFacts();facts.runs[(ORIGIN,run['id'])]=dict(origin_catalog_id=ORIGIN,run=run,payloads=[]) + session=SimpleNamespace(access=SimpleNamespace(require=lambda *a,**k:None),artifacts=None, + snapshot=dict(records=[],project_id=ORIGIN)) + history=ProjectHistory(session,materials=Materials());history._facts=lambda:facts + adapter=SimpleNamespace(history=history,origin=ORIGIN,session=session) + facade=_PreparationRuns(adapter) + with patch('runs.Runs',side_effect=AssertionError('Archived package input allocated Runs')): + with self.assertRaisesRegex(WorkbenchError,'Current explicit disclosure'):facade._load(run['id']) + seen=[];history.authorize_disclosure=lambda selected,population:seen.append((selected['id'],population)) + self.assertEqual(facade._load(run['id']),run) + self.assertEqual(seen,[(run['id'],'unknown')]) + self.assertFalse(hasattr(facade,'_admit'));self.assertFalse(hasattr(facade,'_start')) + + def test_native_directory_write_follows_ack_hook_and_project_failure_preserves_it(self): + import candidate_packages as packages + from test_candidate_packages import fixture + manifest,blobs=fixture();package=packages.create_package(manifest,blobs) + descriptor=packages.decode(blobs['runtime.json'])['snapshot'] + candidate=dict(ref=manifest['candidate'],candidate=packages.decode(blobs['candidate-decision.json'])) + with tempfile.TemporaryDirectory(prefix='tuldok-package-publication-') as directory: + root=Path(directory);root_fd=os.open(root,os.O_RDONLY|os.O_DIRECTORY) + self.addCleanup(os.close,root_fd);events=[] + def opened(body): + self.assertTrue((root/'prepared'/'project.json').is_file()) + events.append('source-read');raise RuntimeError('Controlled failure after directory publication') + access=SimpleNamespace(require=lambda *a,**k:SimpleNamespace(actor_id='5'*32)) + dataset=SimpleNamespace(db=SimpleNamespace(in_transaction=False,dataset_id=descriptor['catalog_id']), + path=root,projects=SimpleNamespace(roots={'explicit':(root_fd,[os.fstat(root_fd).st_dev,os.fstat(root_fd).st_ino])},open=opened), + runs=SimpleNamespace(artifact=lambda *args:(package,'application/zip'))) + studies=SimpleNamespace(db=dataset.db,access=access,dataset=dataset,lock=threading.RLock(), + _receipt=lambda *args:None,_study=lambda _:dict(revision=1),_expected=lambda *args:None, + candidate=lambda _:candidate) + def before_write(): + self.assertFalse((root/'prepared').exists());events.append('acknowledged-start') + publication=SimpleNamespace(runtime_snapshot=descriptor,before_write=before_write) + body=dict(request_key='controlled-prepare',revision=1,candidate_id=manifest['candidate']['id'], + root='explicit',directory='prepared',runtime_snapshot_sha256=descriptor['manifest_sha256'], + package_run_id='7'*32,package_sha256=packages.sha(package)) + with self.assertRaisesRegex(RuntimeError,'after directory publication'): + packages._prepare_operation(studies,candidate['candidate']['study_id'],body,'verify',publication=publication) + self.assertEqual(events,['acknowledged-start','source-read']) + self.assertTrue((root/'prepared'/'project.json').is_file()) + + +class StudyExecutionCodecTests(unittest.TestCase): + def test_admission_has_only_exact_reservation_and_references(self): + value=trial('9'*32,'3'*32)['document']['trial'];composition=Materials().recipe(value['material']) + charge=composition['material']['resource_envelope'] + reservation=dict(id='e'*32,study_id='3'*32,admission_epoch=1,request_key='admission-0001',actor_id='5'*32, + trial_id=value['id'],parent_run_id=None,run_id='7'*32,request_sha256='c'*64, + material_sha256=value['material']['sha256'],envelope=charge,state='reserved',settled=None) + run=dict(id='7'*32,actor_id='5'*32,request_sha256='c'*64,parent_run_id=None, + approval_material_sha256=value['material']['sha256']) + validate_retained_reservation(reservation,trial=value,composition=composition,run=run) + result=dict(format='project_study_admission_v1',origin_catalog_id=ORIGIN, + study_id=reservation['study_id'],trial_id=value['id'],run_id=reservation['run_id'],reservation=reservation) + self.assertTrue(validate_operation_result('study_execution.admit',result)) + for change in ({'run':{'token':'secret'}},{'run_id':'8'*32}): + with self.subTest(change=change),self.assertRaises(WorkbenchError): + validate_operation_result('study_execution.admit',dict(result,**change)) + changed=copy.deepcopy(result);changed['reservation']['envelope']['secret']='secret' + with self.assertRaises(WorkbenchError):validate_operation_result('study_execution.admit',changed) + + def test_approval_canonical_fact_is_inert_and_never_contains_a_local_grant_wrapper(self): + fact=dict(format='project_study_approval_v1',origin_catalog_id=ORIGIN,study_id='3'*32, + trial_id='9'*32,approval_id='a'*32,material_sha256='b'*64, + consumer_actor_id='c'*32,approver_actor_id='d'*32,created_at=1,expires_at=601) + self.assertTrue(validate_operation_result('study_execution.approve',fact)) + for change in ({'current_local':True},{'grant':{'token':'secret'}},{'expires_at':4000}): + with self.subTest(change=change),self.assertRaises(WorkbenchError): + validate_operation_result('study_execution.approve',dict(fact,**change)) + + def test_project_reservation_uses_retained_material_and_shared_proof_validation(self): + value=trial('9'*32,'3'*32)['document']['trial'];composition=Materials().recipe(value['material']) + reservation=dict(id='e'*32,study_id='3'*32,admission_epoch=1,request_key='admission-0001',actor_id='5'*32, + trial_id=value['id'],parent_run_id=None,run_id='7'*32,request_sha256='c'*64, + material_sha256=value['material']['sha256'],envelope=composition['material']['resource_envelope'], + state='reserved',settled=None) + run=dict(id='7'*32,actor_id='5'*32,request_sha256='c'*64,parent_run_id=None, + approval_material_sha256=value['material']['sha256']) + row={key:reservation[key] for key in ('id','study_id','actor_id','request_key','request_sha256','trial_id','run_id')} + row['document']=json.dumps(reservation) + owner=Experiments.__new__(Experiments);owner.trial=lambda _:value;owner._run_fact=lambda _:run + owner.dataset=SimpleNamespace(project_study_execution=SimpleNamespace(materials=Materials()), + pipelines=SimpleNamespace(recipe_material=lambda _:(_ for _ in ()).throw( + AssertionError('Project settlement crossed into host-authorized material owner')))) + self.assertEqual(owner._reservation(row),reservation) + changed=copy.deepcopy(reservation);changed['envelope']['retained_bytes']+=1 + row['document']=json.dumps(changed) + with self.assertRaises(WorkbenchError):owner._reservation(row) + + def test_approval_binds_exact_owner_audit_and_abandon_cannot_activate_pending_epoch(self): + from project_history import HistoryFacts, _audit + value=trial('9'*32,'3'*32)['document']['trial'] + fact=dict(format='project_study_approval_v1',origin_catalog_id=ORIGIN,study_id='3'*32, + trial_id=value['id'],approval_id='a'*32,material_sha256=value['material']['sha256'], + consumer_actor_id='c'*32,approver_actor_id='d'*32,created_at=1,expires_at=601) + details={key:fact[key] for key in ('trial_id','material_sha256','approval_id','consumer_actor_id','created_at','expires_at')} + audit=dict(actor_id=fact['approver_actor_id'],action='studies.approve',target=fact['study_id'], + details=details,result='success',occurred_at='1970-01-01T00:00:01+00:00') + _audit([audit]) + facts=HistoryFacts();facts.studies[(ORIGIN,fact['study_id'])]=dict(audit=[audit]) + facts.trials[(ORIGIN,value['id'])]=dict(trial=value) + # Supply only the already-decoded closure lookup; the actual binding + # policy and the common execution approval validator remain real. + with patch('project_study_execution._facts',return_value=facts): + self.assertTrue(validate_operation_result('study_execution.approve',fact,[])) + for field,changed in (('consumer_actor_id','e'*32),('approver_actor_id','e'*32), + ('expires_at',602),('material_sha256','f'*64)): + with self.subTest(field=field),self.assertRaises(WorkbenchError): + validate_operation_result('study_execution.approve',dict(fact,**{field:changed}),[]) + adapter=ProjectStudyExecution.__new__(ProjectStudyExecution) + adapter._dirty={'3'*32};adapter._revisions={'3'*32:{2:{'revision':2}}} + adapter._pending_epochs={'3'*32:99};adapter._epochs={'4'*32:2} + adapter._package_records=[] + adapter.abandon_pending() + self.assertEqual(adapter._epochs,{'4'*32:2}) + self.assertFalse(adapter._dirty);self.assertFalse(adapter._revisions);self.assertFalse(adapter._pending_epochs) + + def test_old_reservations_contribute_full_charges_but_are_not_local_children(self): + historical=dict(id='e'*32,run_id='7'*32,state='unknown',settled=None, + envelope=dict(attempt_slots=2,admitted_wall_seconds=70,retained_bytes=800,peak_staging_bytes=900)) + class ReadOnlyRows: + def execute(self,query,args=()): + if query.startswith('SELECT * FROM model_attempt_reservations'):return [] + raise AssertionError(query) + adapter=SimpleNamespace(historical_reservations=lambda _: [copy.deepcopy(historical)],local_run_ids=lambda:set()) + owner=Experiments.__new__(Experiments) + owner.db=ReadOnlyRows();owner.dataset=SimpleNamespace(project_study_execution=adapter, + runs=SimpleNamespace(_load=lambda _:(_ for _ in ()).throw(AssertionError('Historical Run reached live resolver')))) + values=owner._reservations('3'*32) + self.assertEqual(owner._totals(values),historical['envelope']) + self.assertEqual(owner._children_ceased(values),(True,False)) + self.assertEqual(values,[historical]) + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'),'Requires disposable PostgreSQL') +class ProjectStudyControlTests(unittest.TestCase): + def setUp(self): + self.temporary=tempfile.TemporaryDirectory(prefix='tuldok-study-execution-') + self.addCleanup(self.temporary.cleanup);self.root=Path(self.temporary.name) + self.databases=[];self.projections=[];self.addCleanup(self.cleanup) + host_db=self.metadata('host');self.host=SimpleNamespace(db=host_db,access=Access(host_db,host_db.path)) + self.owner=ExplicitOwnerFixture.bootstrap(self.host) + self.store=ProjectStore.create(self.root/'project','Current Study intent','study-fixture','f'*64) + self.addCleanup(self.store.close) + self.open_projection() + + def metadata(self,name,*,project_id=None): + kwargs={} if project_id is None else dict(dataset_id=project_id,projection_id=uuid.uuid4().hex) + db=Metadata(self.root/name,**kwargs);self.databases.append(db);return db + + def open_projection(self): + db=self.metadata('projection-'+uuid.uuid4().hex,project_id=self.store.snapshot()['project_id']) + with self.owner.bound(self.host),patch('runs.Runs',side_effect=AssertionError('Inert Study Open constructed Runs')): + projection=ProjectProjection(self.store,db,self.host.access,{}) + adapter=getattr(projection,'study_execution',None) + if adapter is None: + adapter=ProjectStudyExecution(projection,projection.execution);adapter.restore_authoring() + self.projections.append(projection);self.projection=projection;self.adapter=adapter + + def cleanup(self): + import psycopg + from psycopg import sql + for projection in reversed(self.projections): + try:projection.close() + except BaseException: + # No test here admits a worker; cleanup only this fixture's DB + # and artifact handles even after an asserted rollback failure. + projection.artifacts.close();projection.db.close() + for db in reversed(self.databases):db.close() + with psycopg.connect(connection_info(),autocommit=True) as connection: + for db in self.databases: + connection.execute(sql.SQL('DROP SCHEMA IF EXISTS {} CASCADE').format(sql.Identifier(db.schema))) + + def create_study(self): + body=dict(name='Persistent scientific intent',limits=dict(attempt_slots=3,admitted_wall_seconds=100, + retained_bytes=10000,peak_staging_bytes=10000),policy=policy()) + return self.projection.history.create_study(body,self.projection.session.snapshot['checkpoint'],'study-create-0001') + + def assert_no_runtime_authority(self): + for table in ('workspace_runs','model_attempt_reservations','access_admissions','access_execution_grants', + 'access_execution_consumptions','access_actors','access_credentials'): + self.assertEqual(self.projection.db.execute('SELECT count(*) FROM '+table).fetchone()[0],0,table) + self.assertIsNone(self.projection.execution._runs) + + def test_canonical_metadata_projects_before_background_read_and_continue_never_constructs_runs(self): + with self.owner.bound(self.host),patch('runs.Runs',side_effect=AssertionError('Continue constructed Runs')): + value=self.create_study() + self.assertEqual(self.adapter.studies._study(value['id']),value) + opened=self.adapter.continue_study(value['id'],dict(revision=1,admission_epoch=0), + self.projection.session.snapshot['checkpoint'],'study-continue-0001') + self.assertTrue(self.adapter.epoch_current(opened));self.assertEqual(opened['admission_epoch'],1) + saved=self.projection.history.save_study(value['id'],dict(revision=opened['revision'],name='New exact intent', + limits=opened['limits'],policy=opened['policy']),self.projection.session.snapshot['checkpoint'],'study-save-0001') + self.assertEqual(self.adapter.studies._study(value['id']),saved) + self.assertTrue(self.adapter.epoch_current(saved)) + self.assert_no_runtime_authority() + + def test_cold_open_keeps_epoch_fact_inert_and_requires_fresh_explicit_continue(self): + with self.owner.bound(self.host): + value=self.create_study() + opened=self.adapter.continue_study(value['id'],dict(revision=1,admission_epoch=0), + self.projection.session.snapshot['checkpoint'],'study-continue-0001') + checkpoint=self.projection.session.snapshot['checkpoint'] + self.projection.close();self.open_projection() + with self.owner.bound(self.host),patch.object(self.projection.execution,'_local_owner',side_effect=AssertionError('Historical epoch activated owner')): + self.assertEqual(self.adapter.studies._study(value['id']),opened) + self.assertFalse(self.adapter.epoch_current(opened));self.assert_no_runtime_authority() + before=self.projection.session.snapshot + with self.assertRaisesRegex(WorkbenchError,'historical Continue'): + self.adapter.continue_study(value['id'],dict(revision=1,admission_epoch=0),checkpoint,'study-continue-0001') + with self.assertRaisesRegex(WorkbenchError,'Explicitly continue'): + self.adapter.admit(value['id'],dict(revision=opened['revision'],admission_epoch=opened['admission_epoch'],trial_id='9'*32), + checkpoint,'new-attempt-0001') + self.assertEqual(self.projection.session.snapshot,before) + continued=self.adapter.continue_study(value['id'],dict(revision=opened['revision'],admission_epoch=opened['admission_epoch']), + checkpoint,'study-continue-0002') + self.assertEqual(continued['admission_epoch'],opened['admission_epoch']+1) + self.assertEqual(continued['limits'],opened['limits']);self.assertTrue(self.adapter.epoch_current(continued)) + self.assert_no_runtime_authority() + + def test_multiple_source_revisions_in_one_transaction_survive_and_rollback_does_not_activate_epoch(self): + with self.owner.bound(self.host): + value=self.create_study();before=self.projection.session.snapshot + with self.assertRaisesRegex(RuntimeError,'authored rollback'): + with self.projection.db.transaction(): + changed=copy.deepcopy(value);changed['name']='Must roll back' + self.adapter.studies._write(changed,'studies.save') + self.adapter._pending_epochs[value['id']]=99 + raise RuntimeError('authored rollback') + self.assertEqual(self.projection.session.snapshot,before) + self.assertFalse(self.adapter._dirty);self.assertFalse(self.adapter._epochs) + with self.projection.db.transaction(): + current=copy.deepcopy(value);current['name']='Observed revision two' + current=self.adapter.studies._write(current,'studies.save') + current['name']='Observed revision three' + current=self.adapter.studies._write(current,'studies.save') + doc=self.projection.history._facts().studies[(self.adapter.origin,value['id'])] + self.assertEqual([item['revision'] for item in doc['revisions']],[1,2,3]) + self.assertEqual([item['name'] for item in doc['revisions']], + ['Persistent scientific intent','Observed revision two','Observed revision three']) + self.assertEqual(doc['study'],current);self.assert_no_runtime_authority() + + +if __name__=='__main__':unittest.main() + + +# Reuse only the authored neutral fixture helpers, never its test methods. +import test_project_execution as _run_fixtures + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'),'Requires disposable PostgreSQL and coordinated host-source freeze') +class ProjectStudyCustodyTests(unittest.TestCase): + setUp=_run_fixtures.ProjectRunCustodyTests.setUp + metadata=_run_fixtures.ProjectRunCustodyTests.metadata + cleanup=_run_fixtures.ProjectRunCustodyTests.cleanup + checkpoint=_run_fixtures.ProjectRunCustodyTests.checkpoint + record=_run_fixtures.ProjectRunCustodyTests.record + await_drained=_run_fixtures.ProjectRunCustodyTests.await_drained + + def prepare_study(self): + from pipeline import digest + materials=self.projection.materials + body=dict(name='Captured neutral Study model',kind='captured_source', + task=dict(kind='task',catalog_id=self.db.dataset_id,id='neutral.bytes',revision=1, + sha256=digest({'format':'authored_neutral_task_v1'})), + specification={'format':'authored_neutral_model_v1'}, + source_selector=dict(project_id=self.manifest['id'],recipe_id='neutral', + expected_sha256=hashlib.sha256(self.definition_raw).hexdigest()), + preprocessing_policy={'kind':'identity'},output_policy={'kind':'exact_bytes'}) + draft=materials.mutate_definition('model_definition','models.create',None,body,self.checkpoint(), + 'study-model-create',lambda:self.projection.models.create(body)) + model=materials.publish_model(self.projection.models,draft['id'],{'revision':1},self.checkpoint(),'study-model-publish') + material=materials.prepare_recipe(self.projection.pipelines,self.graph['id'],self.graph['revision'],model['ref'], + self.checkpoint(),'study-recipe-prepare') + # The capture owner returns the immutable composition, never a plan. + composition=self.projection.materials.recipe(material['ref']) + envelope=composition['material']['resource_envelope'] + limits={key:value*3 for key,value in envelope.items()} + study_value=self.projection.history.create_study(dict(name='Current neutral Study',limits=limits,policy=policy()), + self.checkpoint(),'study-create') + trial_value=self.projection.history.prepare_trial_from_material(study_value['id'],dict(revision=1, + material=composition['ref'],replicate=0),self.checkpoint(),'study-trial-prepare') + ref=dict(catalog_id=self.db.dataset_id,trial_id=trial_value['id'],material_sha256=composition['ref']['sha256']) + intent=dict(ancestry='root',parent=None,baseline=None,cohort='authored neutral control', + hypothesis='Exact original Study intent survives a cold runtime',change_summary='No change', + target=dict(metric_id='diagnostic',metric_definition_sha256=None,direction='maximize',threshold=None, + acceptance='No scientific qualification claim')) + lineage=self.projection.history.record_lineage(dict(trial=ref,intent=intent),self.checkpoint(),'study-lineage') + return study_value['id'],trial_value,composition,envelope,lineage + + def test_cold_study_spent_budget_lineage_and_approval_remain_inert_while_fresh_attempt_is_current(self): + from project_projection import ProjectProjection + study_id,trial_value,composition,envelope,lineage=self.prepare_study() + # Keep this case within its declared custody budget plus the two + # bounded catalog waits for terminal publication and observation. + drain_timeout=envelope['admitted_wall_seconds']+2*CATALOG_LOCAL_TIMEOUT + adapter=self.projection.study_execution + value=adapter.studies._study(study_id) + opened=adapter.continue_study(study_id,dict(revision=value['revision'],admission_epoch=value['admission_epoch']), + self.checkpoint(),'study-current-continue') + self.assertIsNone(self.execution._runs) + approval_body=dict(trial_id=trial_value['id'],expected_material_sha256=composition['ref']['sha256'], + consumer_actor_id=self.access.identity()['actor_id']) + approval=adapter.approve(study_id,approval_body,self.checkpoint(),'study-current-approve') + fact=operation_records(self.session.snapshot['records'])['study-current-approve']['result'] + self.assertEqual(fact['format'],'project_study_approval_v1') + self.assertNotIn('current_local',fact);self.assertNotIn('grant',fact) + self.assertEqual(approval['grant']['id'],fact['approval_id']) + self.assertIsNone(self.execution._runs) + request=dict(revision=opened['revision'],admission_epoch=opened['admission_epoch'],trial_id=trial_value['id'], + approval_id=approval['grant']['id']) + admitted=adapter.admit(study_id,request,self.checkpoint(),'study-current-attempt') + first=self.await_drained(admitted['run_id'],timeout=drain_timeout) + self.assertEqual(first['state'],'completed') + saved=self.projection.history.study(study_id) + self.assertEqual(saved['charged'],envelope) + self.assertEqual(saved['reservations'][0]['state'],'settled') + self.assertEqual(saved['reservations'][0]['settled'],envelope) + self.assertEqual(adapter.studies.lineage._record(trial_value['id']),lineage) + old_grant=approval['grant']['id'];old_run=admitted['run_id'];logical=self.db.dataset_id + self.execution.reconcile_current() + self.projection.close() + # Its worker has drained and closed; leave no duplicate cleanup owner. + self.execution._runs=None + self.db=self.metadata('cold-study-projection',logical) + with patch('runs.Runs',side_effect=AssertionError('Cold Study Open constructed Runs')): + self.projection=ProjectProjection(self.store,self.db,self.access,{'fixture':self.source_root}) + self.projections.append(self.projection);self.execution=self.projection.execution;self.session=self.projection.session + adapter=self.projection.study_execution + self.assertEqual(adapter.studies._totals(adapter.studies._reservations(study_id)),envelope) + self.assertEqual(adapter.studies.lineage._record(trial_value['id']),lineage) + for table in ('workspace_runs','model_attempt_reservations','access_admissions','access_execution_grants', + 'access_execution_consumptions','model_operation_receipts'): + self.assertEqual(self.db.execute('SELECT count(*) FROM '+table).fetchone()[0],0,table) + with patch.object(self.execution,'_local_owner',side_effect=AssertionError('Historical request allocated a new owner')): + with self.assertRaisesRegex(WorkbenchError,'historical'): + adapter.approve(study_id,approval_body,self.checkpoint(),'study-current-approve') + with self.assertRaisesRegex(WorkbenchError,'historical admission'): + adapter.admit(study_id,request,self.checkpoint(),'study-current-attempt') + value=adapter.studies._study(study_id) + with self.assertRaisesRegex(WorkbenchError,'Explicitly continue'): + adapter.admit(study_id,dict(revision=value['revision'],admission_epoch=value['admission_epoch'], + trial_id=trial_value['id'],approval_id=old_grant),self.checkpoint(),'study-before-new-epoch') + lower=dict(value['limits']);lower['retained_bytes']=envelope['retained_bytes']-1 + before=self.session.snapshot + with self.assertRaises(WorkbenchError): + self.projection.history.save_study(study_id,dict(revision=value['revision'],name=value['name'], + limits=lower,policy=value['policy']),self.checkpoint(),'study-lower-spent-budget') + self.assertEqual(self.session.snapshot,before) + continued=adapter.continue_study(study_id,dict(revision=value['revision'],admission_epoch=value['admission_epoch']), + self.checkpoint(),'study-fresh-continue') + self.assertGreater(continued['admission_epoch'],opened['admission_epoch']) + self.projection.source_adapter.register(dict(root='fixture',manifest_path='project.json'), + self.checkpoint(),'study-fresh-source-binding') + recipe_cache=self.projection.pipelines.material_path/composition['ref']['sha256'] + model_cache=self.projection.models.path/composition['material']['model']['sha256'] + self.assertFalse(recipe_cache.exists());self.assertFalse(model_cache.exists()) + original_open=self.projection.artifacts.open_verified + def missing_payload(declaration,**kwargs): + if declaration['sha256']==composition['material']['recipe_document']['sha256']: + raise WorkbenchError('Controlled missing canonical recipe bytes.','unavailable',409) + return original_open(declaration,**kwargs) + before=self.session.snapshot + with patch.object(self.projection.artifacts,'open_verified',side_effect=missing_payload): + with self.assertRaisesRegex(WorkbenchError,'missing canonical'): + adapter.approve(study_id,approval_body,self.checkpoint(),'study-missing-payload') + self.assertEqual(self.session.snapshot,before) + self.assertEqual(self.db.execute('SELECT count(*) FROM access_execution_grants').fetchone()[0],0) + current=adapter.approve(study_id,approval_body,self.checkpoint(),'study-fresh-approve') + self.assertTrue(recipe_cache.is_dir());self.assertTrue(model_cache.is_dir()) + self.assertNotEqual(current['grant']['id'],old_grant) + self.assertIsNone(self.db.execute('SELECT id FROM access_execution_grants WHERE id=%s',(old_grant,)).fetchone()) + second=adapter.admit(study_id,dict(revision=continued['revision'],admission_epoch=continued['admission_epoch'], + trial_id=trial_value['id'],approval_id=current['grant']['id']),self.checkpoint(),'study-fresh-attempt') + result=self.await_drained(second['run_id'],timeout=drain_timeout);self.assertEqual(result['state'],'completed') + self.assertNotEqual(second['run_id'],old_run) + self.assertEqual(self.db.execute('SELECT count(*) FROM workspace_runs').fetchone()[0],1) + self.assertEqual(self.db.execute('SELECT count(*) FROM model_attempt_reservations').fetchone()[0],1) + self.assertIsNone(self.db.execute('SELECT id FROM workspace_runs WHERE id=%s',(old_run,)).fetchone()) + self.assertEqual(self.projection.history.study(study_id)['charged'],{key:2*value for key,value in envelope.items()}) + self.assertEqual(adapter.studies.lineage._record(trial_value['id']),lineage) + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'),'Requires disposable PostgreSQL') +class CandidateNativePublicationTests(unittest.TestCase): + """Actual writers/codecs with authored observations; no fitted-model claim. + + Only verified candidate selection is substituted. The explicitly authored + historical Run/receipt is test data, never an observed process completion. + No runtime executable, project source, or model state may be launched. + """ + setUp=ProjectStudyControlTests.setUp + metadata=ProjectStudyControlTests.metadata + cleanup=ProjectStudyControlTests.cleanup + prepare_study=ProjectStudyCustodyTests.prepare_study + + def open_projection(self): + from project_custody import ProjectCustody + db=self.metadata('writer-projection-'+uuid.uuid4().hex,project_id=self.store.snapshot()['project_id']) + journal=ProjectCustody(self.host.db.path,self.host.db.dataset_id).for_projection(self.store,db.projection_id) + with self.owner.bound(self.host),patch('runs.Runs',side_effect=AssertionError('Inert writer Open constructed Runs')): + projection=ProjectProjection(self.store,db,self.host.access,{},custody=journal) + self.projections.append(projection);self.projection=projection;self.adapter=projection.study_execution + + def checkpoint(self):return self.projection.session.snapshot['checkpoint'] + + def test_actual_verifier_owner_publication_is_canonical_and_cold_inert(self): + self._native_publication() + + def test_native_completion_before_wrapper_ack_reconciles_without_repeating_writer(self): + self._native_publication(wrapper_loss=True) + + def _native_publication(self,*,wrapper_loss=False): + import shutil + import candidate_packages as packages + import recipe_contract,recipe_process + from pipeline import digest,execution_digest + from project_history import envelope,FAMILY_FORMATS + from project_runtime import ProjectRuntime + from test_candidate_packages import fixture + from test_project_files_material_http import neutral_source + self.db=self.projection.db;self.source_root=self.root/'source';self.source_root.mkdir() + self.manifest,self.manifest_raw,self.definition_raw,self.source,self.input=neutral_source(self.source_root) + definition=json.loads(self.definition_raw) + definition['outputs']=[dict(path=name,role='result',max_bytes=2*1024**2,required=True) + for name in ('package.zip','checkpoint-epoch-0001.json','metrics.json')] + definition['resources']['max_output_bytes']=6*1024**2 + self.definition_raw=json.dumps(definition).encode();self.manifest['recipes'][0]['sha256']=packages.sha(self.definition_raw) + (self.source_root/'recipe.json').write_bytes(self.definition_raw) + (self.source_root/'project.json').write_bytes(json.dumps(self.manifest).encode()) + fd=os.open(self.source_root,os.O_RDONLY|os.O_DIRECTORY);info=os.fstat(fd) + self.projection.sources.roots['fixture']=(fd,[info.st_dev,info.st_ino]) + with self.owner.bound(self.host),patch('subprocess.Popen',side_effect=AssertionError('No process in writer qualification')),patch('runs.Runs',side_effect=AssertionError('No Run owner in writer qualification')): + self.projection.source_adapter.register(dict(root='fixture',manifest_path='project.json'),self.checkpoint(),'candidate-source') + body=dict(name='Authored source graph',project_id=self.manifest['id'],recipe_id='neutral',expected_sha256=packages.sha(self.definition_raw), + inputs={'sample':dict(reference_id='sample',expected_sha256=packages.sha(self.input))}) + self.graph=self.projection.materials.mutate_definition('pipeline_graph','graphs.create_external',None,body, + self.checkpoint(),'candidate-source-graph',lambda:self.projection.pipelines.create_external(body)) + study_id,trial_value,composition,envelope_value,_lineage=self.prepare_study() + python=self.root/'python';site=self.root/'packages' + (python/'bin').mkdir(parents=True);(python/'lib/python3.12/lib-dynload').mkdir(parents=True) + shutil.copyfile('/usr/bin/true',python/'bin/python3.12');(python/'lib/python3.12/lib-dynload/inert.txt').write_text('Not a Python environment') + entries=[] + for name in ('torch','numpy'): + (site/name).mkdir(parents=True);(site/name/'__init__.py').write_text('raise AssertionError("Never import")\n') + meta=site/(name+'-0.0.dist-info');meta.mkdir();(meta/'METADATA').write_text('Name: '+name+'\nVersion: 0.0\n');(meta/'LICENSE').write_text('Fixture') + entries.extend([name,meta.name]) + config=dict(schema_version=1,python_root=str(python),python_version='3.12',site_packages=str(site),packages=entries) + runtime=ProjectRuntime(self.projection,resolve_configuration=lambda request:copy.deepcopy(config)) + observed=runtime.inspect({'source':'authored'}) + captured=runtime.capture({'source':'authored'},observed['inventory_sha256'],self.checkpoint(),'candidate-runtime',reserve_bytes=0) + descriptor=self.projection.materials.runtime(captured['runtime_ref'])['descriptor'] + old_manifest,old_blobs=fixture();published=packages.decode(old_blobs['published-model-material.json']);published['catalog_id']=self.db.dataset_id + artifacts={name:old_blobs[name] for name in ('checkpoint-epoch-0001.json','weights-epoch-0001.pt','model-definition.json','fitted-transform.json','metrics.json','probe.json','probes-epoch-0001.json')} + checkpoint=packages.decode(artifacts['checkpoint-epoch-0001.json']);checkpoint['model_sha256']=packages.sha(packages.encoded(published));artifacts['checkpoint-epoch-0001.json']=packages.encoded(checkpoint) + probes=packages.decode(artifacts['probes-epoch-0001.json']);probes['checkpoint_sha256']=packages.sha(artifacts['checkpoint-epoch-0001.json']);artifacts['probes-epoch-0001.json']=packages.encoded(probes) + study_doc=next(copy.deepcopy(r) for r in self.projection.session.snapshot['records'] if r['family']=='study_history' and r['document']['study']['id']==study_id) + decision=dict(format='candidate_v1',id='f'*32,study_id=study_id,trial_id=trial_value['id'],run_id='a'*32, + checkpoint_sha256=packages.sha(artifacts['checkpoint-epoch-0001.json']),evaluation_sha256=packages.sha(artifacts['metrics.json']), + policy=study_doc['document']['study']['policy'],decision='diagnostic',limitations=['Authored structural writer fixture; not scientific qualification.']) + candidate=dict(ref=dict(kind='candidate',catalog_id=self.db.dataset_id,id=decision['id'],sha256=digest(decision)),candidate=decision, + run_id=decision['run_id'],checkpoint=packages.blob('checkpoint-epoch-0001.json',artifacts['checkpoint-epoch-0001.json'])) + manifest,blobs=packages.classifier_closure(candidate,artifacts,{'c'*32:b'authored fixture; not a PNG'}, + {'image_training.py':b'# inert authored source fixture\n'},packages.runtime_contract(descriptor), + {'material':published,**{name:old_blobs['published-'+name+'.json'] for name in ('specification','preprocessing_policy','output_policy')}}) + package=packages.create_package(manifest,blobs);packages.inspect_package(package,packages.sha(package)) + recipe_snapshot=json.loads(self.projection.materials.blob(composition['ref'],composition['material']['recipe_document'])) + execution=recipe_snapshot['preflight']['material'];run_id='a'*32;request_sha='c'*64 + spec=dict(recipe_contract.spec_for_material(execution,Path('/authored/workspace-runs')/('.external-'+run_id)),attempt_id=run_id,request_sha256=request_sha) + identity=dict(attempt_id=run_id,request_sha256=request_sha,spec_sha256=packages.sha(recipe_process.encoded(spec)),helper=dict(pid=123,start_ticks='1',boot_id='00000000-0000-0000-0000-000000000000')) + receipt=dict(schema_version=1,**identity,go_received=True,spawn_closed=True,drained=True,echild=True,outcome='completed',reason='Authored fixture only',leader_returncode=0, + streams={name:dict(bytes=0,sha256=packages.sha(b'')) for name in ('stdout','stderr')}) + receipt_raw=recipe_process.encoded(receipt) + payload_bytes={'package.zip':package,'checkpoint-epoch-0001.json':artifacts['checkpoint-epoch-0001.json'],'metrics.json':artifacts['metrics.json']} + inventory=[dict(name=name,role='result',bytes=len(raw),sha256=packages.sha(raw),media_type='application/octet-stream') for name,raw in payload_bytes.items()] + actor=self.owner.identity['actor_id'];admission_id='8'*32;approval_id='9'*32 + run=dict(schema_version=1,mode='real',execution_kind='external_recipe',result_kind='external_recipe',id=run_id,dataset_id=self.db.dataset_id, + request_key='authored-history',request_sha256=request_sha,actor_id=actor,admission_id=admission_id,approval_id=approval_id, + state='completed',created_at='2026-10-22T00:00:00+00:00',started_at=None,finished_at='2026-10-22T00:00:01+00:00',graph=self.graph, + graph_revision=self.graph['revision'],graph_sha256=execution_digest(self.graph),graph_document_sha256=digest(self.graph),material=execution, + material_sha256=digest(execution),spec=spec,outputs=execution['recipe']['outputs'],parameters=execution['parameters'],attempt=1,parent_run_id=None, + events=[],artifacts=inventory,metrics=None,error=None,custody_phase='drained',custody_identity=identity,custody_receipt_sha256=packages.sha(receipt_raw), + quality_claim=False,progress=dict(stage='completed',completed=None,total=None),node_attempts=[],approval_material_sha256=composition['ref']['sha256']) + proof=dict(grant=dict(id=approval_id,catalog_id=self.db.dataset_id,approver_actor_id=actor,approver_credential_id='4'*32,consumer_actor_id=actor, + material_sha256=composition['ref']['sha256'],created_at=1,expires_at=601,revoked_at=None), + consumption=dict(grant_id=approval_id,run_id=run_id,admission_id=admission_id,consumer_credential_id='5'*32,request_sha256=request_sha,consumed_at=2), + admission=dict(id=admission_id,catalog_id=self.db.dataset_id,actor_id=actor,credential_id='5'*32,kind='runs',job_id=run_id,request_digest=request_sha, + effects={'runs.update':[run_id]},created_at=2)) + payload_bytes['custody-receipt.json']=receipt_raw + payload_bytes['artifact-manifest.json']=encode(dict(schema_version=1,run_id=run_id,material_sha256=run['material_sha256'],request_sha256=request_sha,custody_receipt_sha256=run['custody_receipt_sha256'],artifacts=inventory)).encode() + for path in recipe_snapshot['capture_paths']:payload_bytes[path['path']]=self.projection.materials.blob(composition['ref'],path['blob']) + edges=[] + for name,raw in payload_bytes.items(): + item=dict(sha256=packages.sha(raw),bytes=len(raw));self.projection.artifacts.publish_bytes(raw,dict(format='project_artifact_v1',**item)) + edges.append(dict(name=name,blob=item,population='unknown',role='authored_fixture',preservation='unique',required_for=['inspection'])) + archived=capture_run(run,admission_proof=proof,payloads=edges) + reservation=dict(id='e'*32,study_id=study_id,admission_epoch=0,request_key='authored-history',actor_id=actor,trial_id=trial_value['id'],parent_run_id=None, + run_id=run_id,request_sha256=request_sha,material_sha256=composition['ref']['sha256'],envelope=envelope_value,state='settled',settled=envelope_value) + study_doc['document']['reservations']=[reservation] + selected=envelope('candidate_history',self.db.dataset_id,decision['id'],dict(format=FAMILY_FORMATS['candidate_history'],origin_catalog_id=self.db.dataset_id,candidate=decision)) + before=self.projection.session.snapshot;records=[r for r in before['records'] if (r['family'],r['id'])!=(study_doc['family'],study_doc['id'])] + merged={(r['family'],r['id']):r for r in [*records,study_doc,archived,*declarations(archived),selected]} + self.projection.validate_records(list(merged.values()));self.store.commit(list(merged.values()),before['checkpoint'],'authored-history-fixture','a'*64) + self.projection.session.adopt(self.store.snapshot());self.adapter.restore_authoring() + # This is the sole scientific-selection substitution. Every package, + # source, custody/input, current disclosure and canonical codec stays real. + with patch.object(self.adapter,'candidate',return_value=candidate): + request=dict(revision=study_doc['document']['study']['revision'],candidate_id=decision['id'],root='fixture',directory='verifier', + runtime_snapshot_sha256=descriptor['manifest_sha256'],package_run_id=run_id,package_sha256=packages.sha(package)) + with self.assertRaisesRegex(WorkbenchError,'disclose'): + self.adapter.prepare_verifier(study_id,request,self.checkpoint(),'verify-without-disclosure') + self.assertFalse((self.source_root/'verifier').exists()) + self.projection.disclose(run_id,dict(origin_catalog_id=self.db.dataset_id,reason='Authored writer qualification'),self.checkpoint(),'disclose-writer-fixture') + # The denied attempt retained an honest intent; use a new key. + writes=[];original_writer=packages._write_project + def write_after_intent(root,files): + receipts=operation_records(self.projection.session.snapshot['records']) + self.assertTrue(any(value['operation']=='study_execution.prepare_verifier_intent' + and value['result']['directory']=='verifier' for value in receipts.values())) + self.assertIn('verify-authored-fixture',self.projection.execution._package_pending) + self.assertFalse((self.source_root/'verifier').exists()) + writes.append(self.checkpoint());return original_writer(root,files) + with patch.object(packages,'_write_project',side_effect=write_after_intent): + if wrapper_loss: + original_mutate=self.adapter._mutate + def lose_wrapper(operation,*args,**kwargs): + if operation=='study_execution.prepare_verifier': + raise OSError('Controlled failure before public wrapper acknowledgement') + return original_mutate(operation,*args,**kwargs) + with patch.object(self.adapter,'_mutate',side_effect=lose_wrapper),self.assertRaisesRegex(OSError,'public wrapper'): + self.adapter.prepare_verifier(study_id,request,self.checkpoint(),'verify-authored-fixture') + else:result=self.adapter.prepare_verifier(study_id,request,self.checkpoint(),'verify-authored-fixture') + self.assertEqual(len(writes),1) + if wrapper_loss: + self.assertTrue(self.projection.custody.has_pending) + self.assertNotIn('verify-authored-fixture',operation_records(self.projection.session.snapshot['records'])) + native=next(value for record in self.projection.session.snapshot['records'] if record['family']=='study_history' + and record['document']['study']['id']==study_id for value in record['document']['operations'] + if value['operation_id']=='studies.prepare_verifier' and value['request_key']=='verify-authored-fixture') + self.assertEqual(native['result']['state'],'prepared') + before=copy.deepcopy(self.projection.session.snapshot) + with self.assertRaisesRegex(WorkbenchError,'complete'): + self.adapter.abandon_preparation(study_id,dict(preparation_key='verify-authored-fixture',reason='Must retain complete science'), + self.checkpoint(),'forbidden-complete-abandon') + self.assertEqual(self.projection.session.snapshot,before) + with patch.object(packages,'_write_project',side_effect=AssertionError('Recovery repeated native filesystem writes')): + result=self.adapter.reconcile_preparation(study_id,dict(preparation_key='verify-authored-fixture'),self.checkpoint()) + self.assertEqual(result,native['result']) + receipt=operation_records(self.projection.session.snapshot['records'])['verify-authored-fixture'] + self.assertEqual(receipt['operation'],'study_execution.prepare_verifier') + self.assertEqual(receipt['result'],native['result']) + self.assertFalse(self.projection.custody.has_pending) + self.assertEqual(self.adapter.prepare_verifier(study_id,request,self.checkpoint(),'verify-authored-fixture'),result) + for table in ('workspace_runs','access_execution_grants','access_admissions','model_attempt_reservations'): + self.assertEqual(self.db.execute('SELECT count(*) FROM '+table).fetchone()[0],0,table) + return + self.assertEqual(result['state'],'prepared');self.assertTrue((self.source_root/'verifier/project.json').is_file()) + receipts=operation_records(self.projection.session.snapshot['records']) + self.assertEqual(receipts['verify-authored-fixture']['operation'],'study_execution.prepare_verifier') + self.assertTrue(validate_operation_result('study_execution.prepare_verifier',result,self.projection.session.snapshot['records'])) + saved=self.projection.session.snapshot + self.assertEqual(self.adapter.prepare_verifier(study_id,request,self.checkpoint(),'verify-authored-fixture'),result) + self.assertEqual(self.projection.session.snapshot,saved) + consumer=self.projection.materials.recipe(result['trial']['material']) + # The actual native reader must consume an archived producer + # through current disclosure, with no live producer SQL row. + self.assertEqual(self.projection.pipelines.recipe_material(result['trial']['material'],verify=True),consumer) + self.projection._disclosures.clear() + with self.assertRaisesRegex(WorkbenchError,'disclose'): + self.projection.pipelines.recipe_material(result['trial']['material'],verify=True) + self.projection.disclose(run_id,dict(origin_catalog_id=self.db.dataset_id,reason='New consumer read permission'), + self.checkpoint(),'disclose-consumer-input') + snapshot=json.loads(self.projection.materials.blob(consumer['ref'],consumer['material']['recipe_document'])) + tampered=copy.deepcopy(snapshot['preflight']['material']) + import base64 + source=tampered['inputs']['package']['source'] + forged=recipe_process.decode(base64.b64decode(source['custody_receipt_base64'])) + forged['reason']='Different self-consistent authored receipt' + forged_raw=recipe_process.encoded(forged) + source['custody_receipt_base64']=base64.b64encode(forged_raw).decode('ascii') + source['custody_receipt_sha256']=packages.sha(forged_raw) + recipe_contract.validate_material(tampered) + with self.assertRaisesRegex(WorkbenchError,'producer proof differs'): + packages.authorize_material_inputs(self.projection.history,tampered) + # The named current execution composition selects the same + # history proof seam; it cannot call a general live resolver. + execution=self.projection.execution + local_reader=SimpleNamespace(project_execution=execution, + _load=lambda _:(_ for _ in ()).throw(AssertionError('Imported producer reached live SQL resolver'))) + execution._runs=local_reader + try:packages.authorize_material_inputs(local_reader,snapshot['preflight']['material']) + finally:execution._runs=None + # A synchronous failure after actual source/graph/model draft + # publication preserves its directory and every canonical phase. + partial=dict(request,revision=self.adapter.studies._study(study_id)['revision'],directory='partial') + with patch.object(self.projection.models,'publish',side_effect=RuntimeError('Controlled partial owner failure')): + with self.assertRaisesRegex(RuntimeError,'partial owner failure'): + self.adapter.prepare_verifier(study_id,partial,self.checkpoint(),'verify-partial') + phases=self.projection.session.snapshot['records'] + self.assertTrue((self.source_root/'partial/project.json').is_file()) + self.assertTrue(self.projection.custody.has_pending) + aborted=self.adapter.abandon_preparation(study_id,dict(preparation_key='verify-partial',reason='Preserve and stop the authored partial writer'), + self.checkpoint(),'abandon-partial') + self.assertEqual(aborted['state'],'abandoned') + self.assertFalse(self.projection.custody.has_pending) + current={(r['family'],r['id']):r for r in self.projection.session.snapshot['records']} + self.assertTrue(all(current[(r['family'],r['id'])]==r for r in phases)) + self.assertTrue((self.source_root/'partial/project.json').is_file()) + # Lose only final journal acknowledgement, after all native and + # canonical commits. Recovery must confirm the existing result. + uncertain=dict(partial,directory='ack-lost') + with patch.object(self.projection.custody,'package_confirmed',side_effect=OSError('Controlled final custody ACK loss')): + with self.assertRaisesRegex(OSError,'custody ACK loss'): + self.adapter.prepare_verifier(study_id,uncertain,self.checkpoint(),'verify-ack-lost') + self.assertFalse(self.projection.session.available) + completed=operation_records(self.projection.session.snapshot['records'])['verify-ack-lost']['result'] + with patch.object(packages,'_write_project',side_effect=AssertionError('Recovery repeated a native writer')): + recovered=self.adapter.reconcile_preparation(study_id,dict(preparation_key='verify-ack-lost'),self.checkpoint()) + self.assertEqual(recovered,completed);self.assertTrue(self.projection.session.available) + self.assertFalse(self.projection.custody.has_pending) + for table in ('workspace_runs','access_execution_grants','access_admissions','model_attempt_reservations'): + self.assertEqual(self.db.execute('SELECT count(*) FROM '+table).fetchone()[0],0,table) + self.assertFalse(self.projection.execution.has_local_custody) + saved=self.projection.session.snapshot + self.projection.close();self.open_projection() + self.assertEqual(self.projection.session.snapshot,saved) + self.assertFalse(self.projection.execution.has_local_custody) + self.assertIn(result['trial']['id'],self.projection.history.study(study_id)['study']['trial_ids']) + self.assertEqual(self.adapter.prepare_verifier(study_id,request,self.checkpoint(),'verify-authored-fixture'),result) + with self.assertRaisesRegex(WorkbenchError,'exact original file writer') as refused: + self.adapter.reconcile_preparation(study_id,dict(preparation_key='verify-ack-lost'),self.checkpoint()) + self.assertEqual(refused.exception.code,'unavailable') + self.assertFalse(self.projection._disclosures) + with self.assertRaisesRegex(WorkbenchError,'disclose'): + self.projection.history.artifact(run_id,'package.zip') + for table in ('workspace_runs','access_execution_grants','access_admissions','model_attempt_reservations','model_operation_receipts'): + self.assertEqual(self.projection.db.execute('SELECT count(*) FROM '+table).fetchone()[0],0,table) diff --git a/tests/test_project_text.py b/tests/test_project_text.py new file mode 100644 index 00000000..69322b7a --- /dev/null +++ b/tests/test_project_text.py @@ -0,0 +1,419 @@ +"""Text-domain acceptance through real PostgreSQL and canonical POSIX files.""" +import copy +import hashlib +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import threading +import unittest +from unittest.mock import patch + +import psycopg +from psycopg import sql + +from access import Access, AccessError +from metadata import Metadata +from project_store import ProjectStore, ProjectStoreError, _encode +from project_text import ProjectText, ProjectTextError, decode_records + + +class ProjectTextTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.host = self.metadata('host') + self.access = Access(self.host, self.host.path) + self.token = self.access.exchange(self.access._local_owner_token())['token'] + self.enterContext(self.access.bind(self.token)) + self.db = self.metadata('projection') + self.store = ProjectStore.create(self.root / 'project', 'Authored text', 'new-project', 'a' * 64) + self.addCleanup(self.store.close) + self.text = ProjectText(self.store, self.db, self.access) + self.text.restore() + + def metadata(self, name): + database = Metadata(self.root / name) + def drop_fixture(): + with psycopg.connect(database.dsn, autocommit=True) as connection: + connection.execute(sql.SQL('DROP SCHEMA {} CASCADE').format(sql.Identifier(database.schema))) + self.addCleanup(drop_fixture) + self.addCleanup(database.close) + return database + + def body(self, source='Hello 😀 cafe\u0301\r\nSecond\rline', **extra): + return dict(text=source, name='Original note', groups=['owned-fixture'], rights='Authored fixture; no grant implied', **extra) + + def import_text(self, source='Hello 😀 cafe\u0301\r\nSecond\rline', key='import-one', **extra): + return self.text.import_text(self.body(source, **extra), self.text.checkpoint, key) + + def annotation(self, row, **extra): + return dict(revision=row['revision'], source_revision=1, task='text_entities', + annotation={'spans': [dict(label='emoji', start=6, end=7)]}, + groups=['owned-fixture', 'reviewed-fixture'], review='human_reviewed', **extra) + + def save_annotation(self, row, key='annotate-one'): + return self.text.annotate(row['id'], self.annotation(row), self.text.checkpoint, key) + + def test_cold_fresh_database_restores_raw_normalized_history_and_inert_facts(self): + source = 'Hello 😀 cafe\u0301\r\nSecond\rline' + row = self.import_text(source) + saved = self.save_annotation(row) + history = self.text.history(row['id']) + snapshot = self.store.snapshot() + doc = snapshot['records'][0]['document'] + self.assertEqual(doc['original_text'], source) + self.assertEqual(saved['text'], 'Hello 😀 café\nSecond\nline') + self.assertEqual(saved['source_sha256'], hashlib.sha256(source.encode()).hexdigest()) + self.assertEqual(saved['content_hash'], hashlib.sha256(saved['text'].encode()).hexdigest()) + self.assertEqual([item['revision'] for item in doc['history']], [1, 2]) + self.assertEqual(doc['audit'][1]['action'], 'workbench.approve_review') + serialized = json.dumps(snapshot) + self.assertNotIn(self.token, serialized) + self.assertNotIn('credential_id', serialized) + self.assertNotIn('access_credentials', serialized) + self.store.save(self.text.checkpoint) + self.store.close() + self.db.close() + # A new installation grants its own current permission; old audit facts + # neither create credentials nor require the old installation to exist. + new_host = self.metadata('other-host') + new_access = Access(new_host, new_host.path) + token = new_access.exchange(new_access._local_owner_token())['token'] + fresh = self.metadata('fresh-projection') + reopened = ProjectStore.open(self.root / 'project') + self.addCleanup(reopened.close) + cold = ProjectText(reopened, fresh, new_access) + cold.restore() + with new_access.bind(token): + actual = cold.get(row['id']) + self.assertEqual({k: v for k, v in saved.items() if k != 'request_key'}, actual) + self.assertEqual(history, cold.history(row['id'])) + self.assertEqual(cold.query({'q': 'emoji'})['total'], 1) + self.assertEqual(reopened.save(cold.checkpoint), snapshot) + self.assertEqual(fresh.execute('SELECT count(*) FROM access_credentials').fetchone()[0], 0) + self.assertEqual(fresh.execute('SELECT original_text FROM workbench_records').fetchone()[0], source) + + def test_retry_returns_original_target_revision_before_stale_precondition(self): + before = self.text.checkpoint + body = self.body() + row = self.text.import_text(body, before, 'import-one') + self.save_annotation(row) + checkpoint = self.text.checkpoint + replay = self.text.import_text(body, before, 'import-one') + self.assertEqual((replay['id'], replay['revision']), (row['id'], 1)) + self.assertEqual(replay['checkpoint'], checkpoint) + self.assertEqual(self.text.query()['total'], 1) + self.assertEqual(len(self.text.history(row['id'])), 2) + with self.assertRaises(ProjectStoreError) as failure: + self.text.import_text(self.body('different'), before, 'import-one') + self.assertEqual(failure.exception.code, 'conflict') + self.assertEqual(self.text.checkpoint, checkpoint) + + def test_parent_lineage_and_annotation_replay_survive_restore(self): + parent = self.import_text() + child = self.import_text('A distinct child', key='import-child', parents=[parent['id']]) + body = dict(revision=1, source_revision=1, task='text_corpus', annotation={'note': 'Deliberate corpus review'}, + groups=child['groups'], review='human_reviewed') + before = self.text.checkpoint + reviewed = self.text.annotate(child['id'], body, before, 'review-child') + self.text.restore() + replay = self.text.annotate(child['id'], body, before, 'review-child') + self.assertEqual(reviewed, replay) + self.assertEqual(self.text.get(child['id'])['parents'], [parent['id']]) + + def test_unavailable_operations_and_invalid_annotation_have_no_effects(self): + row = self.import_text() + before = self.store.snapshot() + cases = [lambda: self.text.import_text(dict(kind='image', image='not decoded'), self.text.checkpoint, 'image'), + lambda: self.text.annotate(row['id'], dict(self.annotation(row), review='programmatically_verified'), self.text.checkpoint, 'verify'), + lambda: self.text.annotate(row['id'], dict(self.annotation(row), task='image_detection'), self.text.checkpoint, 'image-task'), + lambda: self.text.annotate(row['id'], dict(self.annotation(row), annotation={'spans': [dict(label='bad', start=True, end=7)]}), self.text.checkpoint, 'invalid-span')] + for action in cases: + with self.subTest(action=action), self.assertRaises(ProjectTextError if action != cases[-1] else ValueError): + action() + self.assertEqual(self.store.snapshot(), before) + self.assertEqual(self.text.get(row['id'])['revision'], 1) + self.assertFalse(hasattr(self.text, 'import_asset')) + self.assertFalse(hasattr(self.text, 'preferences')) + + def test_expected_checkpoint_and_record_revision_reject_without_publish(self): + checkpoint = self.text.checkpoint + row = self.import_text() + before = self.store.snapshot() + with self.assertRaises(ProjectTextError): + self.text.import_text(self.body('another'), checkpoint, 'stale-project') + self.save_annotation(row) + current = self.store.snapshot() + with self.assertRaises(ValueError): + self.text.annotate(row['id'], self.annotation(row), self.text.checkpoint, 'stale-record') + self.assertEqual(self.store.snapshot(), current) + self.assertNotEqual(current, before) + + def test_file_publication_failure_rolls_back_actual_sql(self): + before = self.store.snapshot() + with patch.object(self.store, '_publish', side_effect=ProjectStoreError('fixture unavailable', 'unavailable', 503)): + with self.assertRaises(ProjectStoreError): + self.import_text() + self.assertEqual(self.store.snapshot(), before) + self.assertEqual(self.db.execute('SELECT count(*) FROM workbench_records').fetchone()[0], 0) + self.assertEqual(self.db.execute('SELECT count(*) FROM workbench_history').fetchone()[0], 0) + self.assertEqual(self.text.query()['total'], 0) + + def test_real_postgres_commit_failure_retains_canonical_success_and_rebuilds(self): + # Deferred trigger fails at the actual outer PostgreSQL COMMIT, after + # canonical file publication, rather than replacing the DB adapter. + self.db.execute("""CREATE FUNCTION fixture_refuse_commit() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN RAISE EXCEPTION 'fixture deferred rejection' USING ERRCODE='23514'; END $$""") + self.db.execute('''CREATE CONSTRAINT TRIGGER fixture_commit_failure AFTER INSERT ON workbench_records + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION fixture_refuse_commit()''') + with self.assertRaises(ProjectTextError) as failure: + self.import_text() + self.assertEqual(failure.exception.code, 'projection_unavailable') + self.assertTrue(failure.exception.committed) + snapshot = self.store.snapshot() + self.assertEqual(failure.exception.receipt, dict(committed=True, selected=True, + project_id=snapshot['project_id'], checkpoint=snapshot['checkpoint'], request_key='import-one')) + self.assertEqual(len(snapshot['records']), 1) + self.assertFalse(self.text.available) + self.assertEqual(self.db.execute('SELECT count(*) FROM workbench_records').fetchone()[0], 0) + with self.assertRaises(ProjectTextError): + self.text.query() + self.db.execute('DROP TRIGGER fixture_commit_failure ON workbench_records') + self.text.restore() + replay = self.text.import_text(self.body(), '0' * 64, 'import-one') + self.assertEqual(replay['id'], snapshot['records'][0]['id']) + self.assertEqual(self.text.query()['total'], 1) + + def test_external_checkpoint_and_immutable_byte_edits_fence_reads_and_writes(self): + row = self.import_text() + manifest_path = self.root / 'project' / '.tuldok' / 'project.json' + original = manifest_path.read_bytes() + manifest = json.loads(original) + manifest['name'] = 'External checkout' + del manifest['checkpoint'] + manifest['checkpoint'] = hashlib.sha256(_encode(manifest)).hexdigest() + manifest_path.write_bytes(_encode(manifest)) + for action in (lambda: self.text.query(), lambda: self.text.get(row['id']), + lambda: self.text.history(row['id']), lambda: self.import_text('another', key='drift')): + with self.assertRaises(ProjectStoreError) as failure: + action() + self.assertEqual(failure.exception.code, 'stale') + self.assertEqual(self.db.execute('SELECT count(*) FROM workbench_records').fetchone()[0], 1) + manifest_path.write_bytes(original) + manifest = json.loads(original) + record_path = self.root / 'project' / '.tuldok' / manifest['inventory'][0]['path'] + record_path.write_text('{}') + with self.assertRaises(ProjectStoreError): + self.text.query() + with self.assertRaises(ProjectStoreError): + self.import_text('another', key='corrupt') + + def test_cold_decoder_rejects_corrupt_semantics_before_any_projection_write(self): + row = self.import_text() + original = self.store.snapshot()['records'] + mutations = { + 'extra credential': lambda d: d.update(access_credentials=[]), + 'missing source bytes': lambda d: d.pop('original_text'), + 'changed raw source': lambda d: d.update(original_text='forged'), + 'normalized hash': lambda d: d['history'][0].update(content_hash='f' * 64), + 'noninteger revision': lambda d: d['current'].update(revision=True), + 'future task': lambda d: d['history'][0].update(task='text_future'), + 'missing history': lambda d: d.update(history=[]), + 'missing audit': lambda d: d.update(audit=[]), + 'credential in fact': lambda d: d['audit'][0].update(credential_id='1' * 32), + 'forged review': lambda d: d['history'][0].update(review='programmatically_verified'), + 'broken parent': lambda d: d['history'][0].update(parents=['1' * 32]), + 'foreign target': lambda d: d['operations'][0].update(target='1' * 32), + } + for label, mutate in mutations.items(): + with self.subTest(label=label): + records = copy.deepcopy(original) + mutate(records[0]['document']) + if label == 'broken parent': + records[0]['document']['current']['parents'] = ['1' * 32] + before = self.db.execute('SELECT snapshot FROM workbench_history').fetchone()[0] + key = label.replace(' ', '-') + self.store.commit(records, self.store.snapshot()['checkpoint'], 'bad-' + key, hashlib.sha256(label.encode()).hexdigest()) + with self.assertRaises(ValueError): + self.text.restore() + self.assertEqual(self.db.execute('SELECT snapshot FROM workbench_history').fetchone()[0], before) + self.store.commit(original, self.store.snapshot()['checkpoint'], 'repair-' + key, hashlib.sha256(('repair' + label).encode()).hexdigest()) + self.assertEqual(self.db.execute('SELECT count(*) FROM workbench_records').fetchone()[0], 1) + + def test_decoder_rejects_unknown_formats_families_and_validly_hashed_broken_lineage(self): + row = self.import_text() + original = self.store.snapshot()['records'] + for field, value in [('family', 'future_family'), ('format', 'workbench_text_v2')]: + records = copy.deepcopy(original) + if field == 'family': + records[0][field] = value + else: + records[0]['document'][field] = value + with self.assertRaises(ProjectTextError) as failure: + decode_records(records) + self.assertEqual(failure.exception.code, 'unsupported') + from workbench import encode + for parent, message in [('1' * 32, 'missing'), (row['id'], 'cycle')]: + records = copy.deepcopy(original) + doc = records[0]['document'] + doc['current']['parents'] = [parent] + doc['history'][0]['parents'] = [parent] + doc['audit'][0]['details']['content_sha256'] = hashlib.sha256(encode(doc['history'][0]).encode()).hexdigest() + with self.assertRaisesRegex(ProjectTextError, message): + decode_records(records) + + def test_anonymous_current_access_is_not_granted_by_imported_history(self): + row = self.import_text() + unrelated_host = self.metadata('unrelated-host') + access = Access(unrelated_host, unrelated_host.path) + reader = ProjectText(self.store, self.db, access) + reader.restore() + # Current context is bound to the original host, not this installation. + with self.assertRaises(AccessError): + reader.get(row['id']) + self.assertEqual(unrelated_host.execute('SELECT count(*) FROM access_credentials').fetchone()[0], 0) + + def test_scoped_agent_draft_edits_and_reads_keep_owner_review_authority(self): + credential = self.access.issue(dict(label='Project fixture agent', scopes=['inspect', 'edit'])) + with self.access.bind(credential['token']): + row = self.import_text() + body = dict(self.annotation(row), review='draft') + updated = self.text.annotate(row['id'], body, self.text.checkpoint, 'agent-draft') + self.assertEqual(updated['revision'], 2) + self.assertEqual(self.text.get(row['id'])['annotation'], body['annotation']) + self.assertEqual(self.text.query()['total'], 1) + self.assertEqual(len(self.text.records()['items']), 1) + self.assertEqual(len(self.text.history(row['id'])), 2) + before = self.store.snapshot() + with self.assertRaises(AccessError): + self.text.annotate(row['id'], self.annotation(updated), self.text.checkpoint, 'agent-approval') + self.assertEqual(self.store.snapshot(), before) + portable = json.dumps(self.store.snapshot()) + self.assertNotIn(credential['token'], portable) + self.assertNotIn('credential_id', portable) + inspection = self.access.issue(dict(label='Read-only fixture agent', scopes=['inspect'])) + with self.access.bind(inspection['token']): + self.assertEqual(self.text.get(row['id'])['revision'], 2) + with self.assertRaises(AccessError): + self.import_text('Read only cannot import', key='read-only-write') + self.assertEqual(self.store.snapshot(), before) + + def test_no_dataset_or_recovery_owner_is_constructed(self): + with patch('app.Dataset', side_effect=AssertionError('Dataset must remain absent')): + fresh = ProjectText(self.store, self.db, self.access) + fresh.restore() + row = fresh.import_text(self.body(), fresh.checkpoint, 'inert-import') + self.assertEqual(fresh.get(row['id'])['revision'], 1) + self.assertEqual(self.db.execute('SELECT count(*) FROM workspace_runs').fetchone()[0], 0) + + def test_nested_caller_transaction_cannot_acknowledge_before_outer_commit(self): + before = self.store.snapshot() + with self.db.transaction(): + with self.assertRaises(ProjectTextError): + self.import_text() + self.assertEqual(self.store.snapshot(), before) + + def test_current_owner_revocation_waits_for_canonical_publication(self): + credential_id = self.access.require('edit', 'workbench.import_asset').credential_id + administrator = self.access.exchange(self.access._local_owner_token())['token'] + at_publication, proceed, revocation_attempt = threading.Event(), threading.Event(), threading.Event() + outcomes, failures = [], [] + original_commit = self.store.commit + def commit(*args, **kwargs): + at_publication.set() + if not proceed.wait(5): + raise AssertionError('Publication fixture timed out') + result = original_commit(*args, **kwargs) + outcomes.append('canonical') + return result + def mutate(): + try: + with self.access.bind(self.token): + self.import_text() + except BaseException as error: + failures.append(error) + def revoke(): + try: + revocation_attempt.set() + with self.access.bind(administrator): + self.access.revoke(credential_id) + outcomes.append('revoked') + except BaseException as error: + failures.append(error) + writer, revoker = threading.Thread(target=mutate), threading.Thread(target=revoke) + with patch.object(self.store, 'commit', side_effect=commit): + writer.start() + try: + self.assertTrue(at_publication.wait(5)) + revoker.start() + self.assertTrue(revocation_attempt.wait(5)) + acquired = self.access.lock.mutex.acquire(blocking=False) + if acquired: + self.access.lock.mutex.release() + self.assertFalse(acquired, 'Host authority must stay locked through canonical publication') + finally: + proceed.set() + writer.join(5) + if revoker.ident is not None: + revoker.join(5) + self.assertFalse(writer.is_alive()) + self.assertFalse(revoker.is_alive()) + self.assertEqual(failures, []) + self.assertEqual(outcomes, ['canonical', 'revoked']) + with self.assertRaises(AccessError): + self.text.query() + with self.access.bind(administrator): + self.assertEqual(len(self.text.records()['items']), 1) + + def test_full_current_records_include_text_without_query_pagination(self): + first = self.import_text() + second = self.import_text('Another complete text', key='second') + full = self.text.records() + self.assertEqual(full['checkpoint'], self.text.checkpoint) + self.assertEqual({row['id'] for row in full['items']}, {first['id'], second['id']}) + self.assertEqual({row['text'] for row in full['items']}, {first['text'], second['text']}) + self.assertNotIn('text', self.text.query({'limit': 1})['items'][0]) + + def test_lost_canonical_publication_reply_requires_reopen_and_reconciles(self): + publish = self.store._publish + def publish_then_lose_reply(*args, **kwargs): + publish(*args, **kwargs) + raise ProjectStoreError('Fixture lost publication reply.', 'unknown', 503) + with patch.object(self.store, '_publish', side_effect=publish_then_lose_reply): + with self.assertRaises(ProjectStoreError) as failure: + self.import_text() + self.assertEqual(failure.exception.code, 'unknown') + self.assertFalse(self.text.available) + self.assertEqual(self.db.execute('SELECT count(*) FROM workbench_records').fetchone()[0], 0) + self.store.close() + reopened = ProjectStore.open(self.root / 'project') + self.addCleanup(reopened.close) + restored = ProjectText(reopened, self.db, self.access) + restored.restore() + replay = restored.import_text(self.body(), '0' * 64, 'import-one') + self.assertEqual(replay['id'], reopened.snapshot()['records'][0]['id']) + self.assertEqual(len(restored.records()['items']), 1) + + def test_closed_process_opens_files_without_old_projection(self): + row = self.import_text() + self.save_annotation(row) + expected = self.store.snapshot() + self.store.close() + script = ''' +import json,sys +from project_store import ProjectStore +from project_text import decode_records +with ProjectStore.open(sys.argv[1]) as store: + snapshot=store.snapshot() + decode_records(snapshot['records']) + print(json.dumps(snapshot,ensure_ascii=False,sort_keys=True)) +''' + result = subprocess.run([sys.executable, '-c', script, str(self.root / 'project')], capture_output=True, text=True, check=True) + self.assertEqual(json.loads(result.stdout), expected) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_project_transfer.py b/tests/test_project_transfer.py new file mode 100644 index 00000000..7f088140 --- /dev/null +++ b/tests/test_project_transfer.py @@ -0,0 +1,504 @@ +"""M5 explicit cessation, readonly capture and atomic extraction on disposable PG.""" +import copy +import hashlib +import http.client +from http.server import ThreadingHTTPServer +import json +import os +from pathlib import Path +import tempfile +import threading +import time +from types import SimpleNamespace +import unittest +from unittest.mock import patch +import uuid + +import psycopg +from psycopg import sql +from app import Dataset, make_handler +from backup_metadata import tree_manifest +from project_store import ProjectStore, ProjectStoreError +from project_transfer import (ProjectTransfer, StoppedSourceLease, preview_source, + stop_source, decode_records, _assert_unchanged) +from workbench import WorkbenchError + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'),'Requires disposable PostgreSQL') +class ProjectTransferTests(unittest.TestCase): + def setUp(self): + self.temporary=tempfile.TemporaryDirectory(prefix='tuldok-transfer-fixture-') + self.addCleanup(self.temporary.cleanup) + self.root=Path(self.temporary.name) + self.project_root=self.root/'registered';self.project_root.mkdir() + self.dataset=Dataset(self.root/'legacy',project_roots={'fixture':self.project_root}) + self.addCleanup(self.cleanup) + exchange=self.dataset.access.exchange(self.dataset.access._local_owner_token()) + self.token,self.actor=exchange['token'],exchange['identity']['actor_id'] + self.enterContext(self.dataset.access.bind(self.token)) + self.manifest=dict(schema_version=1,id=uuid.uuid4().hex,name='Exact chosen source λ',data=[],recipes=[],evidence=[],requirements=[]) + self.raw_manifest=(json.dumps(self.manifest,ensure_ascii=False,indent=3)+'\n\n').encode() + (self.project_root/'source.json').write_bytes(self.raw_manifest) + self.dataset.projects.register_snapshot(dict(root='fixture',manifest_path='source.json')) + self.selection=dict(project_snapshot=dict(id=self.manifest['id'],manifest_sha256=hashlib.sha256(self.raw_manifest).hexdigest()),roots={},decisions=[]) + self.parent_fd=os.open(self.root,os.O_RDONLY|os.O_DIRECTORY);self.addCleanup(os.close,self.parent_fd) + self.leases=[] + def cleanup(self): + for source in self.leases:source.close() + self.dataset.close() + with psycopg.connect(self.dataset.db.dsn,autocommit=True) as connection: + connection.execute(sql.SQL('DROP SCHEMA {} CASCADE').format(sql.Identifier(self.dataset.db.schema))) + def text(self,value='Exact original e\u0301\r\nλ'): + return self.dataset.workbench.import_asset(dict(kind='text',name='Authored unique source',text=value,groups=['shared-fixture'],parents=[],rights='Fixture permission')) + def stop(self): + source=self.dataset.stop_for_project_extraction(self.parent_fd,'verified-backup');self.leases.append(source);return source + def test_preview_readonly_no_constructor_no_stop_and_explicit_unassigned(self): + selected=self.text();other=self.text('Unassigned unique source') + self.selection['roots']['records']=[selected['id']] + before=tree_manifest(self.dataset.path) + with preview_source(self.dataset) as source: + with patch('metadata.Metadata.__init__',side_effect=AssertionError('preview initialized Metadata')), \ + patch('synthetic.Jobs.__init__',side_effect=AssertionError('preview constructed Jobs')), \ + patch('runs.Runs.__init__',side_effect=AssertionError('preview constructed Runs')): + report=ProjectTransfer(source).preview(self.selection) + self.assertFalse(report['can_extract']) + self.assertEqual([b['code'] for b in report['blockers']],['cessation_proof_unavailable']) + self.assertIn(dict(family='workbench_record',id=other['id'],revision=1),report['unassigned']) + self.assertEqual(source.db.execute("SELECT current_setting('transaction_read_only')").fetchone()[0],'on') + with self.assertRaises(psycopg.errors.ReadOnlySqlTransaction):source.db.execute('DELETE FROM workbench_records') + self.assertEqual(before,tree_manifest(self.dataset.path)) + self.assertFalse(self.dataset._extraction_stopping) + def test_actual_stop_backup_is_private_verified_and_source_unchanged(self): + self.text();source=self.stop() + self.assertIs(type(source),StoppedSourceLease) + self.assertTrue(self.dataset._closed) + self.assertEqual(tree_manifest(self.dataset.path),source.backup_receipt['files']) + self.assertEqual(tree_manifest(self.root/'verified-backup'/'catalog'),source.backup_receipt['files']) + self.assertTrue((self.root/'verified-backup'/'metadata.json').exists()) + _assert_unchanged(source) + with self.assertRaises(WorkbenchError): + with self.dataset._project_request():pass + with self.assertRaises(WorkbenchError):self.dataset.stop_for_project_extraction(self.parent_fd,'retry-backup') + def test_json_bool_and_fake_controller_guards_never_stop(self): + for guard in (True,{},SimpleNamespace(require=lambda _:None)): + with self.assertRaises(WorkbenchError) as caught: + stop_source(self.dataset,backup_parent_fd=self.parent_fd,backup_directory='forged',controller_guard=guard) + self.assertEqual(caught.exception.code,'forbidden') + with self.assertRaises(WorkbenchError):StoppedSourceLease(None,None,None,None) + with preview_source(self.dataset) as forged: + forged.backup_receipt={} + forged.__class__=StoppedSourceLease + with self.assertRaises(WorkbenchError):forged.require() + with self.assertRaises(WorkbenchError):ProjectTransfer(forged).extract(self.selection,'0'*64,self.parent_fd,'forged','No forged authority','forged-key') + self.assertFalse(self.dataset._closed) + self.assertFalse((self.root/'forged').exists()) + def test_unknown_custody_and_peer_ownership_cannot_mint_lease(self): + self.dataset.runs._blocked=True + with self.assertRaises(WorkbenchError) as caught:self.stop() + self.assertEqual(caught.exception.code,'unknown') + self.assertFalse((self.root/'verified-backup').exists()) + self.assertFalse(self.dataset._closed) + self.dataset.runs._blocked=False + def test_peer_lock_handoff_after_close_refuses_lease(self): + from metadata import _key + peer=psycopg.connect(self.dataset.db.dsn,autocommit=True);self.addCleanup(peer.close) + original=self.dataset.close + def close_and_claim(): + original() + self.assertTrue(peer.execute('SELECT pg_try_advisory_lock(%s)',(_key(self.dataset.db.schema+':worker:generation_jobs'),)).fetchone()[0]) + with patch.object(self.dataset,'close',side_effect=close_and_claim),self.assertRaises(WorkbenchError) as caught:self.stop() + self.assertEqual(caught.exception.code,'busy') + self.assertFalse((self.root/'verified-backup').exists()) + def test_stopped_source_sql_and_external_byte_changes_are_detected(self): + record=self.text();source=self.stop() + source.read_root('fixture','source.json') + with psycopg.connect(source.db.dsn,autocommit=True) as connection: + connection.execute(sql.SQL('UPDATE {}.workbench_records SET name=%s WHERE id=%s').format(sql.Identifier(source.db.schema)),('bypass attempt',record['id'])) + with self.assertRaises(WorkbenchError) as caught:_assert_unchanged(source) + self.assertEqual(caught.exception.code,'stale_source') + def test_registered_source_change_blocks_before_publication(self): + self.text();source=self.stop();source.read_root('fixture','source.json') + (self.project_root/'source.json').write_bytes(self.raw_manifest+b' ') + with self.assertRaises(WorkbenchError) as caught:_assert_unchanged(source) + self.assertEqual(caught.exception.code,'stale_source') + self.assertFalse((self.root/'extracted').exists()) + def test_handler_inflight_is_drained_then_new_requests_are_fenced(self): + entered=threading.Event();release=threading.Event();errors=[] + handler=make_handler(self.dataset) + original=handler._handle_GET + # The actual HTTP gate in _observe_request surrounds this handler body. + def blocked_get(request): + entered.set();release.wait(5);return original(request) + handler._handle_GET=blocked_get + server=ThreadingHTTPServer(('127.0.0.1',0),handler) + thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() + self.addCleanup(server.server_close);self.addCleanup(server.shutdown) + def request(): + try: + connection=http.client.HTTPConnection(*server.server_address,timeout=10) + connection.request('GET','/');response=connection.getresponse();response.read();connection.close() + except BaseException as error:errors.append(error) + client=threading.Thread(target=request);client.start();self.assertTrue(entered.wait(5)) + def stop(): + try: + with self.dataset.access.bind(self.token):self.leases.append(self.dataset.stop_for_project_extraction(self.parent_fd,'verified-backup')) + except BaseException as error:errors.append(error) + stopper=threading.Thread(target=stop);stopper.start();time.sleep(.1) + # If do_GET replacement was outside the gate this proves nothing; inspect + # the real gate state before releasing the in-flight HTTP operation. + self.assertTrue(self.dataset._extraction_stopping) + self.assertTrue(stopper.is_alive()) + release.set();client.join(10);stopper.join(20) + self.assertFalse(stopper.is_alive());self.assertEqual(errors,[]) + connection=http.client.HTTPConnection(*server.server_address,timeout=10) + connection.request('GET','/');response=connection.getresponse();response.read();connection.close() + self.assertEqual(response.status,409) + def test_selected_text_working_file_material_extract_replay_and_cold_rebuild(self): + row=self.text();unrelated=self.text('Keep unrelated global data unassigned') + script=b'raise AssertionError("extraction must never run me")\n' + (self.project_root/'scripts').mkdir();(self.project_root/'scripts'/'neutral.py').write_bytes(script) + self.selection['roots'].update(records=[row['id']],working_files=[dict(kind='script',path='scripts/neutral.py')]) + source=self.stop();baseline=tree_manifest(self.dataset.path);transfer=ProjectTransfer(source) + report=transfer.preview(self.selection) + self.assertTrue(report['can_extract'],report['blockers']) + self.assertTrue(any(item['id']==unrelated['id'] for item in report['unassigned'])) + receipt=transfer.extract(self.selection,report['report_sha256'],self.parent_fd,'extracted','New portable project','extract-once') + self.assertTrue(receipt['committed']);self.assertFalse(receipt['selected']) + self.assertEqual(receipt['verification']['active_data_records'],1) + with ProjectStore.open(self.root/'extracted') as store: + snapshot=store.snapshot();decode_records(snapshot['records']) + self.assertNotEqual(snapshot['project_id'],source.db.dataset_id) + active=[r for r in snapshot['records'] if r['family']=='workbench_record' and r['document']['current']['id']!=row['id']] + self.assertEqual(len(active),1) + self.assertEqual(active[0]['document']['source']['original_text'],'Exact original e\u0301\r\nλ') + self.assertEqual((store.path/'scripts'/'neutral.py').read_bytes(),script) + self.assertFalse(any(r['family'].startswith('access_') for r in snapshot['records'])) + # Actual ordinary Open under a different installation, with fresh + # physical SQL and current host authority, remains inert. + from access import Access + from metadata import Metadata + from project_projection import ProjectProjection + host=Metadata(self.root/'new-installation') + projected=Metadata(self.root/'cold-projection',dataset_id=snapshot['project_id'],projection_id=uuid.uuid4().hex) + projection=None + try: + authority=Access(host,host.path);token=authority.exchange(authority._local_owner_token())['token'] + with authority.bind(token),patch('runs.Runs.__init__',side_effect=AssertionError('Open started Runs')),patch('synthetic.Jobs.__init__',side_effect=AssertionError('Open started Jobs')): + projection=ProjectProjection(store,projected,authority,{}) + self.assertEqual(projection.data.get(active[0]['id']),active[0]['document']['current']) + for table in ('access_actors','access_credentials','access_admissions','access_execution_grants','workspace_runs'): + self.assertEqual(projected.execute('SELECT count(*) FROM '+table).fetchone()[0],0) + self.assertEqual(store.snapshot(),snapshot) + finally: + if projection is not None:projection.close() + else:projected.close() + host.close() + with psycopg.connect(source.db.dsn,autocommit=True) as cleanup: + for database in (projected,host):cleanup.execute(sql.SQL('DROP SCHEMA {} CASCADE').format(sql.Identifier(database.schema))) + replay=transfer.extract(self.selection,report['report_sha256'],self.parent_fd,'extracted','New portable project','extract-once') + self.assertEqual({k:v for k,v in replay.items() if k!='replayed'},{k:v for k,v in receipt.items() if k!='replayed'}) + self.assertTrue(replay['replayed']) + self.assertEqual(baseline,tree_manifest(self.dataset.path));_assert_unchanged(source) + with self.assertRaises(ProjectStoreError):transfer.extract(self.selection,report['report_sha256'],self.parent_fd,'extracted','Changed name','extract-once') + + def test_response_preference_model_study_shared_closure_is_exact(self): + from test_project_history import policy + parent=self.text();unrelated=self.text('Unassigned other project source') + answers=[] + for completion in ('Unique first answer\r\n','Unique second answer λ'): + answers.append(self.dataset.workbench.save_response(dict(id=uuid.uuid4().hex,prompt_id=parent['id'],revision=0, + parent_revision=1,source_revision=1,completion=completion,review='draft'))['response']) + judgment=self.dataset.workbench.preferences.save(dict(id=uuid.uuid4().hex,prompt_id=parent['id'],parent_revision=1,source_revision=1, + left_id=answers[0]['id'],left_revision=1,right_id=answers[1]['id'],right_revision=1,revision=0, + outcome='left',rationale='Exact authored distinction',review='human_reviewed'))['judgment'] + model=self.dataset.models.create(dict(name='Original model metadata',task=dict(kind='task',catalog_id=self.dataset.db.dataset_id, + id='neutral',revision=1,sha256='b'*64),kind='template',specification={'unique':'λ'},source_selector=None,preprocessing_policy={},output_policy={})) + changed=copy.deepcopy(model);changed['name']='Renamed observed metadata' + model=self.dataset.models.save(model['id'],dict(revision=1,document=changed)) + study=self.dataset.studies.create(dict(name='Chosen actual scientific intent',limits=dict(attempt_slots=2,admitted_wall_seconds=100, + retained_bytes=10000,peak_staging_bytes=10000),policy=policy())) + self.selection['roots'].update(preferences=[judgment['id']],model_definitions=[model['id']],studies=[study['id']]) + source=self.stop();transfer=ProjectTransfer(source);report=transfer.preview(self.selection) + self.assertTrue(report['can_extract'],report['blockers']) + required={(v['family'],v['id']) for v in report['required']} + self.assertIn(('workbench_record',parent['id']),required) + self.assertIn(('workbench_response',answers[0]['id']),required) + self.assertTrue(any(v['id']==unrelated['id'] for v in report['unassigned'])) + self.assertTrue(any(v['code']=='observed_history_incomplete' for v in report['warnings'])) + baseline=tree_manifest(self.dataset.path) + receipt=transfer.extract(self.selection,report['report_sha256'],self.parent_fd,'closure','Closure destination','closure-key') + with ProjectStore.open(self.root/'closure') as store: + records=store.snapshot()['records'];fork=next(r['document'] for r in records if r['family']=='project_fork') + self.assertEqual(next(r['document']['current'] for r in records if r['family']=='workbench_preference' and r['id']==judgment['id']),judgment) + fresh=next(r['document']['current'] for r in records if r['family']=='workbench_preference' and r['id']!=judgment['id']) + self.assertEqual((fresh['review'],fresh['revision']),('draft',1)) + self.assertIn(dict(family='workbench_response',id=answers[0]['id']),fork['archived']) + kept=next(r['document']['current'] for r in records if r['family']=='model_definition' and r['id'].startswith(source.db.dataset_id+'.')) + self.assertEqual(kept,model) + saved_study=next(r['document']['study'] for r in records if r['family']=='study_history') + self.assertEqual(saved_study,study) + self.assertFalse(any(r['family'].startswith('access_') for r in records)) + bad=copy.deepcopy(records);next(r['document'] for r in bad if r['family']=='project_extraction')['input_sha256']='0'*64 + with self.assertRaises(WorkbenchError):decode_records(bad) + self.assertEqual(receipt['verification']['active_data_records'],1) + self.assertEqual(baseline,tree_manifest(self.dataset.path)) + + def test_required_exclusion_and_absent_history_block_publication(self): + parent=self.text();answer=self.dataset.workbench.save_response(dict(id=uuid.uuid4().hex,prompt_id=parent['id'],revision=0, + parent_revision=1,source_revision=1,completion='Unique answer',review='draft'))['response'] + search=self.dataset.searches.create(dict(name='Original criteria',criteria={k:'' for k in ('q','kind','review','sort','task','label','group','rights')})) + self.dataset.searches.mutate(search['id'],'rename',dict(revision=1,name='Saved later name')) + self.selection['roots'].update(responses=[answer['id']],searches=[search['id']]) + self.selection['decisions']=[dict(family='workbench_record',id=parent['id'],include=False,reason='Deliberate ownership exclusion')] + source=self.stop();transfer=ProjectTransfer(source);report=transfer.preview(self.selection) + self.assertFalse(report['can_extract']) + self.assertIn('required_dependency_excluded',{v['code'] for v in report['blockers']}) + self.assertIn('intent_history_unavailable',{v['code'] for v in report['blockers']}) + with self.assertRaises(WorkbenchError) as caught:transfer.extract(self.selection,report['report_sha256'],self.parent_fd,'blocked','Blocked','block-key') + self.assertEqual(caught.exception.code,'extraction_blocked') + self.assertFalse((self.root/'blocked').exists()) + self.assertEqual(list(self.root.glob('.tuldok-extraction-*')),[]) + + def test_failed_final_rename_preserves_stage_and_lost_rename_ack_reconciles(self): + from project_transfer import _rename_noreplace as rename + self.selection['roots']['records']=[self.text()['id']] + source=self.stop();transfer=ProjectTransfer(source);report=transfer.preview(self.selection) + def fail_before(parent,old,new): + if new=='failed':raise OSError('Injected final publication failure') + return rename(parent,old,new) + with patch('project_transfer._rename_noreplace',side_effect=fail_before),self.assertRaises(OSError) as caught: + transfer.extract(self.selection,report['report_sha256'],self.parent_fd,'failed','Retained stage','before-key') + stage=self.root/caught.exception.staging_directory + self.assertTrue(stage.is_dir());self.assertFalse((self.root/'failed').exists()) + with ProjectStore.open(stage) as store:self.assertTrue(store.reconcile('before-key',next(r['document']['input_sha256'] for r in store.snapshot()['records'] if r['family']=='project_extraction'))) + def lose_ack(parent,old,new): + rename(parent,old,new) + if new=='published':raise OSError('Injected lost rename acknowledgement') + with patch('project_transfer._rename_noreplace',side_effect=lose_ack),self.assertRaises(WorkbenchError) as lost: + transfer.extract(self.selection,report['report_sha256'],self.parent_fd,'published','Known committed identity','after-key') + self.assertEqual(lost.exception.code,'unknown');self.assertTrue(lost.exception.receipt['committed']) + self.assertFalse(lost.exception.receipt['selected']);self.assertTrue((self.root/'published').exists()) + receipt=transfer.extract(self.selection,report['report_sha256'],self.parent_fd,'published','Known committed identity','after-key') + self.assertEqual(receipt['project_id'],lost.exception.receipt['project_id']);self.assertTrue(receipt['replayed']) + _assert_unchanged(source) + + def test_current_inspect_agent_can_preview_but_cannot_stop_or_reuse_revoked_view(self): + from access import AccessError + agent=self.dataset.access.issue(dict(label='Finite extraction inspector',scopes=['inspect'])) + with self.dataset.access.bind(agent['token']): + source=preview_source(self.dataset);self.addCleanup(source.close) + self.assertFalse(ProjectTransfer(source).preview(self.selection)['can_extract']) + with self.assertRaises(AccessError):self.dataset.stop_for_project_extraction(self.parent_fd,'agent-backup') + with self.dataset.access.bind(self.token):self.dataset.access.revoke(agent['identity']['credential_id']) + with self.assertRaises(AccessError):source.read_catalog('.tuldok.json') + self.assertFalse(self.dataset._extraction_stopping) + self.assertFalse((self.root/'agent-backup').exists()) + + def test_standalone_owned_image_operation_is_cancelled_and_drained(self): + from image_generation import Operation + operation=Operation();identity=uuid.uuid4().hex + with self.dataset.image_requests.lock:self.dataset.image_requests.active[identity]=operation + def worker(): + operation.cancelled.wait(5) + with self.dataset.image_requests.lock:self.dataset.image_requests.active.pop(identity,None) + operation.done.set() + thread=threading.Thread(target=worker);thread.start();self.addCleanup(thread.join,5) + source=self.stop() + self.assertTrue(operation.cancelled.is_set());self.assertTrue(operation.done.is_set()) + self.assertFalse(self.dataset.image_requests.active);source.require() + + def test_parent_revision_two_response_fork_keeps_original_binding_and_fresh_prompt(self): + parent=self.text() + parent=self.dataset.workbench.save(parent['id'],dict(revision=1,source_revision=1,task='text_classification', + annotation={'label':'authored'},groups=parent['groups'],review='draft')) + answer=self.dataset.workbench.save_response(dict(id=uuid.uuid4().hex,prompt_id=parent['id'],revision=0,parent_revision=2, + source_revision=1,completion='Exact postannotation answer',review='draft'))['response'] + self.selection['roots']['responses']=[answer['id']] + source=self.stop();transfer=ProjectTransfer(source);report=transfer.preview(self.selection) + self.assertTrue(report['can_extract'],report['blockers']) + transfer.extract(self.selection,report['report_sha256'],self.parent_fd,'revision-two','Fresh prompt','revision-two-key') + with ProjectStore.open(self.root/'revision-two') as store: + records=store.snapshot()['records'] + original=next(r for r in records if r['family']=='workbench_response' and r['id']==answer['id']) + fresh=next(r for r in records if r['family']=='workbench_response' and r['id']!=answer['id']) + self.assertEqual(original['document']['history'][0]['parent']['revision'],2) + self.assertEqual(fresh['document']['history'][0]['parent']['revision'],1) + self.assertEqual(original['document']['current'],answer) + + def test_native_original_bytea_extracts_without_reexport_or_regeneration(self): + import meshes + from test_meshes import fixture + prepared=meshes.prepare(*fixture()) + row=self.dataset.workbench.meshes.admit(prepared,dict(groups=['native-fixture'],rights='Unique native authored source')) + original=bytes(self.dataset.db.execute('SELECT bundle FROM workbench_mesh_assets WHERE id=%s',(row['id'],)).fetchone()[0]) + self.selection['roots']['records']=[row['id']] + source=self.stop();transfer=ProjectTransfer(source);report=transfer.preview(self.selection) + self.assertTrue(report['can_extract'],report['blockers']) + # Existing pure owner validation may reparse the bundle; custody must + # publish the exact original bytes rather than a newly encoded archive. + receipt=transfer.extract(self.selection,report['report_sha256'],self.parent_fd,'native','Native custody','native-key') + from project_artifacts import ProjectArtifacts + with ProjectStore.open(self.root/'native') as store,ProjectArtifacts(store) as artifacts: + records=store.snapshot()['records'];source_record=next(r for r in records if r['family']=='workbench_record' and r['id']==row['id']) + edge=source_record['document']['source']['asset']['artifact'] + self.assertEqual(artifacts.read_blob(dict(sha256=edge['sha256'],bytes=len(original))),original) + self.assertEqual(source_record['document']['current'],row) + self.assertEqual(receipt['verification']['active_data_records'],1) + self.assertEqual(bytes(source.db.execute('SELECT bundle FROM workbench_mesh_assets WHERE id=%s',(row['id'],)).fetchone()[0]),original) + + def test_missing_unique_run_input_remains_explicitly_blocked(self): + from test_project_history import legacy_run + run=legacy_run(self.dataset.db.dataset_id) + with self.dataset.db.lock,self.dataset.db.transaction(): + self.dataset.db.execute('INSERT INTO workspace_runs VALUES(%s,%s,%s,%s)',(run['id'],run['request_key'],run['request_sha256'],json.dumps(run))) + self.selection['roots']['runs']=[run['id']] + source=self.stop();transfer=ProjectTransfer(source);report=transfer.preview(self.selection) + self.assertFalse(report['can_extract']) + self.assertIn('missing_unique_payload',{v['code'] for v in report['blockers']}) + self.assertTrue(any(v['sha256']==run['release_sha256'] and v['preservation']=='unique' and v['state']=='missing' for v in report['artifacts'])) + with self.assertRaises(WorkbenchError):transfer.extract(self.selection,report['report_sha256'],self.parent_fd,'missing','Missing unique source','missing-key') + self.assertFalse((self.root/'missing').exists()) + + def test_selected_working_symlink_and_hardlink_refuse_without_following(self): + target=self.root/'outside.py';target.write_bytes(b'outside secret fixture never captured') + (self.project_root/'scripts').mkdir();(self.project_root/'scripts'/'unsafe.py').symlink_to(target) + self.selection['roots']['working_files']=[dict(kind='script',path='scripts/unsafe.py')] + source=self.stop();transfer=ProjectTransfer(source);report=transfer.preview(self.selection) + self.assertFalse(report['can_extract']);self.assertEqual(report['working_files'],[]) + self.assertFalse(any(v['sha256']==hashlib.sha256(target.read_bytes()).hexdigest() for v in report['artifacts'])) + (self.project_root/'scripts'/'unsafe.py').unlink();os.link(target,self.project_root/'scripts'/'unsafe.py') + report=transfer.preview(self.selection) + self.assertFalse(report['can_extract']);self.assertEqual(report['working_files'],[]) + + def test_full_two_study_scientific_custody_extracts_and_cold_rebuilds(self): + """Reuse strict historical source authorship, without an execution claim. + + The readonly fixture's capture boundary delegates to an actual opened + Dataset's stop guard. Neither the proxy nor a fabricated JSON proof is + ever supplied to ProjectTransfer as its source capability. + """ + import test_project_extraction as legacy_fixtures + from project_extraction import capture_legacy + holder={};expected={} + class SourceResult: + def __getattr__(_,name):return getattr(holder['source'],name) + def files(): + return dict(catalog=tree_manifest(self.dataset.path),registered=tree_manifest(self.project_root)) + def capture(_,roots): + self.selection['roots']=roots + before_stop=files() + source=self.stop();holder['source']=source + holder['baseline']=files() + holder['stop_changes']=[group+'/'+path for group in before_stop for path in set(before_stop[group])|set(holder['baseline'][group]) if before_stop[group].get(path)!=holder['baseline'][group].get(path)] + transfer=ProjectTransfer(source);report=transfer.preview(self.selection) + self.assertTrue(report['can_extract'],report['blockers']) + required={(v['family'],v['id']) for v in report['required']} + self.assertEqual(len([v for v in report['included'] if v['family']=='study_history']),2) + self.assertEqual(len([v for v in report['included'] if v['family']=='trial_history']),2) + self.assertEqual(len([v for v in report['included'] if v['family']=='candidate_history']),1) + expected['captured']=capture_legacy(source,self.selection) + receipt=transfer.extract(self.selection,report['report_sha256'],self.parent_fd,'science','Independent scientific custody','science-key') + self.assertEqual(receipt['verification']['active_data_records'],1) + from project_artifacts import ProjectArtifacts + from project_history import decode_records as history + from project_material import ProjectMaterial + with ProjectStore.open(self.root/'science') as store,ProjectArtifacts(store) as artifacts: + snapshot=store.snapshot();records=snapshot['records'] + view=SimpleNamespace(snapshot=snapshot,artifacts=artifacts,check=lambda:None) + facts=history(records,materials=ProjectMaterial(view),artifacts=artifacts) + self.assertEqual(len(facts.studies),2);self.assertEqual(len(facts.trials),2) + self.assertEqual(len(facts.candidates),1);self.assertEqual(len(facts.reservations),1);self.assertEqual(len(facts.runs),1) + for original in expected['captured']['records']: + self.assertIn(original,records) + if original['family']=='project_artifact' and source.artifacts.status(original['document'])['state']=='verified': + blob=dict(sha256=original['id'],bytes=original['document']['bytes']) + self.assertEqual(artifacts.read_blob(blob),source.artifacts.read_blob(blob)) + self.assertFalse(any(row['family'].startswith('access_') for row in records)) + _assert_unchanged(source) + self.assertEqual(files(),holder['baseline']) + return expected['captured'] + def fixture_assert_equal(actual,expected,*args): + # The reused capture-only fixture compares all files around capture. + # This adapter explicitly stops first; its source preservation + # baseline therefore belongs after stop/backup, as the API promises. + if type(actual) is dict and set(actual)=={'catalog','registered'} and 'baseline' in holder: + return self.assertEqual(actual,holder['baseline'],*args) + return self.assertEqual(actual,expected,*args) + fixture=SimpleNamespace(dataset=self.dataset,db=self.dataset.db,access=self.dataset.access, + actor=self.actor,workbench=self.dataset.workbench,text=self.text,files=files, + view=lambda:SourceResult(),capture=capture,assertEqual=fixture_assert_equal,assertTrue=self.assertTrue) + legacy_fixtures.LegacyCapture.test_two_study_lineage_reservation_candidate_and_run_science_are_captured(fixture) + self.assertEqual(holder['stop_changes'],['catalog/workspace-runs/.coordinator-v1.json']) + + def test_noncooperating_owned_close_stays_bounded_fenced_and_has_no_lease(self): + entered=threading.Event();release=threading.Event();original=self.dataset.generation_jobs.close + def blocked_close(): + entered.set();release.wait(5);original() + actual=self.dataset.close_for_project_extraction + def bounded(**kwargs):return actual(deadline=time.monotonic()+.1) + try: + with patch.object(self.dataset.generation_jobs,'close',side_effect=blocked_close),patch.object(self.dataset,'close_for_project_extraction',side_effect=bounded): + started=time.monotonic() + with self.assertRaises(WorkbenchError) as caught:self.stop() + self.assertEqual(caught.exception.code,'source_busy') + self.assertLess(time.monotonic()-started,2) + self.assertTrue(entered.is_set());self.assertTrue(self.dataset._extraction_closer.is_alive()) + self.assertTrue(self.dataset._extraction_stopping);self.assertEqual(self.leases,[]) + self.assertFalse((self.root/'verified-backup').exists()) + with self.assertRaises(WorkbenchError): + with self.dataset._project_request():pass + with self.assertRaises(WorkbenchError):self.dataset.stop_for_project_extraction(self.parent_fd,'retry') + release.set();self.dataset._extraction_closer.join(5) + self.assertFalse(self.dataset._extraction_closer.is_alive());self.assertTrue(self.dataset._closed) + finally: + release.set() + if self.dataset._extraction_closer is not None:self.dataset._extraction_closer.join(5) + + def test_retry_after_destination_image_edit_verifies_current_destination(self): + import base64 + import io + from PIL import Image + from access import Access + from metadata import Metadata + from project_projection import ProjectProjection + self.selection['roots']['records']=[self.text()['id']] + source=self.stop();transfer=ProjectTransfer(source);report=transfer.preview(self.selection) + birth=transfer.extract(self.selection,report['report_sha256'],self.parent_fd,'edited','Editable destination','edited-key') + host=Metadata(self.root/'edit-host');projection=None;db=None + try: + authority=Access(host,host.path);token=authority.exchange(authority._local_owner_token())['token'] + with ProjectStore.open(self.root/'edited') as store,authority.bind(token): + db=Metadata(self.root/'edit-projection',dataset_id=store.snapshot()['project_id'],projection_id=uuid.uuid4().hex) + projection=ProjectProjection(store,db,authority,{}) + image=io.BytesIO();Image.new('RGB',(4,3),'purple').save(image,'PNG');raw=image.getvalue() + current=projection.data.import_image(dict(image=base64.b64encode(raw).decode(),name='New destination-only image', + groups=['new-destination'],rights='New authored rights'),projection.session.snapshot['checkpoint'],'new-image') + checkpoint=projection.session.snapshot['checkpoint'] + self.assertNotEqual(checkpoint,birth['checkpoint']) + self.assertNotIn(hashlib.sha256(raw).hexdigest(),source.artifacts.declarations) + # The trusted controller pins its selected Store; replay must + # borrow that exact writer rather than opening a second one. + with authority.lock,projection.session.lock,db.lock: + selected=transfer.extract(self.selection,report['report_sha256'],self.parent_fd,'edited','Editable destination','edited-key',opened_store=store) + self.assertTrue(selected['replayed']);self.assertEqual(selected['checkpoint'],checkpoint) + self.assertEqual(selected['verification']['active_data_records'],2) + self.assertEqual(store.snapshot()['checkpoint'],checkpoint) + with authority.lock,projection.session.lock,db.lock,self.assertRaises(WorkbenchError): + transfer.extract(self.selection,report['report_sha256'],self.parent_fd,'different','Wrong destination','different-key',opened_store=store) + projection.close();projection=None + replay=transfer.extract(self.selection,report['report_sha256'],self.parent_fd,'edited','Editable destination','edited-key') + self.assertTrue(replay['replayed']);self.assertEqual(replay['project_id'],birth['project_id']) + self.assertEqual(replay['checkpoint'],checkpoint);self.assertEqual(replay['verification']['checkpoint'],checkpoint) + self.assertEqual(replay['verification']['active_data_records'],2) + self.assertEqual(replay['source'],birth['source']);self.assertEqual(replay['input_sha256'],birth['input_sha256']) + self.assertEqual(replay['artifacts'],birth['artifacts']);self.assertFalse(replay['selected']) + finally: + if projection is not None:projection.close() + elif db is not None:db.close() + host.close() + with psycopg.connect(source.db.dsn,autocommit=True) as cleanup: + for database in (db,host): + if database is not None:cleanup.execute(sql.SQL('DROP SCHEMA {} CASCADE').format(sql.Identifier(database.schema))) + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_project_transfer_clients.py b/tests/test_project_transfer_clients.py new file mode 100644 index 00000000..bb15545f --- /dev/null +++ b/tests/test_project_transfer_clients.py @@ -0,0 +1,156 @@ +"""Five fixed owner extraction contracts and explicit authority handoff. + +Controlled replies qualify client refusal/correlation. Actual source ownership, +stop and extraction are exercised by controller and public HTTP tests elsewhere. +""" +import copy +from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer +import io +import json +from pathlib import Path +import tempfile +import threading +import unittest +from unittest.mock import Mock,patch + +import agent_api as api +import project_cli +from tuldok_client import ProjectTransferClient + +SOURCE,CONTROL,BINDING,NEXT_BINDING,PROJECT=(c*32 for c in 'abcdf') +SHA,OTHER='e'*64,'f'*64 +TOKEN='fixture-only-transfer-owner-token' + + +def source():return dict(catalog_id=SOURCE,schema_version=10,catalog_signature=SHA,snapshot_sha256=SHA, + backup_receipt_sha256=None,external_inventory_sha256=SHA) + + +def state(stopped=False): + return dict(format='project_transfer_state_v1',binding=NEXT_BINDING if stopped else BINDING, + phase='source_stopped' if stopped else 'source_live',source_catalog_id=SOURCE,control_catalog_id=CONTROL, + roots=['output'],available_actions=['status','preview','report_page'],report=None, + backup=dict(root='output',directory='backup',receipt_sha256=SHA) if stopped else None, + destination=dict(root='output',directory='extracted') if stopped else None,failure=None,last_receipt=None) + + +def receipt(): + return dict(format='project_transfer_receipt_v1',committed=True,project_id=PROJECT,checkpoint=OTHER,request_key='k', + input_sha256=SHA,report_sha256=SHA,source=dict(source(),backup_receipt_sha256=SHA), + artifacts=dict(copied_count=1,missing_regenerable_count=0),verification=dict(checkpoint=OTHER,active_data_records=1),replayed=False) + + +def result(): + return dict(format='project_transfer_extraction_result_v1',binding=NEXT_BINDING,receipt=receipt(), + selection=dict(selected=False,state=None,error=dict(code='project_selection_unavailable',detail='Published; explicit Open required.'))) + + +def ready(value,catalog=CONTROL): + client=api.ProjectTransferOwnerClient('http://127.0.0.1:12345',TOKEN,catalog) + client._ready=True;client._selected={name:1 for name in api.PROJECT_TRANSFER_OPERATIONS} + client._request=Mock(return_value={'status':200,'result':value}) + return client + + +class TransferClients(unittest.TestCase): + def test_five_closed_operations_and_default_unavailable_profiles(self): + self.assertEqual(api.PROJECT_TRANSFER_OPERATIONS,api.ProjectTransferOwnerClient.OPERATIONS) + for profile in ('catalog','project'): + manifest=api.capabilities(SOURCE,profile=profile) + found=[op for op in manifest['operations'] if op['id'] in api.PROJECT_TRANSFER_OPERATIONS] + self.assertEqual(len(found),5);self.assertTrue(all(op['availability']=='unavailable' for op in found)) + self.assertLessEqual(len(api.encoded(manifest)),api.CAPABILITIES_LIMIT) + for name in api.PROJECT_TRANSFER_OPERATIONS: + self.assertTrue(callable(getattr(ProjectTransferClient(Mock()),name.split('.')[1]))) + self.assertEqual(api.contract(name)['authority'],'owner') + self.assertEqual(api.contract(name)['bounds']['response_bytes'],1048576) + selection=dict(project_snapshot=dict(id=SOURCE,manifest_sha256=SHA),roots={'records':[PROJECT]},decisions=[]) + api.validate_arguments('project_transfer.preview',dict(binding=BINDING,selection=selection)) + for bad in (dict(selection,credential='forbidden'),dict(selection,stopped_source_lease={}), + dict(selection,roots={'arbitrary_files':['/etc/passwd']})): + with self.assertRaises(api.AgentAPIError):api.validate_arguments('project_transfer.preview',dict(binding=BINDING,selection=bad)) + for directory in ('../escape','a/b','a\\b','.','..','bad\nname'): + with self.assertRaises(api.AgentAPIError):api.validate_arguments('project_transfer.extract',dict(binding=NEXT_BINDING,report_sha256=SHA, + destination_root='output',directory=directory,name='Extracted',request_key='k')) + + def test_owner_bootstrap_limits_tools_and_stop_requires_independent_new_client(self): + client=api.ProjectTransferOwnerClient('http://127.0.0.1:12345',TOKEN,SOURCE) + def request(method,path,body=None,**kwargs): + if path=='/api/access/session':value=dict(kind='owner',catalog_id=SOURCE) + elif path=='/api/workspace/context':value=dict(dataset_id=SOURCE) + elif path=='/api/agent/capabilities': + value=api.capabilities(SOURCE) + for op in value['operations']: + if op['id'] in api.PROJECT_TRANSFER_OPERATIONS:op['availability']='available' + else:value=state(True) + return dict(status=200,result=value) + client._request=Mock(side_effect=request) + self.assertEqual(set(client.bootstrap()['operations']),api.PROJECT_TRANSFER_OPERATIONS) + self.assertEqual({t['name'] for t in client.tool_definitions()},{name.replace('.','_') for name in api.PROJECT_TRANSFER_OPERATIONS}) + count=client._request.call_count + with self.assertRaises(api.AgentAPIError):client.call('project_files.open',{}) + self.assertEqual(client._request.call_count,count) + stopped=ProjectTransferClient(client).stop(binding=BINDING,report_sha256=SHA,backup_root='output',backup_directory='backup',destination_root='output',directory='extracted') + self.assertEqual(stopped['phase'],'source_stopped');count=client._request.call_count + with self.assertRaises(api.AgentAPIError) as failure:ProjectTransferClient(client).status() + self.assertEqual(failure.exception.code,'authentication_handoff');self.assertEqual(client._request.call_count,count) + control=ready(state(True));self.assertEqual(ProjectTransferClient(control).status(),stopped) + wrong=ready(state(True),catalog=SOURCE) + with self.assertRaises(api.AgentAPIError):ProjectTransferClient(wrong).status() + + def test_paged_report_and_published_unselected_receipt_correlate_without_retry(self): + page=dict(format='project_transfer_report_page_v1',binding=NEXT_BINDING,report_sha256=SHA,section='included', + items=[dict(family='workbench_record',id=PROJECT,revision=1)],offset=0,limit=40,total=1) + client=ready(page);sdk=ProjectTransferClient(client) + sdk.report_page(binding=NEXT_BINDING,report_sha256=SHA,section='included') + for change in ({'binding':BINDING},{'report_sha256':OTHER},{'section':'unassigned'},{'offset':1},{'total':41}): + client._request.return_value=dict(status=200,result=dict(page,**change)) + with self.assertRaises(api.AgentAPIError):sdk.report_page(binding=NEXT_BINDING,report_sha256=SHA,section='included') + args=dict(binding=NEXT_BINDING,report_sha256=SHA,destination_root='output',directory='extracted',name='Extracted',request_key='k') + client._request.return_value=dict(status=200,result=result());self.assertFalse(sdk.extract(**args)['selection']['selected']) + bad=result();bad['receipt']['request_key']='other';client._request.return_value=dict(status=200,result=bad) + with self.assertRaises(api.AgentAPIError):sdk.extract(**args) + bad=result();bad['selection']['selected']=True;client._request.return_value=dict(status=200,result=bad) + with self.assertRaises(api.AgentAPIError):sdk.extract(**args) + client._request.return_value=dict(status=503,result={'code':'unknown','error':'Inspect explicit current status.'});before=client._request.call_count + with self.assertRaises(api.AgentAPIError):sdk.extract(**args) + self.assertEqual(client._request.call_count,before+1) + + def test_real_http_preserves_only_exact_partial_receipt_and_enforces_one_mib(self): + partial=dict(committed=True,selected=False,project_id=PROJECT,checkpoint=OTHER,request_key='k',directory='extracted') + value=dict(code='unknown',error='Published; inspect exact destination.',receipt=partial);oversized=False + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + self.rfile.read(int(self.headers.get('Content-Length','0')));raw=api.encoded(value) + self.send_response(503);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(1048577 if oversized else len(raw)));self.end_headers() + if not oversized:self.wfile.write(raw) + def log_message(self,*_):pass + server=ThreadingHTTPServer(('127.0.0.1',0),Handler);thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() + self.addCleanup(server.server_close);self.addCleanup(thread.join,5);self.addCleanup(server.shutdown) + client=api.ProjectTransferOwnerClient(f'http://127.0.0.1:{server.server_port}',TOKEN,CONTROL) + client._ready=True;client._selected={'project_transfer.extract':1} + args=dict(binding=NEXT_BINDING,report_sha256=SHA,destination_root='output',directory='extracted',name='Extracted',request_key='k') + with self.assertRaises(api.AgentAPIError) as failure:ProjectTransferClient(client).extract(**args) + self.assertEqual(failure.exception.receipt,partial) + value['receipt']=dict(partial,credential=TOKEN) + with self.assertRaises(api.AgentAPIError) as failure:ProjectTransferClient(client).extract(**args) + self.assertEqual(failure.exception.code,'transport') + oversized=True + with self.assertRaises(api.AgentAPIError) as failure:ProjectTransferClient(client).extract(**args) + self.assertEqual(failure.exception.code,'response_too_large') + + def test_named_cli_selects_separate_owner_and_preserves_selection_document(self): + with tempfile.TemporaryDirectory(prefix='transfer-cli-fixture-') as directory: + selection=dict(project_snapshot=dict(id=SOURCE,manifest_sha256=SHA),roots={'records':[PROJECT]},decisions=[]) + file=Path(directory)/'selection.json';file.write_text(json.dumps(selection)) + client=Mock();client.call.return_value=dict(status=200,result=state()) + output=io.StringIO() + with (patch.object(project_cli.ProjectTransferOwnerClient,'from_config',return_value=client) as constructor, + patch('sys.stdout',output)): + code=project_cli.main(['--config','/not-read','project-transfer','preview','--binding',BINDING,'--selection',str(file)]) + self.assertEqual(code,0);constructor.assert_called_once() + self.assertEqual(client.call.call_args.args[:2],('project_transfer.preview',dict(binding=BINDING,selection=selection))) + self.assertEqual(json.loads(output.getvalue()),state()) + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_project_transfer_clients_http.py b/tests/test_project_transfer_clients_http.py new file mode 100644 index 00000000..6bd9ff2b --- /dev/null +++ b/tests/test_project_transfer_clients_http.py @@ -0,0 +1,235 @@ +"""One frozen M5 SDK/CLI/MCP workflow over the actual legacy HTTP controller. + +Only a disposable fixture is authored. Stop uses the original live Dataset; +the two client configurations contain independent current owner credentials. +No runtime, provider, model preparation or scientific execution is qualified. +""" +from datetime import timedelta +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import time +import unittest +import uuid + +import agent_api as api +from backup_metadata import tree_manifest +from project_transfer import _assert_unchanged +from tuldok_client import ProjectTransferClient +import test_project_transfer_controller as controller_fixture + +ROOT = Path(__file__).resolve().parents[1] +SDK_AVAILABLE = all(importlib.util.find_spec(name) for name in ('mcp', 'anyio', 'jsonschema')) + + +def frozen_subject(): + name = os.environ.get('TULDOK_M5_CLIENTS_MANIFEST') + if not name: + raise AssertionError('Provide the immutable M5 clients qualification manifest') + path = Path(name) + value = json.loads(path.read_text()) + if value['qualification_copy'] != str(ROOT): + raise AssertionError('Run this acceptance test in the selected immutable copy') + current = {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in value['files']} + if current != value['files']: + raise AssertionError('Immutable M5 client source changed before acceptance') + return dict(manifest_sha256=hashlib.sha256(path.read_bytes()).hexdigest(), + parent_manifest_sha256=value['parent_manifest_sha256'], files=current) + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL') and SDK_AVAILABLE, + 'Requires disposable PostgreSQL and the approved optional MCP dependencies') +class ProjectTransferClientsHTTP(unittest.TestCase): + def setUp(self): + self.subject = frozen_subject() + self.started, self.performed = time.monotonic(), [] + self.pins = {name: api.contract(name)['sha256'] for name in sorted(api.PROJECT_TRANSFER_OPERATIONS)} + self.assertEqual(len(self.pins), 5) + # Reuse only fixture authoring/cleanup, never inherit its TestCase or run + # its controller test methods as a second acceptance campaign. + self.fixture = controller_fixture.TransferControllerTests('test_actual_http_source_control_handoff_report_and_extraction') + self.fixture.setUp() + self.addCleanup(self.fixture.doCleanups) + self.addCleanup(self.receipt) + f = self.fixture + f.workspace.transfer_controller = f.controller # Same attachment as normal application startup. + self.server = f.server() + self.origin = 'http://127.0.0.1:' + str(self.server.server_port) + self.environment = {key: value for key, value in os.environ.items() if not key.startswith(('TULDOK_', 'PG'))} + self.environment['PYTHONDONTWRITEBYTECODE'] = '1' + self.source_config = self.config('source', f.source_token, f.dataset.db.dataset_id) + self.control_config = self.config('control', f.control_token, f.workspace.db.dataset_id) + + def receipt(self): + current = {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in self.subject['files']} + output = Path(os.environ.get('TULDOK_QA_OUTPUT_ROOT', '/workspace/scratch/tuldok-save-load-qa/artifacts')) / 'm5-clients' + output.mkdir(parents=True, exist_ok=True) + value = dict(test=self.id(), seconds=round(time.monotonic() - self.started, 3), performed=self.performed, + qualification_manifest_sha256=self.subject['manifest_sha256'], + parent_manifest_sha256=self.subject['parent_manifest_sha256'], source_stable=current == self.subject['files'], + contract_sha256=self.pins, test_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + fixture='Actual owner SDK, CLI subprocesses and MCP SDK stdio over real HTTP; disposable legacy/control PG catalogs; authored text and inert script only') + (output / (self._testMethodName + '-' + uuid.uuid4().hex + '.json')).write_text(json.dumps(value, indent=2) + '\n') + self.assertEqual(current, self.subject['files'], 'Immutable M5 source changed during acceptance') + + def config(self, label, token, catalog_id): + private = self.fixture.root / (label + '.token') + config = self.fixture.root / (label + '.config.json') + private.write_text(token + '\n'); private.chmod(0o600) + config.write_text(json.dumps(dict(endpoint=self.origin, catalog_id=catalog_id, token_file=str(private), timeout=30))) + config.chmod(0o600) + return config + + def cli(self, config, action, *flags): + result = subprocess.run([sys.executable, str(ROOT / 'project_cli.py'), '--config', str(config), + 'project-transfer', action, *flags], cwd=ROOT, env=self.environment, capture_output=True, text=True, timeout=45) + for token in (self.fixture.source_token, self.fixture.control_token): + self.assertNotIn(token, result.stdout + result.stderr) + self.assertEqual(result.returncode, 0, result.stderr) + return json.loads(result.stdout) + + def parameters(self, config): + from mcp import StdioServerParameters + return StdioServerParameters(command=sys.executable, args=[str(ROOT / 'mcp_stdio.py'), '--config', str(config), + '--project-transfer-owner'], cwd=str(ROOT), env=self.environment) + + async def tools(self, session): + definitions = {tool.name: tool for tool in (await session.list_tools()).tools} + expected = {api.operation(name)['mcp_name'] for name in api.PROJECT_TRANSFER_OPERATIONS} + self.assertEqual(set(definitions), expected) + preview = definitions[api.operation('project_transfer.preview')['mcp_name']] + self.assertEqual(preview.inputSchema, api.operation('project_transfer.preview')['inputSchema']) + self.assertEqual(preview.outputSchema, api.operation('project_transfer.preview')['outputSchema']) + return definitions + + async def tool(self, session, action, arguments, status=200): + name = 'project_transfer.' + action + value = await session.call_tool(api.operation(name)['mcp_name'], arguments) + envelope = value.structuredContent + self.assertEqual(envelope['status'], status, envelope) + self.assertEqual(value.isError, status >= 400) + self.assertEqual(json.loads(value.content[0].text), envelope) + api.validate_output(name, envelope) + return envelope['result'] + + def test_owner_handoff_and_single_extraction_through_sdk_cli_and_real_mcp(self): + import anyio + from mcp import ClientSession + from mcp.client.stdio import stdio_client + + f = self.fixture + sentinel = f.root / 'must-not-execute' + script = ('from pathlib import Path\nPath(' + repr(str(sentinel)) + ').write_text("executed")\n# exact inert Ω\r\n').encode() + (f.registered / 'scripts').mkdir() + (f.registered / 'scripts' / 'inert.py').write_bytes(script) + f.selection['roots']['working_files'] = [dict(kind='script', path='scripts/inert.py')] + source_transport = api.ProjectTransferOwnerClient.from_config(self.source_config) + source = ProjectTransferClient(source_transport) + bootstrap = source_transport.bootstrap() + self.assertEqual(bootstrap['identity']['catalog_id'], f.dataset.db.dataset_id) + self.assertEqual(set(bootstrap['operations']), api.PROJECT_TRANSFER_OPERATIONS) + self.assertEqual(source_transport.selected_contracts(), {name: 1 for name in self.pins}) + initial = source.status() + live = source.preview(f.selection, binding=initial['binding']) + self.assertEqual(live['phase'], 'source_live') + self.assertFalse(live['report']['can_extract']) + first = source.report_page(binding=live['binding'], report_sha256=live['report']['report_sha256'], section='included', limit=1) + second = self.cli(self.source_config, 'report-page', '--binding', live['binding'], '--report-sha256', live['report']['report_sha256'], + '--section', 'included', '--offset', '1', '--limit', '1') + self.assertEqual((first['offset'], second['offset'], len(second['items'])), (0, 1, 1)) + self.assertNotEqual(first['items'][0], second['items'][0]) + self.assertEqual(first['report_sha256'], second['report_sha256']) + config_before = self.source_config.read_bytes() + self.performed.append('Source owner SDK negotiates exactly five pins, previews read-only; named CLI pages the next report item') + + async def exercise(): + source_errors = f.root / 'source-mcp.stderr' + with source_errors.open('w+') as diagnostics: + async with stdio_client(self.parameters(self.source_config), errlog=diagnostics) as (read, write): + async with ClientSession(read, write, read_timeout_seconds=timedelta(seconds=30)) as source_session: + await source_session.initialize() + await self.tools(source_session) + before = await self.tool(source_session, 'status', {}) + self.assertEqual(before['binding'], live['binding']) + working = await self.tool(source_session, 'report_page', dict(binding=live['binding'], report_sha256=live['report']['report_sha256'], section='working_files', offset=0, limit=40)) + self.assertEqual(working['items'], [dict(kind='script', path='scripts/inert.py', sha256=hashlib.sha256(script).hexdigest(), bytes=len(script))]) + self.performed.append('Source MCP exposes only five pinned tools and reads exact inert script availability') + + stopped = source.stop(binding=live['binding'], report_sha256=live['report']['report_sha256'], + backup_root='output', backup_directory='backup', destination_root='output', directory='extracted') + self.assertEqual(stopped['phase'], 'source_stopped') + self.assertNotEqual(stopped['binding'], live['binding']) + baseline = tree_manifest(f.dataset.path) + with self.assertRaises(api.AgentAPIError) as fenced: source.status() + self.assertEqual(fenced.exception.code, 'authentication_handoff') + denied = await self.tool(source_session, 'status', {}, status=401) + self.assertIn(denied['code'], ('unauthenticated', 'forbidden')) + self.assertEqual(self.source_config.read_bytes(), config_before) + self.assertEqual(source_transport.catalog_id, f.dataset.db.dataset_id) + + # New credentials AND catalog configuration are supplied + # explicitly; no old client learns a replacement token. + control_transport = api.ProjectTransferOwnerClient.from_config(self.control_config) + control = ProjectTransferClient(control_transport) + current = control_transport.bootstrap() + self.assertEqual(current['identity']['catalog_id'], f.workspace.db.dataset_id) + self.assertEqual(set(current['operations']), api.PROJECT_TRANSFER_OPERATIONS) + fresh = control.status() + selection_file = f.root / 'explicit-selection.json' + selection_file.write_text(json.dumps(f.selection)) + selection_file.chmod(0o600) + ready = self.cli(self.control_config, 'preview', '--binding', fresh['binding'], '--selection', str(selection_file)) + self.assertTrue(ready['report']['can_extract']) + self.assertNotEqual(ready['report']['report_sha256'], live['report']['report_sha256']) + self.assertEqual(ready['binding'], fresh['binding']) + self.performed.append('One explicit SDK Stop; old SDK fences locally and old MCP receives401; new independently configured owner SDK/CLI identify the control catalog') + + arguments = dict(binding=ready['binding'], report_sha256=ready['report']['report_sha256'], + destination_root='output', directory='extracted', name='Client extraction Ω', request_key='m5-client-extraction-key') + with (f.root / 'control-mcp.stderr').open('w+') as errors: + async with stdio_client(self.parameters(self.control_config), errlog=errors) as (reader, writer): + async with ClientSession(reader, writer, read_timeout_seconds=timedelta(seconds=30)) as session: + await session.initialize() + await self.tools(session) + result = await self.tool(session, 'extract', arguments) + errors.seek(0) + self.assertNotIn(f.source_token, errors.read()) + errors.seek(0) + self.assertNotIn(f.control_token, errors.read()) + self.assertTrue(result['receipt']['committed']) + self.assertFalse(result['receipt']['replayed']) + self.assertTrue(result['selection']['selected']) + self.assertEqual(result['receipt']['verification']['active_data_records'], 1) + self.assertEqual(result['receipt']['source']['catalog_id'], f.dataset.db.dataset_id) + self.assertNotIn(result['receipt']['project_id'], (f.dataset.db.dataset_id, f.workspace.db.dataset_id)) + replay = control.extract(**arguments) + self.assertTrue(replay['receipt']['replayed']) + self.assertTrue(replay['selection']['selected']) + self.assertEqual(replay['receipt']['project_id'], result['receipt']['project_id']) + self.assertEqual(replay['receipt']['checkpoint'], result['receipt']['checkpoint']) + self.assertEqual(replay['selection']['state']['project']['checkpoint'], replay['receipt']['checkpoint']) + self.assertEqual(tree_manifest(f.dataset.path), baseline) + _assert_unchanged(f.controller._source) + self.performed.append('Fresh-control MCP publishes once and selects inertly; SDK exact-key retry reconciles the selected writer with the same verified project/head') + diagnostics.seek(0) + self.assertNotIn(f.source_token, diagnostics.read()) + diagnostics.seek(0) + self.assertNotIn(f.control_token, diagnostics.read()) + return result + + result = anyio.run(exercise) + self.assertEqual((f.publication / 'extracted' / 'scripts' / 'inert.py').read_bytes(), script) + self.assertFalse(sentinel.exists()) + self.assertEqual(f.workspace._store.snapshot()['project_id'], result['receipt']['project_id']) + canonical = json.dumps(f.workspace._store.snapshot(), ensure_ascii=False) + self.assertNotIn(f.source_token, canonical) + self.assertNotIn(f.control_token, canonical) + self.performed.append('Original source remains unchanged; destination retains exact script bytes; no script executes or credentials enter canonical facts') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_project_transfer_controller.py b/tests/test_project_transfer_controller.py new file mode 100644 index 00000000..a9802cff --- /dev/null +++ b/tests/test_project_transfer_controller.py @@ -0,0 +1,345 @@ +"""Finite two-authority extraction controller on disposable PostgreSQL.""" +import hashlib +import http.client +from http.server import ThreadingHTTPServer +import json +import os +from pathlib import Path +import tempfile +import threading +import time +import unittest +from unittest.mock import patch +import uuid + +import psycopg +from psycopg import sql +from app import Dataset, make_handler +import agent_api +from access import AccessError +from backup_metadata import tree_manifest +from project_store import ProjectStore, ProjectStoreError +from project_transfer_controller import ProjectTransferController, _receipt_summary +from project_workspace import ProjectWorkspace +from workbench import WorkbenchError + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'), 'Requires disposable PostgreSQL') +class TransferControllerTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='tuldok-transfer-controller-') + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.registered = self.root / 'registered' + self.publication = self.root / 'publication' + self.registered.mkdir(); self.publication.mkdir() + self.dataset = Dataset(self.root / 'legacy', project_roots={'fixture': self.registered}) + self.workspace = ProjectWorkspace(self.root / 'control', {'output': self.publication}) + self.controller = ProjectTransferController(self.dataset, self.workspace) + self.schemas = {self.dataset.db.schema, self.workspace.db.schema} + self.addCleanup(self.cleanup) + source = self.dataset.access.exchange(self.dataset.access._local_owner_token()) + control = self.workspace.access.exchange(self.workspace.access._local_owner_token()) + self.source_token, self.control_token = source['token'], control['token'] + self.control_credential = control['identity']['credential_id'] + self.enterContext(self.dataset.access.bind(self.source_token)) + manifest = dict(schema_version=1, id=uuid.uuid4().hex, name='Explicit source λ', data=[], recipes=[], evidence=[], requirements=[]) + raw = (json.dumps(manifest, ensure_ascii=False, indent=3) + '\n\n').encode() + (self.registered / 'source.json').write_bytes(raw) + self.dataset.projects.register_snapshot(dict(root='fixture', manifest_path='source.json')) + record = self.dataset.workbench.import_asset(dict(kind='text', name='Exact selected source', text='Original e\u0301\r\nλ', groups=['fixture-source'], parents=[], rights='Fixture permission')) + self.selection = dict(project_snapshot=dict(id=manifest['id'], manifest_sha256=hashlib.sha256(raw).hexdigest()), roots=dict(records=[record['id']]), decisions=[]) + + def cleanup(self): + if self.workspace._projection is not None: + self.schemas.add(self.workspace._projection.db.schema) + self.controller.close(); self.workspace.close(); self.dataset.close() + with psycopg.connect(self.dataset.db.dsn, autocommit=True) as connection: + for schema in self.schemas: + connection.execute(sql.SQL('DROP SCHEMA IF EXISTS {} CASCADE').format(sql.Identifier(schema))) + + def preview(self): + state = self.controller.status() + return self.controller.preview(state['binding'], self.selection) + + def stop(self): + state = self.preview() + return self.controller.stop(state['binding'], state['report']['report_sha256'], 'output', 'backup', 'output', 'extracted') + + def ready(self): + self.stop() + with self.workspace.access.bind(self.control_token): + return self.preview() + + def extract(self, state, key='controller-extract-key'): + return self.controller.extract(state['binding'], state['report']['report_sha256'], 'output', 'extracted', 'Extracted scientific project', key) + + def test_live_preview_is_readonly_paged_and_never_constructs_owners(self): + for number in range(41): + self.dataset.workbench.import_asset(dict(kind='text', name=str(number), text='Unassigned ' + str(number), groups=['fixture-source'], parents=[], rights='Fixture permission')) + before = tree_manifest(self.dataset.path) + with patch('metadata.Metadata.__init__', side_effect=AssertionError('Metadata construction')), patch('runs.Runs.__init__', side_effect=AssertionError('Runs construction')): + state = self.preview() + self.assertFalse(state['report']['can_extract']) + self.assertEqual(state['report']['counts']['unassigned'], 41) + page = self.controller.report_page(state['binding'], state['report']['report_sha256'], 'unassigned') + self.assertEqual((len(page['items']), page['total'], page['offset'], page['limit']), (40, 41, 0, 40)) + self.assertEqual(len(self.controller.report_page(state['binding'], state['report']['report_sha256'], 'unassigned', 40)['items']), 1) + for section, offset, limit in (([], 0, 40), ('unassigned', True, 40), ('unassigned', 0, 41)): + with self.assertRaises(WorkbenchError): self.controller.report_page(state['binding'], state['report']['report_sha256'], section, offset, limit) + with self.assertRaises(WorkbenchError) as caught: self.controller.report_page(state['binding'], '0' * 64, 'unassigned') + self.assertEqual(caught.exception.code, 'stale') + self.assertEqual(before, tree_manifest(self.dataset.path)) + self.assertFalse(self.dataset._extraction_stopping) + + def test_stop_validates_physical_targets_and_current_owner_before_effects(self): + state = self.preview() + (self.publication / 'already-there').mkdir() + with patch.object(self.dataset, 'stop_for_project_extraction', wraps=self.dataset.stop_for_project_extraction) as stop: + for backup, destination in (('same', 'same'), ('already-there', 'new')): + with self.assertRaises(WorkbenchError): self.controller.stop(state['binding'], state['report']['report_sha256'], 'output', backup, 'output', destination) + with self.workspace.access.bind(self.control_token): + with self.assertRaises(AccessError): self.controller.stop(state['binding'], state['report']['report_sha256'], 'output', 'backup', 'output', 'extracted') + stop.assert_not_called() + self.assertFalse(self.dataset._closed) + with self.assertRaises(WorkbenchError): ProjectTransferController(self.dataset, self.workspace, {'inside': self.dataset.path}) + linked = self.root / 'linked'; linked.symlink_to(self.publication, target_is_directory=True) + with self.assertRaises((WorkbenchError, ProjectStoreError, OSError)): ProjectTransferController(self.dataset, self.workspace, {'linked': linked}) + + def test_scoped_agent_cannot_review_or_stop_the_catalog(self): + state = self.preview() + agent = self.dataset.access.issue(dict(label='Fixture scoped reader', scopes=['inspect', 'edit'])) + with self.dataset.access.bind(agent['token']), patch.object(self.dataset, 'stop_for_project_extraction', wraps=self.dataset.stop_for_project_extraction) as stop: + with self.assertRaises(AccessError): self.controller.status() + with self.assertRaises(AccessError): self.controller.preview(state['binding'], self.selection) + with self.assertRaises(AccessError): self.controller.stop(state['binding'], state['report']['report_sha256'], 'output', 'backup', 'output', 'extracted') + stop.assert_not_called() + self.assertFalse(self.dataset._extraction_stopping) + + def test_stop_rotates_binding_and_requires_independent_control_auth_then_cold_open(self): + old = self.controller.status()['binding'] + stopped = self.stop() + self.assertEqual((stopped['phase'], self.controller.phase), ('source_stopped', 'source_stopped')) + self.assertIs(self.controller.access, self.workspace.access) + self.assertIs(self.controller.control_workspace, self.workspace) + self.assertNotEqual(stopped['binding'], old) + self.assertIsNone(stopped['report']) + with self.assertRaises(AccessError): self.controller.status() + baseline = tree_manifest(self.dataset.path) + with self.workspace.access.bind(self.control_token): + with self.assertRaises(WorkbenchError) as caught: self.controller.preview(old, self.selection) + self.assertEqual(caught.exception.code, 'stale') + ready = self.preview() + self.assertTrue(ready['report']['can_extract']) + result = self.extract(ready) + self.assertTrue(result['receipt']['committed']) + self.assertTrue(result['selection']['selected'], result) + self.assertIsNone(result['selection']['error']) + self.assertEqual(result['selection']['state']['project']['checkpoint'], result['receipt']['checkpoint']) + replay = self.extract(ready) + self.assertTrue(replay['receipt']['replayed']) + self.assertEqual(result['receipt']['project_id'], replay['receipt']['project_id']) + with self.assertRaises(ProjectStoreError): + self.controller.extract(ready['binding'], ready['report']['report_sha256'], 'output', 'extracted', 'Changed input', 'controller-extract-key') + self.assertEqual(baseline, tree_manifest(self.dataset.path)) + self.schemas.add(self.workspace._projection.db.schema) + self.workspace.close() + with ProjectStore.open(self.publication / 'extracted') as store: + snapshot = store.snapshot() + self.assertEqual(snapshot['project_id'], result['receipt']['project_id']) + self.assertNotIn('access_credential', {r['family'] for r in snapshot['records']}) + + def test_published_lost_ack_is_visible_and_exact_retry_reconciles(self): + state = self.ready() + from project_transfer import _rename_noreplace + def renamed_then_lost(*arguments): + _rename_noreplace(*arguments) + raise OSError('Controlled lost acknowledgement') + with self.workspace.access.bind(self.control_token): + with patch('project_transfer._rename_noreplace', side_effect=renamed_then_lost), self.assertRaises(WorkbenchError) as caught: + self.extract(state) + self.assertEqual(caught.exception.code, 'unknown') + status = self.controller.status() + self.assertTrue(status['last_receipt']['committed']) + self.assertFalse(status['last_receipt']['selected']) + self.assertEqual(status['failure']['action'], 'extract') + result = self.extract(state) + self.assertTrue(result['receipt']['replayed']) + self.assertTrue(result['selection']['selected']) + self.assertEqual(result['receipt']['project_id'], status['last_receipt']['project_id']) + + def test_known_prepublication_failure_retains_private_staging(self): + state = self.ready() + with self.workspace.access.bind(self.control_token): + with patch('project_transfer._rename_noreplace', side_effect=OSError('Controlled before-rename failure')), self.assertRaises(OSError) as caught: + self.extract(state) + staging = caught.exception.staging_directory + self.assertTrue((self.publication / staging).is_dir()) + self.assertFalse((self.publication / 'extracted').exists()) + failed = self.controller.status() + self.assertIsNone(failed['last_receipt']) + self.assertEqual(failed['failure']['staging_directory'], staging) + result = self.extract(state) + self.assertFalse(result['receipt']['replayed']) + self.assertTrue(result['selection']['selected']) + + def test_open_failure_preserves_committed_receipt_and_retry_selects(self): + state = self.ready() + dispatch = self.workspace.dispatch + def unavailable_open(name, arguments): + if name == 'project_files.open': raise WorkbenchError('Controlled selection failure', 'unavailable', 503) + return dispatch(name, arguments) + with self.workspace.access.bind(self.control_token): + with patch.object(self.workspace, 'dispatch', side_effect=unavailable_open): result = self.extract(state) + self.assertTrue(result['receipt']['committed']) + self.assertFalse(result['selection']['selected']) + self.assertEqual(result['selection']['error']['code'], 'unavailable') + retry = self.extract(state) + self.assertTrue(retry['receipt']['replayed']) + self.assertTrue(retry['selection']['selected']) + + def test_revoked_control_authority_cannot_replay_a_published_extraction(self): + state = self.ready() + with self.workspace.access.bind(self.control_token): + result = self.extract(state) + before = tree_manifest(self.publication / 'extracted') + self.workspace.access.revoke(self.control_credential) + with patch.object(self.controller._transfer, 'extract', wraps=self.controller._transfer.extract) as extract: + with self.assertRaises(AccessError): self.extract(state) + extract.assert_not_called() + self.assertTrue(result['selection']['selected']) + self.assertEqual(before, tree_manifest(self.publication / 'extracted')) + + def test_failed_stop_stays_fenced_without_minting_a_lease(self): + state = self.preview() + def fenced_pending(*arguments): + self.dataset._extraction_stopping = True + raise WorkbenchError('Controlled pending drain', 'busy', 409) + with patch.object(self.dataset, 'stop_for_project_extraction', side_effect=fenced_pending): + with self.assertRaises(WorkbenchError): self.controller.stop(state['binding'], state['report']['report_sha256'], 'output', 'backup', 'output', 'extracted') + self.assertEqual(self.controller.phase, 'source_fenced') + self.assertIsNone(self.controller._source) + with self.assertRaises(AccessError): self.controller.status() + with self.workspace.access.bind(self.control_token): + failed = self.controller.status() + self.assertEqual(failed['available_actions'], ['status']) + self.assertEqual(failed['failure']['code'], 'busy') + with self.assertRaises(WorkbenchError): self.controller.preview(failed['binding'], self.selection) + for name, body in (([], {}), ('project_transfer.status', {'lease': True}), ('project_transfer.extract', {'binding': failed['binding']})): + with self.assertRaises(WorkbenchError): self.controller.dispatch(name, body) + + def test_native_no_effect_stop_denial_keeps_source_authentication(self): + state = self.preview() + with patch.object(self.dataset, 'stop_for_project_extraction', side_effect=AccessError('Revoked before native fence')): + with self.assertRaises(AccessError): self.controller.stop(state['binding'], state['report']['report_sha256'], 'output', 'backup', 'output', 'extracted') + self.assertEqual(self.controller.phase, 'source_live') + self.assertIs(self.controller.access, self.dataset.access) + refreshed = self.controller.status() + self.assertIsNone(refreshed['report']) + self.assertNotEqual(refreshed['binding'], state['binding']) + self.assertFalse(self.dataset._extraction_stopping) + + def server(self, blocked=None): + handler = make_handler(self.dataset, transfer_controller=self.controller) + if blocked is not None: + original = handler._handle_GET + def held(request): + if request.path == '/api/access/session' and request.headers.get('Fixture-Hold') == 'yes': + blocked[0].set() + if not blocked[1].wait(10): raise AssertionError('Fixture handler release timed out') + return original(request) + handler._handle_GET = held + server = ThreadingHTTPServer(('127.0.0.1', 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + self.addCleanup(server.server_close); self.addCleanup(server.shutdown) + return server + + def http(self, server, method, path, token=None, body=None, operation=None, headers=None): + fields = dict(headers or {}) + if token is not None: fields['Authorization'] = 'Bearer ' + token + if operation is not None: fields[agent_api.CONTRACT_HEADER] = agent_api.selected_header(operation) + raw = None if body is None else json.dumps(body).encode() + if raw is not None: fields['Content-Type'] = 'application/json' + connection = http.client.HTTPConnection(*server.server_address, timeout=15) + try: + connection.request(method, path, raw, fields) + response = connection.getresponse() + value = json.loads(response.read()) + result = dict(status=response.status, result=value) + if operation is not None: agent_api.validate_output(operation, result) + return result + finally: + connection.close() + + def transfer_http(self, server, action, token, arguments): + name = 'project_transfer.' + action + method, path, body = agent_api.project_request(name, arguments) + return self.http(server, method, path, token, body, name) + + def test_actual_http_source_control_handoff_report_and_extraction(self): + server = self.server() + initial = self.transfer_http(server, 'status', self.source_token, {}) + self.assertEqual(initial['status'], 200) + live = self.transfer_http(server, 'preview', self.source_token, dict(binding=initial['result']['binding'], selection=self.selection))['result'] + page = self.transfer_http(server, 'report_page', self.source_token, dict(binding=live['binding'], report_sha256=live['report']['report_sha256'], section='included', offset=0, limit=2)) + self.assertEqual(page['status'], 200) + self.assertEqual(page['result']['limit'], 2) + stopped = self.transfer_http(server, 'stop', self.source_token, dict(binding=live['binding'], report_sha256=live['report']['report_sha256'], backup_root='output', backup_directory='backup', destination_root='output', directory='extracted')) + self.assertEqual(stopped['status'], 200) + self.assertEqual(stopped['result']['phase'], 'source_stopped') + self.assertEqual(self.http(server, 'GET', '/api/access/session', self.source_token)['status'], 401) + who = self.http(server, 'GET', '/api/access/session', self.control_token) + self.assertEqual(who['result']['catalog_id'], self.workspace.db.dataset_id) + fresh = self.transfer_http(server, 'status', self.control_token, {})['result'] + ready = self.transfer_http(server, 'preview', self.control_token, dict(binding=fresh['binding'], selection=self.selection))['result'] + result = self.transfer_http(server, 'extract', self.control_token, dict(binding=ready['binding'], report_sha256=ready['report']['report_sha256'], destination_root='output', directory='extracted', name='Actual wire extraction', request_key='http-extraction-key')) + self.assertEqual(result['status'], 200, result) + self.assertTrue(result['result']['selection']['selected']) + self.assertEqual(result['result']['receipt']['project_id'], self.workspace._store.snapshot()['project_id']) + + def test_actual_http_late_source_handler_prevents_early_authentication_handoff(self): + entered, release = threading.Event(), threading.Event() + server = self.server((entered, release)) + self.addCleanup(release.set) + outcomes = [] + def held_request(): + try: outcomes.append(self.http(server, 'GET', '/api/access/session', self.source_token, headers={'Fixture-Hold': 'yes'})) + except BaseException as error: outcomes.append(error) + client = threading.Thread(target=held_request) + client.start() + self.assertTrue(entered.wait(5)) + live = self.preview() + monotonic = time.monotonic + # Advance only the native Stop deadline, without sleeping a minute or + # inventing a cessation lease. The real in-flight request remains held. + with patch('app.time.monotonic', side_effect=lambda: monotonic() + (61 if self.dataset._extraction_stopping else 0)): + stopped = self.transfer_http(server, 'stop', self.source_token, dict(binding=live['binding'], report_sha256=live['report']['report_sha256'], backup_root='output', backup_directory='backup', destination_root='output', directory='extracted')) + self.assertEqual((stopped['status'], stopped['result']['code']), (409, 'source_busy')) + self.assertEqual(self.controller.phase, 'source_fenced') + self.assertFalse(self.dataset._closed) + self.assertTrue(any(self.dataset._extraction_requests.values())) + early = self.http(server, 'GET', '/api/access/session', self.control_token) + self.assertEqual((early['status'], early['result']['code']), (409, 'source_stopping')) + release.set(); client.join(5) + self.assertFalse(client.is_alive()) + self.assertEqual(len(outcomes), 1) + self.assertIsInstance(outcomes[0], dict) + self.assertEqual(outcomes[0]['result']['catalog_id'], self.dataset.db.dataset_id) + current = self.http(server, 'GET', '/api/access/session', self.control_token) + self.assertEqual(current['status'], 200) + self.assertEqual(current['result']['catalog_id'], self.workspace.db.dataset_id) + self.assertEqual(self.http(server, 'GET', '/api/access/session', self.source_token)['status'], 401) + state = self.transfer_http(server, 'status', self.control_token, {}) + self.assertEqual(state['result']['available_actions'], ['status']) + self.assertIsNone(self.controller._source) + + +class TransferReceiptBounds(unittest.TestCase): + def test_many_copied_artifacts_do_not_create_an_oversized_post_commit_ack(self): + native = dict(format='project_extraction_receipt_v1', committed=True, project_id='a' * 32, checkpoint='b' * 64, + request_key='bounded-receipt', input_sha256='c' * 64, report_sha256='d' * 64, source={}, + artifacts=dict(copied=['e' * 64] * 20000, missing_regenerable=['f' * 64] * 20000), verification={}, replayed=False) + receipt = _receipt_summary(native) + self.assertEqual(receipt['artifacts'], dict(copied_count=20000, missing_regenerable_count=20000)) + self.assertLess(len(json.dumps(receipt)), 1024) diff --git a/tests/test_project_workspace.py b/tests/test_project_workspace.py new file mode 100644 index 00000000..0c2dbaee --- /dev/null +++ b/tests/test_project_workspace.py @@ -0,0 +1,342 @@ +"""Workspace recovery/replay boundaries over disposable real PostgreSQL projections.""" +import copy +import errno +import json +import os +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch +import uuid + +import psycopg +from psycopg import sql + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from auth_helpers import ExplicitOwnerFixture +from metadata import connection_info +import project_store +from project_store import ProjectStore, ProjectStoreError +from project_workspace import ProjectWorkspace, _digest +from workbench import WorkbenchError + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'), 'Requires disposable local PostgreSQL') +class ProjectWorkspaceTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='tuldok-project-workspace-') + self.path = Path(self.tmp.name) + self.external = self.path / 'external' + self.external.mkdir() + self.hosts = [] + self.addCleanup(self.tmp.cleanup) + self.addCleanup(self.cleanup_owned) + self.start() + + def start(self, control=None): + control = control or self.path / ('control-' + uuid.uuid4().hex) + workspace = ProjectWorkspace(control, {'fixture': self.external}, dsn=os.environ['TULDOK_DATABASE_URL']) + owner = ExplicitOwnerFixture.bootstrap(workspace) + self.hosts.append((workspace, owner, control)) + self.workspace, self.owner = workspace, owner + + def cleanup_owned(self): + schemas = set() + for workspace, _owner, control in reversed(self.hosts): + workspace.close() + for manifest in Path(control).rglob('.tuldok.json'): + value = json.loads(manifest.read_bytes()) + schemas.add('projection_' + value['projection_id'] if value['version'] == 2 + else 'catalog_' + value['dataset_id']) + with psycopg.connect(connection_info(), autocommit=True) as connection: + for schema in schemas: + connection.execute(sql.SQL('DROP SCHEMA IF EXISTS {} CASCADE').format(sql.Identifier(schema))) + + def call(self, action, arguments=None): + with self.owner.bound(self.workspace): + return self.workspace.dispatch('project_files.' + action, arguments or {}) + + def state(self): + return self.call('status') + + def mutation(self, state=None, **extra): + state = state or self.state() + result = dict(binding=state['binding'], + expected_checkpoint=state['project']['checkpoint'] if state['project'] else None, + request_key=uuid.uuid4().hex) + result.update(extra) + return result + + def create(self, directory='alpha'): + arguments = self.mutation(root='fixture', directory=directory, name='Workspace authored project') + return self.call('new', arguments), arguments + + def test_control_project_ancestry_is_rejected_before_destination_or_projection_creation(self): + with ProjectStore.create(self.external/'science','Existing portable project','existing-project','b'*64):pass + self.start(self.external/'science/control') + control=self.workspace.path + for action,directory in (('open','science'),('new','science/control/child')): + arguments=self.mutation(root='fixture',directory=directory) + if action=='new':arguments['name']='Must not create' + with self.subTest(action=action),self.assertRaisesRegex(ProjectStoreError,'private workspace control') as caught: + self.call(action,arguments) + self.assertEqual(caught.exception.code,'conflict') + self.assertIsNone(self.workspace._projection) + self.assertFalse((control/'.project-projections').exists()) + self.assertFalse((control/'child').exists()) + self.start(self.external/'sibling-control') + created,_=self.create('safe-project') + before=self.workspace._store.snapshot() + bad=self.mutation(root='fixture',directory='sibling-control/child',name='Must not fork') + with self.assertRaisesRegex(ProjectStoreError,'private workspace control') as caught: + self.call('save_as',bad) + self.assertEqual(caught.exception.code,'conflict') + self.assertFalse((self.workspace.path/'child').exists()) + self.assertEqual(self.workspace._store.snapshot(),before) + fork=self.call('save_as',self.mutation(root='fixture',directory='safe-copy',name='Sibling copy')) + self.assertEqual(fork['state']['state'],'open') + self.assertNotEqual(fork['state']['project']['project_id'],created['project']['project_id']) + + def import_record(self, text='Authored source'): + arguments = self.mutation(asset=dict(text=text, name='Source', groups=['owned'], rights='Fixture authorship')) + return self.call('import_text', arguments), arguments + + def annotate(self, row, note='Reviewed note'): + arguments = self.mutation(record_id=row['id'], annotation=dict( + revision=row['revision'], source_revision=1, task='text_corpus', + annotation={'note': note}, groups=['owned'], review='human_reviewed')) + return self.call('annotate', arguments), arguments + + def assert_code(self, code, action, arguments): + with self.assertRaises((WorkbenchError, ProjectStoreError)) as caught: + self.call(action, arguments) + self.assertEqual(caught.exception.code, code) + return caught.exception + + def unknown_import(self): + before = self.state() + arguments = self.mutation(before, asset=dict(text='Uncertain publication', groups=['owned'])) + replace, fsync = project_store.os.replace, project_store.os.fsync + published = False + def after_replace(*args, **kwargs): + nonlocal published + replace(*args, **kwargs) + published = True + def fail_directory(fd): + if published and fd == self.workspace._store._meta_fd: + raise OSError(errno.EIO, 'actual checkpoint directory fsync failure') + return fsync(fd) + with patch.object(project_store.os, 'replace', side_effect=after_replace), patch.object( + project_store.os, 'fsync', side_effect=fail_directory): + self.assert_code('unknown', 'import_text', arguments) + return before, arguments + + def test_unknown_fsync_status_and_explicit_current_open_reconcile_canonical_edit(self): + self.create() + before, edit = self.unknown_import() + recovery = self.state() + self.assertEqual(recovery['state'], 'recovery_required') + self.assertEqual(recovery['binding'], before['binding']) + self.assertEqual(recovery['project'], before['project']) + self.assertEqual(recovery['available_actions'], ['open']) + self.assertEqual(recovery['recovery'], dict(action='open', root='fixture', directory='alpha', + reason='unknown_publication', request_key=edit['request_key'])) + self.assert_code('projection_unavailable', 'close', self.mutation(recovery)) + self.assert_code('stale_project', 'open', self.mutation(recovery, root='fixture', directory='alpha', + expected_checkpoint='a' * 64)) + opened = self.call('open', self.mutation(recovery, root='fixture', directory='alpha')) + self.assertEqual(opened['state'], 'open') + self.assertNotEqual(opened['binding'], before['binding']) + self.assertNotEqual(opened['project']['checkpoint'], before['project']['checkpoint']) + replay = dict(edit, binding=opened['binding']) + result = self.call('import_text', replay) + self.assertEqual(result['record']['text'], 'Uncertain publication') + self.assertEqual(result['record']['revision'], 1) + with self.owner.bound(self.workspace): + self.assertEqual(len(self.workspace._text.records()['items']), 1) + + def test_unknown_recovery_does_not_succeed_until_directory_sync_failure_clears(self): + self.create() + _before, edit = self.unknown_import() + recovery = self.state() + old_projection = self.workspace._projection + metadata = (self.external / 'alpha/.tuldok').stat() + fsync = project_store.os.fsync + def fail_metadata_sync(fd): + info = os.fstat(fd) + if (info.st_dev, info.st_ino) == (metadata.st_dev, metadata.st_ino): + raise OSError(errno.EIO, 'persistent checkpoint directory fsync failure') + return fsync(fd) + reopen = self.mutation(recovery, root='fixture', directory='alpha') + with patch.object(project_store.os, 'fsync', side_effect=fail_metadata_sync): + self.assert_code('unavailable', 'open', reopen) + self.assertEqual(self.state(), recovery) + self.assertIs(self.workspace._projection, old_projection) + opened = self.call('open', reopen) + self.assertEqual(opened['state'], 'open') + self.assertEqual(self.call('import_text', dict(edit, binding=opened['binding']))['record']['revision'], 1) + + def test_new_reconciliation_does_not_select_before_failed_parent_barrier_succeeds(self): + before = self.state() + arguments = self.mutation(before, root='fixture', directory='created', name='Await durable New') + parent = self.external.stat() + fsync = project_store.os.fsync + def fail_published_parent(fd): + info = os.fstat(fd) + if ((info.st_dev, info.st_ino) == (parent.st_dev, parent.st_ino) + and (self.external / 'created').exists()): + raise OSError(errno.EIO, 'persistent New parent directory fsync failure') + return fsync(fd) + with patch.object(project_store.os, 'fsync', side_effect=fail_published_parent): + self.assert_code('unavailable', 'new', arguments) + self.assertEqual(self.state(), before) + with ProjectStore.open(self.external / 'created') as created: + expected = created.snapshot() + selected = self.call('new', arguments) + self.assertEqual(selected['project']['project_id'], expected['project_id']) + self.assertEqual(selected['project']['checkpoint'], expected['checkpoint']) + + def test_real_sql_commit_failure_reports_durable_receipt_and_explicit_open_restores(self): + self.create() + before = self.state() + projection = self.workspace._projection + projection.execute("""CREATE FUNCTION fixture_workspace_refuse_commit() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN RAISE EXCEPTION 'fixture deferred rejection' USING ERRCODE='23514'; END $$""") + projection.execute("""CREATE CONSTRAINT TRIGGER fixture_workspace_commit_failure AFTER INSERT ON workbench_records + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION fixture_workspace_refuse_commit()""") + arguments = self.mutation(before, asset=dict(text='Durable despite rejected SQL commit', groups=['owned'])) + failure = self.assert_code('projection_unavailable', 'import_text', arguments) + canonical = self.workspace._store.snapshot() + self.assertEqual(failure.receipt, dict(committed=True, selected=True, project_id=canonical['project_id'], + checkpoint=canonical['checkpoint'], request_key=arguments['request_key'])) + self.assertEqual(projection.execute('SELECT count(*) FROM workbench_records').fetchone()[0], 0) + recovery = self.state() + self.assertEqual(recovery['state'], 'recovery_required') + self.assertEqual(recovery['project'], before['project']) + self.assertEqual(recovery['recovery']['reason'], 'projection_unavailable') + opened = self.call('open', self.mutation(recovery, root='fixture', directory='alpha')) + self.assertEqual(opened['project']['checkpoint'], canonical['checkpoint']) + result = self.call('import_text', dict(arguments, binding=opened['binding'])) + self.assertEqual(result['record']['text'], 'Durable despite rejected SQL commit') + self.assertEqual(self.workspace._projection.execute('SELECT count(*) FROM workbench_records').fetchone()[0], 1) + self.assert_code('stale_project', 'import_text', arguments) + + def test_failed_reconstruction_keeps_recovery_generation_and_old_projection(self): + self.create() + before, edit = self.unknown_import() + projection = self.workspace._projection + recovery = self.state() + arguments = self.mutation(recovery, root='fixture', directory='alpha') + with patch.object(self.workspace, '_projection_for', side_effect=WorkbenchError('SQL unavailable', 'projection_unavailable', 503)): + self.assert_code('projection_unavailable', 'open', arguments) + self.assertIs(self.workspace._projection, projection) + self.assertEqual(self.state(), recovery) + opened = self.call('open', arguments) + self.assertEqual(opened['state'], 'open') + self.assertNotEqual(opened['binding'], before['binding']) + self.assertEqual(self.call('import_text', dict(edit, binding=opened['binding']))['record']['revision'], 1) + + def test_new_projection_failure_reports_created_unselected_and_exact_retry(self): + before = self.state() + arguments = self.mutation(before, root='fixture', directory='created', name='Created unselected') + with patch.object(self.workspace, '_projection_for', side_effect=RuntimeError('Projection initialization failed')): + failure = self.assert_code('project_selection_unavailable', 'new', arguments) + self.assertEqual(self.state(), before) + self.assertEqual(failure.receipt, dict(committed=True, selected=False, project_id=failure.project_id, + checkpoint=failure.checkpoint, request_key=arguments['request_key'], root='fixture', directory='created')) + with ProjectStore.open(self.external / 'created') as retained: + self.assertEqual(retained.snapshot()['project_id'], failure.project_id) + selected = self.call('new', arguments) + self.assertEqual(selected['project']['project_id'], failure.project_id) + self.assertEqual(selected['project']['checkpoint'], failure.checkpoint) + + def test_new_detach_failure_keeps_original_and_reconciles_after_restart(self): + original, _ = self.create('original') + arguments = self.mutation(root='fixture', directory='created', name='New survives failed Close') + with patch.object(self.workspace, '_detach', side_effect=WorkbenchError('Close barrier failed', 'unavailable', 503)): + failure = self.assert_code('project_selection_unavailable', 'new', arguments) + self.assertEqual(self.state(), original) + self.workspace.close() + self.start() + restarted = self.mutation(root='fixture', directory='created', name=arguments['name'], request_key=arguments['request_key']) + selected = self.call('new', restarted) + self.assertEqual(selected['project']['project_id'], failure.project_id) + + def test_new_receipt_rejects_changed_semantics_and_unrelated_existing_folder(self): + arguments = self.mutation(root='fixture', directory='created', name='Original name') + with patch.object(self.workspace, '_projection_for', side_effect=RuntimeError('Projection failed')): + self.assert_code('project_selection_unavailable', 'new', arguments) + before = (self.external / 'created/.tuldok/project.json').read_bytes() + self.assert_code('conflict', 'new', dict(arguments, name='Changed name')) + self.assertEqual((self.external / 'created/.tuldok/project.json').read_bytes(), before) + with ProjectStore.create(self.external / 'unrelated', 'Other owner', 'unrelated', 'b' * 64): + pass + other = self.mutation(root='fixture', directory='unrelated', name='Original name') + self.assert_code('conflict', 'new', other) + self.assertEqual(self.state()['state'], 'no_project_open') + + def test_lifecycle_replay_is_idempotent_only_while_resulting_state_is_current(self): + created, create_args = self.create() + self.assertEqual(self.call('new', create_args), created) + self.assert_code('conflict', 'new', dict(create_args, name='Changed payload')) + imported, _ = self.import_record() + self.assert_code('stale_project', 'new', create_args) + save_args = self.mutation() + saved = self.call('save', save_args) + self.assertEqual(self.call('save', save_args), saved) + updated, _ = self.annotate(imported['record']) + self.assert_code('stale_project', 'save', save_args) + close_args = self.mutation() + closed = self.call('close', close_args) + self.assertEqual(self.call('close', close_args), closed) + open_args = self.mutation(root='fixture', directory='alpha') + opened = self.call('open', open_args) + self.assertEqual(self.call('open', open_args), opened) + self.assertEqual(opened['state'], 'open') + self.assert_code('stale_project', 'close', close_args) + self.assertEqual(self.state(), opened) + self.call('close', self.mutation()) + self.assert_code('stale_project', 'open', open_args) + self.assertEqual(updated['record']['revision'], 2) + + def test_replay_history_never_exceeds_original_returned_revision(self): + self.create() + imported, import_args = self.import_record() + annotated, annotate_args = self.annotate(imported['record']) + self.annotate(annotated['record'], 'Third revision') + original = self.call('import_text', import_args) + self.assertEqual(original['record']['revision'], 1) + self.assertEqual([item['revision'] for item in original['history']], [1]) + self.assertIsNone(original['next_history_before_revision']) + original_review = self.call('annotate', annotate_args) + self.assertEqual(original_review['record']['revision'], 2) + self.assertEqual([item['revision'] for item in original_review['history']], [2]) + self.assertEqual(original_review['next_history_before_revision'], 2) + + def test_query_and_history_pages_are_bounded_and_complete(self): + self.create() + first, _ = self.import_record('First source') + second, _ = self.import_record('Second source') + current = self.state() + query = dict(binding=current['binding'], checkpoint=current['project']['checkpoint'], limit=1) + page = self.call('query', query) + self.assertEqual(len(page['items']), 1) + self.assertNotIn('text', page['items'][0]) + self.assertNotIn('annotation', page['items'][0]) + other = self.call('query', dict(query, after_id=page['next_after_id'])) + self.assertIsNone(other['next_after_id']) + self.assertEqual({page['items'][0]['id'], other['items'][0]['id']}, {first['record']['id'], second['record']['id']}) + changed, _ = self.annotate(first['record']) + self.annotate(changed['record'], 'Revision three') + current = self.state() + get = dict(binding=current['binding'], checkpoint=current['project']['checkpoint'], record_id=first['record']['id'], history_limit=2) + detail = self.call('get', get) + self.assertEqual([item['revision'] for item in detail['history']], [3, 2]) + older = self.call('get', dict(get, before_revision=detail['next_history_before_revision'])) + self.assertEqual([item['revision'] for item in older['history']], [1]) + self.assertIsNone(older['next_history_before_revision']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_project_workspace_custody.py b/tests/test_project_workspace_custody.py new file mode 100644 index 00000000..b22a9b1f --- /dev/null +++ b/tests/test_project_workspace_custody.py @@ -0,0 +1,197 @@ +"""Flag-gated real Workspace custody boundaries, PostgreSQL and loopback HTTP. + +These tests do not qualify public M3 availability. A real local provider remains +active while an actual canonical commit loses its acknowledgement. Fault patches +inject only the acknowledgement failure; owners and dispatch remain production. +""" +import fcntl +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import os +from pathlib import Path +import tempfile +import threading +import unittest +from unittest.mock import patch +from urllib.error import HTTPError +from urllib.request import Request, urlopen +import uuid + +import psycopg +from psycopg import sql + +import agent_api +from app import make_handler +from auth_helpers import ExplicitOwnerFixture +from metadata import connection_info +from project_store import ProjectStoreError +import project_workspace +from workbench import WorkbenchError, encode + + +@unittest.skipUnless(os.environ.get('TULDOK_DATABASE_URL'), 'Requires disposable real PostgreSQL') +class ProjectWorkspaceCustodyTests(unittest.TestCase): + def setUp(self): + self.temp=tempfile.TemporaryDirectory(prefix='tuldok-workspace-custody-') + self.root=Path(self.temp.name);self.external=self.root/'external';self.external.mkdir() + self.provider_entered=threading.Event();self.provider_release=threading.Event() + self.provider_disconnected=threading.Event();self.provider_requests=[];self.worker_errors=[] + self.workspace=None;self.servers=[] + self.previous_hook=threading.excepthook + threading.excepthook=lambda event:self.worker_errors.append(event.exc_value) + self.addCleanup(self.cleanup) + self.flag=patch.object(project_workspace,'M3_ENABLED',True);self.flag.start() + self.workspace=project_workspace.ProjectWorkspace(self.root/'control',{'fixture':self.external}) + self.owner=ExplicitOwnerFixture.bootstrap(self.workspace) + fixture=self + class Provider(BaseHTTPRequestHandler): + def log_message(self,*args):pass + def send(self,value): + raw=encode(value).encode();self.send_response(200) + self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(raw)));self.end_headers() + try:self.wfile.write(raw) + except (BrokenPipeError,ConnectionResetError):fixture.provider_disconnected.set() + def do_GET(self): + fixture.provider_requests.append(('GET',self.path)) + self.send({'data':[{'id':'fixture-model','owned_by':'fixture'}]}) + def do_POST(self): + fixture.provider_requests.append(('POST',self.path)) + self.rfile.read(int(self.headers.get('Content-Length','0'))) + fixture.provider_entered.set();fixture.provider_release.wait(20) + self.send({'model':'fixture-model','choices':[{'finish_reason':'stop','message':{'content':encode({'label':'positive'})}}]}) + self.provider=self.server(Provider) + self.provider_url='http://127.0.0.1:'+str(self.provider.server_port) + self.application=self.server(make_handler(self.workspace)) + self.origin='http://127.0.0.1:'+str(self.application.server_port) + state=self.state() + status,state=self.call('new',dict(binding=state['binding'],expected_checkpoint=None,request_key=uuid.uuid4().hex, + root='fixture',directory='owned-project',name='Owned custody boundaries')) + self.assertEqual(status,200,state) + status,imported=self.call('data_import_text',self.envelope(text='Exact owned text café 山.',name='Owned source',groups=['owned'],parents=[],rights='Authored fixture')) + self.assertEqual(status,200,imported) + self.source=imported['value'] + + def server(self,handler): + server=ThreadingHTTPServer(('127.0.0.1',0),handler) + thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() + self.servers.append((server,thread));return server + + def call(self,action,body=None,*,token=None): + name='project_files.'+action;operation=agent_api.operation(name) + headers={'Origin':self.origin,'Content-Type':'application/json', + 'Authorization':'Bearer '+(token or self.owner.token), + 'Tuldok-Operation-Contract':agent_api.selected_header(name)} + request=Request(self.origin+operation['path'],data=None if body is None else encode(body).encode(),headers=headers,method=operation['method']) + try:response=urlopen(request,timeout=15) + except HTTPError as error:response=error + with response:return response.status,json.loads(response.read()) + + def state(self): + status,value=self.call('status');self.assertEqual(status,200,value);return value + + def envelope(self,**values): + state=self.state() + return dict(binding=state['binding'],expected_checkpoint=state['project']['checkpoint'],request_key=uuid.uuid4().hex,**values) + + def start_provider(self): + status,result=self.call('execution_producer_start',self.envelope(family='text_proposal_history',document=dict( + request_id=uuid.uuid4().hex,source_id=self.source['id'],revision=self.source['revision'],source_revision=self.source['source_revision'], + server_url=self.provider_url,model='fixture-model',instruction='Select only an exact offered label.',seed=7,labels=['positive','negative']))) + self.assertEqual(status,200,result) + self.assertTrue(self.provider_entered.wait(10),'Actual provider POST was not entered') + projection=self.workspace._projection;adapter=projection.execution.producer_execution + owner=adapter._owners['text_proposal_history'] + self.assertIsNone(projection.execution._runs) + self.assertTrue(owner.worker.is_alive());self.assertFalse(owner.stop.is_set()) + self.assertTrue(projection.custody.has_pending) + return projection,owner,result + + def assert_same_locked_selection(self,store,projection,root_fd,physical): + self.assertIs(self.workspace._store,store);self.assertIs(self.workspace._projection,projection) + self.assertEqual(store._root_fd,root_fd) + observed=os.fstat(root_fd);self.assertEqual((observed.st_dev,observed.st_ino),physical) + competing=os.open(self.external/'owned-project',os.O_RDONLY|os.O_DIRECTORY) + try: + with self.assertRaises(BlockingIOError):fcntl.flock(competing,fcntl.LOCK_EX|fcntl.LOCK_NB) + finally:os.close(competing) + self.assertFalse(projection.db.connection.closed) + + def stop_fixture_worker(self,owner): + # Explicit disposable-fixture disposal, not a successful application + # cancellation/recovery claim. Unknown provider cessation stays unknown. + owner.stop.set();self.provider_release.set();owner.worker.join(10) + self.assertFalse(owner.worker.is_alive()) + + def test_recovery_open_keeps_producer_only_store_projection_and_real_root_flock(self): + projection,owner,_=self.start_provider();state=self.state() + store=self.workspace._store;root_fd=store._root_fd;st=os.fstat(root_fd);physical=(st.st_dev,st.st_ino) + original=store.commit;injected=[] + def lost_ack(*args,**kwargs): + result=original(*args,**kwargs) + injected.append(result['checkpoint']) + raise ProjectStoreError('Authored canonical acknowledgement loss after actual commit','unknown',409) + with patch.object(store,'commit',lost_ack): + status,error=self.call('data_import_text',dict(binding=state['binding'],expected_checkpoint=state['project']['checkpoint'], + request_key=uuid.uuid4().hex,text='Canonical result selected before lost acknowledgement.',name='Lost reply source',groups=['owned'],parents=[],rights='Authored fault fixture')) + self.assertEqual(status,409,error);self.assertEqual(error['code'],'unknown');self.assertEqual(len(injected),1) + self.assertFalse(projection.session.available) + recovery=self.state();self.assertEqual(recovery['state'],'recovery_required') + self.assertTrue(owner.worker.is_alive());self.assertFalse(owner.stop.is_set()) + self.assert_same_locked_selection(store,projection,root_fd,physical) + arguments=dict(binding=recovery['binding'],expected_checkpoint=recovery['project']['checkpoint'],request_key=uuid.uuid4().hex,root='fixture',directory='owned-project') + status,error=self.call('open',arguments) + self.assertEqual(status,503,error);self.assertEqual(error['code'],'projection_unavailable') + self.assertTrue(owner.worker.is_alive());self.assertFalse(owner.stop.is_set()) + self.assert_same_locked_selection(store,projection,root_fd,physical) + # The old local provider thread can drain without any Run owner, yet no + # generic provider cessation receipt exists. Recovery remains fenced. + self.stop_fixture_worker(owner) + status,error=self.call('open',dict(arguments,request_key=uuid.uuid4().hex)) + self.assertEqual(status,503,error);self.assertEqual(error['code'],'projection_unavailable') + self.assertTrue(projection.custody.has_pending) + self.assert_same_locked_selection(store,projection,root_fd,physical) + self.assertIsNone(projection.execution._runs) + + def test_normal_stale_reconcile_checkpoint_refuses_before_any_provider_drain(self): + projection,owner,_=self.start_provider();stale=self.state() + store=self.workspace._store;root_fd=store._root_fd;st=os.fstat(root_fd);physical=(st.st_dev,st.st_ino) + status,changed=self.call('data_import_text',dict(binding=stale['binding'],expected_checkpoint=stale['project']['checkpoint'], + request_key=uuid.uuid4().hex,text='A later canonical edit.',name='Later source',groups=['owned'],parents=[],rights='Authored fixture')) + self.assertEqual(status,200,changed);self.assertNotEqual(changed['checkpoint'],stale['project']['checkpoint']) + before=store.snapshot();thread=owner.worker + status,error=self.call('execution_reconcile_current',dict(binding=stale['binding'],expected_checkpoint=stale['project']['checkpoint'])) + self.assertEqual(status,409,error);self.assertEqual(error['code'],'stale_project') + self.assertIs(owner.worker,thread);self.assertTrue(thread.is_alive());self.assertFalse(owner.stop.is_set()) + self.assertFalse(self.provider_release.is_set());self.assertFalse(self.provider_disconnected.is_set()) + self.assertEqual(store.snapshot(),before) + self.assertEqual(self.state()['state'],'open') + self.assert_same_locked_selection(store,projection,root_fd,physical) + self.assertEqual(self.provider_requests,[('GET','/v1/models'),('POST','/v1/chat/completions')]) + + def cleanup(self): + try: + if self.workspace is not None: + projection=self.workspace._projection + if projection is not None: + adapter=projection.execution.producer_execution + for owner in adapter._owners.values(): + if owner.worker is not None and owner.worker.is_alive():self.stop_fixture_worker(owner) + self.provider_release.set() + for server,thread in reversed(self.servers):server.shutdown();server.server_close();thread.join(3) + # Production correctly refuses Close for unresolved generic provider + # effects. Dispose only the test-owned handles after actual joins. + if self.workspace is not None: + ws=self.workspace + schemas=[ws.db.schema] + if ws._projection is not None: + schemas.append(ws._projection.db.schema) + ws._projection.artifacts.close();ws._projection.db.close() + if ws._store is not None:ws._store.close() + for _path,descriptor in ws._roots.values():os.close(descriptor) + ws._roots.clear();ws.db.close();ws._closed=True + with psycopg.connect(connection_info(),autocommit=True) as connection: + for schema in schemas:connection.execute(sql.SQL('DROP SCHEMA IF EXISTS {} CASCADE').format(sql.Identifier(schema))) + self.assertTrue(all(isinstance(error,(WorkbenchError,ValueError)) for error in self.worker_errors),self.worker_errors) + finally: + threading.excepthook=self.previous_hook + self.flag.stop();self.temp.cleanup() diff --git a/tests/test_recipe_process.py b/tests/test_recipe_process.py index 9a8402eb..e9df4093 100644 --- a/tests/test_recipe_process.py +++ b/tests/test_recipe_process.py @@ -126,6 +126,45 @@ def test_barrier_complete_exact_output_and_cold_receipt(self): wrong=dict(handle.expected_identity,request_sha256='b'*64) with self.assertRaises(custody.ProcessError):custody.load_receipt(handle.directory/'receipt.json',wrong) + def test_ready_owner_ack_wait_is_separate_from_preparation_and_child_wall(self): + spec=self.spec();handle=self.start(spec) + deadline=time.monotonic()+custody.READY_SECONDS+.2 + while time.monotonic()({state:'prepared',candidate_id:CANDIDATE,trial:{format:'trial_v1',id:'1'.repeat(32),study_id:STUDY,material:{kind:'recipe_material',catalog_id:P,id:C,sha256:C},replicate:0},graph:{id:'2'.repeat(32),revision:1,nodes:[],edges:[]},model:{kind:'model',catalog_id:P,id:ACTOR,revision:1,sha256:C},runtime_snapshot_sha256:C}); +const recovery=()=>({...state(),state:'recovery_required',available_actions:['open'],recovery:{action:'open',root:'fixture',directory:'alpha',reason:'projection_unavailable',request_key:'pending-preparation-key'}}); +(async()=>{ + { + const h=mount();await tick();for(const [id,value] of Object.entries({'project-candidate-study':STUDY,'project-candidate-id':CANDIDATE,'project-candidate-revision':'1','project-candidate-directory':'package','project-candidate-runtime':C}))h.input(id,value);h.nodes.get('project-candidate-kind').value='package'; + h.answers.push(({options})=>{const body=JSON.parse(options.body);assert.equal(body.study_id,undefined);assert.equal(body.expected_checkpoint,C);assert.equal(body.runtime_snapshot_sha256,C);assert.equal(body.candidate_id,CANDIDATE);assert.ok(!Object.hasOwn(body,'approval_id'));assert.equal(h.nodes.get('project-candidate-recovery-key').value,body.request_key);return response(bound(prepared(),NEXT));});await h.submit('project-candidate-prepare-form');assert.equal(h.nodes.get('project-files-error').textContent,'');assert.match(h.nodes.get('project-files-status').textContent,/No Run was launched/); + } + { + const h=mount();await tick();h.answers.push(response(recovery()));await h.click('project-files-refresh'); + assert.equal(h.nodes.get('project-candidate-prepare-fields').disabled,true);assert.equal(h.nodes.get('project-candidate-reconcile').disabled,false);assert.equal(h.nodes.get('project-candidate-abandon').disabled,false); + h.input('project-candidate-recovery-study',STUDY);h.input('project-candidate-recovery-key','pending-preparation-key'); + h.answers.push(({options})=>{const body=JSON.parse(options.body);assert.deepEqual(Object.keys(body).sort(),['binding','expected_checkpoint','preparation_key'].sort());assert.equal(body.preparation_key,'pending-preparation-key');return response(bound(prepared(),NEXT));});await h.click('project-candidate-reconcile');assert.equal(h.nodes.get('project-files-error').textContent,'');assert.match(h.nodes.get('project-files-status').textContent,/no writer was replayed/); + } + { + const h=mount();await tick();h.answers.push(response(recovery()));await h.click('project-files-refresh');h.input('project-candidate-recovery-study',STUDY);h.input('project-candidate-recovery-key','pending-preparation-key'); + const before=h.requests.length;await h.click('project-candidate-abandon');assert.equal(h.requests.length,before);assert.match(h.nodes.get('project-files-error').textContent,/explicit reason/); + h.input('project-candidate-abandon-reason','Scientist explicitly abandons stopped partial writer Ω'); + h.answers.push(({options})=>{const body=JSON.parse(options.body);assert.notEqual(body.request_key,body.preparation_key);return response(bound({format:'project_candidate_preparation_abandon_v1',origin_catalog_id:P,study_id:STUDY,preparation_key:body.preparation_key,preparation_operation:'study_execution.prepare_package',input_sha256:C,candidate:{kind:'candidate',catalog_id:P,id:CANDIDATE,sha256:NEXT},actor_id:ACTOR,reason:body.reason,observed_checkpoint:C,occurred_at:'2026-10-22T12:00:00+00:00',state:'abandoned'},NEXT));});await h.click('project-candidate-abandon');assert.equal(h.nodes.get('project-files-error').textContent,'');assert.match(h.nodes.get('project-files-status').textContent,/original staging and scientific facts remain/); + } + console.log('Candidate controller: 3 selected groups PASS'); +})().catch(error=>{console.error(error);process.exitCode=1;}); diff --git a/tests/test_workspace_project_file_deletion.cjs b/tests/test_workspace_project_file_deletion.cjs new file mode 100644 index 00000000..846f944b --- /dev/null +++ b/tests/test_workspace_project_file_deletion.cjs @@ -0,0 +1,43 @@ +'use strict'; +// Controlled response checks; actual filesystem/HTTP proof is a separate gate. +const assert=require('node:assert/strict'),crypto=require('node:crypto'); +const {mount,bound,response,tick,P,ACTOR,C,NEXT}=require('./test_workspace_project_files_m4.cjs'); +const path='notes/scientist.md',text='Ω authored note\0before\r\n',sha=crypto.createHash('sha256').update(text).digest('hex'); +const id=crypto.createHash('sha256').update(P+'\0note\0'+path).digest('hex'),stamp='2026-10-22T12:00:00+00:00'; +const positive={revision:1,sha256:sha,bytes:Buffer.byteLength(text),text,adopted_at:stamp,actor_id:ACTOR}; +const deleted={revision:2,deleted:true,sha256:null,bytes:0,text:null,adopted_at:stamp,actor_id:ACTOR}; +const selection=version=>({id,project_id:P,kind:'note',path,version}); +const missing={id,kind:'note',path,revision:1,expected_sha256:sha,expected_bytes:positive.bytes,observed_sha256:null,observed_bytes:null,state:'missing',reason:'missing'}; +const tombstone={...missing,revision:2,expected_sha256:null,expected_bytes:0,state:'deleted',reason:null}; +const page=item=>({items:[item],offset:0,limit:40,total:1}); +async function chooseMissing(h){h.answers.push(response(bound(page(missing))));await h.click('project-working-load');h.answers.push(response(bound(selection(positive))));await h.row('project-working-list');} +(async()=>{ + { + const h=mount();await tick();await chooseMissing(h); + assert.equal(h.nodes.get('project-working-delete-fields').disabled,false); + assert.equal(h.nodes.get('project-working-text').value,text); + assert.match(h.nodes.get('project-working-delete-summary').textContent,new RegExp(sha)); + assert.ok(!h.requests.some(r=>r.url.endsWith('/adopt-deletion')),'Inspection must never adopt deletion'); + const before=h.requests.length;await h.submit('project-working-delete-form');assert.equal(h.requests.length,before);assert.match(h.nodes.get('project-files-error').textContent,/explicitly confirm/); + h.nodes.get('project-working-delete-confirm').checked=true; + h.nodes.get('project-working-delete-confirm').listeners.get('change')(); + h.answers.push(({url,options})=>{assert.equal(url,'/api/workspace/project-files/files/adopt-deletion');const body=JSON.parse(options.body);assert.equal(body.binding,'b'.repeat(32));assert.equal(body.expected_checkpoint,C);assert.equal(body.path,path);assert.equal(body.expected_revision,1);assert.equal(body.expected_sha256,sha);assert.deepEqual(Object.keys(body).sort(),['binding','expected_checkpoint','expected_revision','expected_sha256','path','request_key']);return response(bound({id,project_id:P,kind:'note',path,revision:2,sha256:null,bytes:0,deleted:true,adopted_at:stamp,changed:true},NEXT));},response(bound(page(tombstone),NEXT)),response(bound(selection(deleted),NEXT))); + await h.submit('project-working-delete-form');assert.equal(h.nodes.get('project-files-error').textContent,'');assert.equal(h.nodes.get('project-working-text').value,'');assert.match(h.nodes.get('project-working-summary').textContent,/note deletion retained/);assert.equal(h.nodes.get('project-working-delete-fields').disabled,true); + const metadata=v=>Object.fromEntries(Object.entries(v).filter(([k])=>k!=='text')); + h.answers.push(response(bound({id,items:[metadata(positive),metadata(deleted)],offset:0,limit:40,total:2},NEXT))); + await h.click('project-working-history');const labels=h.nodes.get('project-working-history-list').querySelectorAll('button').map(b=>b.textContent);assert.match(labels[1],/note deletion/);assert.doesNotMatch(labels[1],/null/); + h.answers.push(response(bound(selection(positive),NEXT)));await h.row('project-working-history-list');assert.equal(h.nodes.get('project-working-text').value,text);assert.equal(h.nodes.get('project-working-delete-fields').disabled,true); + } + { + const h=mount();await tick();await chooseMissing(h); + h.answers.push(response(bound(page({...missing,state:'unchanged',observed_sha256:sha,observed_bytes:positive.bytes,reason:null}))));await h.click('project-working-load'); + assert.equal(h.nodes.get('project-working-delete-fields').disabled,true,'Fresh inspection invalidates prior missing-note choice'); + h.nodes.get('project-working-delete-confirm').checked=true;const before=h.requests.length;await h.submit('project-working-delete-form');assert.equal(h.requests.length,before); + } + { + const h=mount();await tick();await chooseMissing(h);h.nodes.get('project-working-delete-confirm').checked=true; + h.answers.push(response(bound({id,project_id:P,kind:'note',path,revision:2,sha256:sha,bytes:0,deleted:true,adopted_at:stamp,changed:true},NEXT))); + await h.submit('project-working-delete-form');assert.match(h.nodes.get('project-files-error').textContent,/differs/);assert.equal(h.nodes.get('project-working-text').value,text,'Malformed deletion ACK cannot replace captured text'); + } + console.log('PASS 3 note-deletion controller groups: explicit missing-note confirmation, exact CAS, tombstone/history, refresh invalidation and malformed ACK refusal'); +})().catch(error=>{console.error(error);process.exitCode=1;}); diff --git a/tests/test_workspace_project_files.cjs b/tests/test_workspace_project_files.cjs new file mode 100644 index 00000000..ee2e9d80 --- /dev/null +++ b/tests/test_workspace_project_files.cjs @@ -0,0 +1,262 @@ +// Deterministic projection/lifecycle evidence; real keyboard/layout evidence is +// owned by browser_project_files.cjs, not this deliberately small DOM substitute. +'use strict'; +const assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'),crypto=require('node:crypto'); +const html=fs.readFileSync(path.join(__dirname,'../static/project-files.html'),'utf8'); +const source=fs.readFileSync(path.join(__dirname,'../static/project-files.js'),'utf8'); +const pins=JSON.parse(source.match(/PROJECT_FILES_CONTRACTS_START \*\/\s*([\s\S]*?)\s*\/\* PROJECT_FILES_CONTRACTS_END/)[1]); +const capabilities={format:'agent_capabilities_v1',envelope_version:1,catalog_id:'a'.repeat(32),operations:Object.entries(pins).map(([id,sha256])=>({id,availability:'available',contracts:[{version:1,sha256,input_schema:{},output_schema:{},scopes:['inspect'],authority:'agent',effects:'read',retry:'read',bounds:{request_bytes:1048576,response_bytes:16777216,inline_decoded_bytes:524288,json_depth:64}}]})),limits:{request_bytes:1048576,response_bytes:16777216,max_operations:256}}; +const B='b'.repeat(32),P='c'.repeat(32),R='d'.repeat(32),C='e'.repeat(64),N='f'.repeat(64); +const actions=['new','open','save','close','query','get','import_text','annotate']; +const state=(binding=B,checkpoint=C,opened=true)=>({format:'project_files_state_v1',binding,state:opened?'open':'no_project_open',roots:['fixture'],project:opened?{project_id:P,name:'Text project',checkpoint}:null,available_actions:opened?actions:['new','open']}); +const record=(name='Greeting',revision=1)=>({id:R,kind:'text',name,text:'Hello world',groups:['source'],parents:[],provenance:{method:'import',rights:'unknown',normalization:'NFC; LF newlines'},task:'text_classification',annotation:{label:'greeting'},review:'draft',revision,source_revision:1,created_at:'2026-10-22T12:00:00+00:00',updated_at:'2026-10-22T12:00:00+00:00'}); +const summary=value=>Object.fromEntries(Object.entries(value).filter(([key])=>['id','kind','name','task','review','revision','source_revision','created_at','updated_at'].includes(key))); +const records=(binding=B,checkpoint=C,items=[record()],next_after_id=null)=>({binding,project_id:P,checkpoint,items:items.map(summary),next_after_id}); +const result=(binding=B,checkpoint=C,value=record())=>({binding,project_id:P,checkpoint,record:value,history:[value],next_history_before_revision:null}); +const reply=(value,status=200)=>new Response(JSON.stringify(value),{status,headers:{'Content-Type':'application/json'}}); +const defer=()=>{let resolve;const promise=new Promise(done=>{resolve=done;});return {promise,resolve};}; +const tick=async()=>{await new Promise(done=>setImmediate(done));await new Promise(done=>setImmediate(done));}; +function mount(initial=[reply(state()),reply(records())],capabilityValue=capabilities){ + const nodes=new Map(),events=new Map(),requests=[],answers=[...initial],discoveries=[];let focused=null; + class Node{ + constructor(tag='div'){this.tagName=tag;this.value='';this.children=[];this.dataset={};this.attrs={};this.listeners=new Map();this.disabled=false;this.hidden=false;this.textContent='';} + append(...values){this.children.push(...values);if(this.tagName==='select'&&!this.value&&values.length)this.value=values[0].value;} + replaceChildren(...values){this.children=[...values];if(this.tagName==='select')this.value=values[0]?.value??'';} + setAttribute(name,value){this.attrs[name]=value;} + addEventListener(name,handler){this.listeners.set(name,handler);} + focus(){focused=this;} + querySelectorAll(tag){return this.children.flatMap(child=>[...(child.tagName===tag?[child]:[]),...child.querySelectorAll(tag)]);} + } + for(const match of html.matchAll(/<([a-z][a-z0-9]*)\b([^>]*\bid="([^"]+)"[^>]*)>/g)){ + const node=new Node(match[1]);node.value=match[2].match(/\bvalue="([^"]*)"/)?.[1]??'';node.disabled=/\bdisabled\b/.test(match[2]);nodes.set(match[3],node); + } + nodes.get('annotation-task').value='text_classification';nodes.get('record-review').value='draft';nodes.get('project-explore-kind').value='data_query';nodes.get('project-source-kind').value='image';nodes.get('project-data-review').value='draft'; + const context={document:{getElementById:id=>{assert.ok(nodes.has(id),'Unknown node '+id);return nodes.get(id);},createElement:tag=>new Node(tag)},URLSearchParams,TextEncoder,crypto,console,window:null}; + context.window=context;context.TuldokAuth={canReview:()=>true,fetch:(url,options)=>{if(url==='/api/agent/capabilities'){discoveries.push({url,options});return Promise.resolve(reply(capabilityValue));}const header=options.headers?.['Tuldok-Operation-Contract'];assert.ok(header,'Every project call selects an exact contract');const [operation,version,digest]=header.split('/');assert.equal(version,'1');assert.equal(digest,pins[operation]);requests.push({url,options});assert.ok(answers.length,'Unexpected fetch '+url);return Promise.resolve(answers.shift());}}; + context.addEventListener=(name,handler)=>events.set(name,handler); + vm.createContext(context);vm.runInContext(source,context); + return {nodes,requests,answers,events,discoveries,focused:()=>focused, + input(id,value){const node=nodes.get(id);node.value=value;node.listeners.get('input')?.({target:node});}, + async click(id){await nodes.get(id).onclick();await tick();}, + async submit(id){nodes.get(id).onsubmit({preventDefault(){}});await tick();}}; +} +(async()=>{ + // The actual browser controller transports the current binding and checkpoint + // on every read/edit, then adopts only the acknowledged checkpoint. + { + const h=mount();await tick();assert.equal(h.nodes.get('project-files-error').textContent,'');assert.equal(h.discoveries.length,1);assert.equal(h.nodes.get('text-import-fields').disabled,false); + h.input('text-name','Authored note');h.input('text-content',''); + h.answers.push(reply(result(B,N,record('Authored note'))),reply(records(B,N,[record('Authored note')]))); + await h.submit('text-import-form'); + assert.ok(h.requests[2],h.nodes.get('project-files-error').textContent+' '+JSON.stringify(h.requests));const sent=JSON.parse(h.requests[2].options.body); + assert.equal(sent.binding,B);assert.equal(sent.expected_checkpoint,C);assert.equal(sent.asset.text,'');assert.match(sent.request_key,/^[a-f0-9]{32}$/); + assert.equal(h.nodes.get('text-content').value,'');assert.equal(h.nodes.get('project-files-dirty').textContent,'No unsubmitted edits.'); + assert.ok(h.requests[3].url.includes('checkpoint='+N));assert.equal(h.nodes.get('record-heading').textContent,'Authored note'); + assert.equal(h.focused(),h.nodes.get('record-heading')); + h.input('annotation-value','revised');h.answers.push(reply(result(B,C,{...record('Authored note',2),annotation:{label:'revised'}})),reply(records(B,C))); + await h.submit('annotation-form'); + const annotation=JSON.parse(h.requests[4].options.body);assert.equal(annotation.expected_checkpoint,N);assert.equal(annotation.annotation.revision,1);assert.equal(annotation.annotation.source_revision,1);assert.equal(annotation.annotation.annotation.label,'revised'); + h.answers.push(reply(state()),reply(records()));await h.click('project-files-save'); + assert.equal(JSON.parse(h.requests[6].options.body).expected_checkpoint,C);assert.equal(h.nodes.get('record-heading').textContent,'Authored note');assert.equal(h.focused(),h.nodes.get('project-files-save'));assert.equal(h.nodes.get('project-files-save').disabled,false); + h.answers.push(reply(state(P,C,false)));await h.click('project-files-close'); + assert.equal(h.nodes.get('text-import-fields').disabled,true); + h.nodes.get('project-files-directory').value='copied-project';h.answers.push(reply(state(B,C,true)),reply(records()));await h.click('project-files-open'); + const opening=JSON.parse(h.requests[9].options.body);assert.equal(opening.binding,P);assert.equal(opening.expected_checkpoint,null);assert.equal(opening.directory,'copied-project'); + } + // Unsubmitted drafts cannot disappear through lifecycle actions and warn on + // navigation; explicit discard is the only local destructive transition. + { + const h=mount();await tick();h.input('text-content','Keep this draft'); + const count=h.requests.length;await h.click('project-files-close');assert.equal(h.requests.length,count);assert.equal(h.nodes.get('text-content').value,'Keep this draft'); + assert.equal(h.focused(),h.nodes.get('project-files-error'));assert.match(h.nodes.get('project-files-error').textContent,/unsent or unacknowledged/); + let prevented=false;const event={preventDefault(){prevented=true;}};h.events.get('beforeunload')(event);assert.ok(prevented);assert.equal(event.returnValue,''); + await h.click('project-files-discard');assert.equal(h.nodes.get('text-content').value,'');assert.equal(h.nodes.get('project-files-discard').disabled,true); + } + // A session replacement supersedes a delayed response. It cannot display the + // old record even when the old request later succeeds with a valid payload. + { + const h=mount();await tick();const delayed=defer();h.answers.push(delayed.promise); + const button=h.nodes.get('records-list').querySelectorAll('button')[0],old=button.onclick();await tick(); + h.answers.push(reply(state(P,N)),reply(records(P,N,[record('New session record')])));h.events.get('tuldok-session')();await tick(); + delayed.resolve(reply(result(B,C,record('Obsolete record'))));await old;await tick(); + assert.equal(h.nodes.get('record-text').value,'');assert.notEqual(h.nodes.get('record-heading').textContent,'Obsolete record'); + assert.match(h.nodes.get('records-list').querySelectorAll('button')[0].textContent,/New session record/); + } + // Unknown acknowledgement retains edits, fences writes, and exposes recovery. + { + const h=mount();await tick();h.input('text-name','Draft');h.input('text-content','Unique draft'); + h.answers.push(Promise.reject(Error('connection lost')));await h.submit('text-import-form'); + assert.equal(h.nodes.get('text-content').value,'Unique draft');assert.match(h.nodes.get('project-files-error').textContent,/may already be saved/);assert.equal(h.nodes.get('import-text').disabled,true); + h.answers.push(reply(state()),reply(records()));await h.click('project-files-refresh');assert.equal(h.nodes.get('text-content').value,'Unique draft');assert.equal(h.nodes.get('import-text').disabled,false); + } + // History and record continuation are explicit, checkpoint-bound reads. + // Loading an older history page never rewrites an unsubmitted annotation. + { + const h=mount([reply(state()),reply(records(B,C,[record()],R))]);await tick(); + const other={...record('Second record'),id:'e'.repeat(32)};h.answers.push(reply(records(B,C,[other])));await h.click('records-more'); + assert.equal(h.nodes.get('records-list').querySelectorAll('button').length,2);assert.ok(h.requests.at(-1).url.includes('after_id='+R));assert.equal(h.nodes.get('records-more').hidden,true); + const latest=record('Greeting',2);h.answers.push(reply({...result(B,C,latest),next_history_before_revision:2}),reply(records()));await h.nodes.get('records-list').querySelectorAll('button')[0].onclick();await tick(); + h.input('annotation-value','Unsaved annotation');h.answers.push(reply({...result(B,C,latest),history:[record()]}));await h.click('record-history-more'); + assert.ok(h.requests.at(-1).url.includes('before_revision=2'));assert.equal(h.nodes.get('record-history').children.length,2);assert.equal(h.nodes.get('record-history-more').hidden,true);assert.equal(h.nodes.get('annotation-value').value,'Unsaved annotation'); + } + // A committed outcome receipt survives the error projection. Recovery Open + // targets only the declared directory and preserves the in-tab source draft. + { + const h=mount();await tick();h.input('text-name','Retained draft');h.input('text-content','May be committed'); + const receipt={committed:true,selected:true,project_id:P,checkpoint:N,request_key:'file-request-0001'}; + h.answers.push(reply({code:'projection_unavailable',error:'Saved; local view unavailable.',receipt},503));await h.submit('text-import-form'); + assert.match(h.nodes.get('project-files-receipt').textContent,/Saved to project/);assert.equal(h.nodes.get('text-content').value,'May be committed'); + const recovery={...state(),state:'recovery_required',available_actions:['open'],recovery:{action:'open',root:'fixture',directory:'original-project',reason:'projection_unavailable',request_key:'file-request-0001'}}; + h.answers.push(reply(recovery));await h.click('project-files-refresh');assert.equal(h.nodes.get('project-files-open').disabled,false);assert.equal(h.nodes.get('project-files-directory').disabled,true);assert.equal(h.nodes.get('project-files-directory').value,'original-project'); + h.answers.push(reply(state(P,N)),reply(records(P,N)));await h.click('project-files-open');assert.equal(h.nodes.get('text-content').value,'May be committed');assert.equal(JSON.parse(h.requests.at(-2).options.body).directory,'original-project');assert.equal(h.nodes.get('project-files-discard').disabled,false); + h.answers.push(reply(result(P,N)),reply(records(P,N)));await h.nodes.get('records-list').querySelectorAll('button')[0].onclick();await tick();assert.equal(h.nodes.get('record-text').value,'Hello world');assert.equal(h.nodes.get('text-content').value,'May be committed'); + } + // Incompatible capability pins stop dispatch rather than trusting remote + // schemas or silently downgrading the operation selection. + { + const incompatible=JSON.parse(JSON.stringify(capabilities));incompatible.operations.find(item=>item.id==='project_files.status').contracts[0].sha256='0'.repeat(64); + const h=mount([],incompatible);await tick();assert.equal(h.requests.length,0);assert.match(h.nodes.get('project-files-error').textContent,/No compatible project status contract/);assert.equal(h.nodes.get('project-files-open').disabled,true); + } + // Invalid future/unknown fields are errors, never an empty successful project. + { + const h=mount([reply({...state(),future:true})]);await tick();assert.match(h.nodes.get('project-files-error').textContent,/Unsupported project response/);assert.equal(h.nodes.get('project-files-new').disabled,true); + } + // A stale checkpoint error retains annotation text and focuses its recovery + // message. Refresh can update identity without silently reattaching the draft. + { + const h=mount();await tick();h.answers.push(reply(result()),reply(records()));await h.nodes.get('records-list').querySelectorAll('button')[0].onclick();await tick(); + h.input('annotation-value','Never lose this label');h.answers.push(reply({code:'stale_checkpoint',error:'Reload current checkpoint.'},409));await h.submit('annotation-form'); + assert.equal(h.nodes.get('annotation-value').value,'Never lose this label');assert.equal(h.nodes.get('annotation-save').disabled,true);assert.match(h.nodes.get('project-files-error').textContent,/stale_checkpoint/); + h.answers.push(reply(state(B,N)),reply(records(B,N)));await h.click('project-files-refresh');assert.equal(h.nodes.get('annotation-value').value,'Never lose this label');assert.equal(h.nodes.get('annotation-fields').disabled,true); + } + // M2 explores actual data through fixed contracts, preserves independent + // drafts and reads a bounded history page at the exact selected checkpoint. + { + const h=mount();await tick(); + const boundValue=(value,checkpoint=C)=>({binding:B,project_id:P,checkpoint,value}); + const data={...record(),source_available:true,source_lineage_known:true}; + h.answers.push(reply(boundValue({items:[data],offset:0,limit:40,total:1,analysis:{},criteria:{}}))); + await h.click('project-explore-load'); + assert.match(h.requests.at(-1).url,/\/data\?binding=/); + h.answers.push(reply(boundValue(data)));await h.nodes.get('project-explore-list').querySelectorAll('button')[0].onclick();await tick(); + assert.equal(h.nodes.get('project-data-note-form').hidden,false);assert.equal(h.nodes.get('project-metadata-form').hidden,true); + assert.equal(h.nodes.get('project-detail-title').textContent,'Greeting'); + h.answers.push(reply(boundValue({items:[data],offset:0,limit:1,total:2}))); + await h.click('project-detail-history');assert.match(h.requests.at(-1).url,/limit=1/);assert.equal(h.nodes.get('project-detail-history-more').hidden,false); + h.answers.push(reply(boundValue({items:[data],offset:1,limit:1,total:2}))); + await h.click('project-detail-history-more');assert.match(h.requests.at(-1).url,/offset=1/);assert.equal(h.nodes.get('project-detail-history-more').hidden,true); + h.input('project-search-name','Keep this independent draft');h.input('project-rights-note','Authored permission note'); + h.answers.push(reply(boundValue({record:{...data,revision:2},changed:true},N)),reply(records(B,N))); + await h.submit('project-rights-form');const request=h.requests.at(-2),body=JSON.parse(request.options.body); + assert.equal(request.options.headers['Tuldok-Operation-Contract'].split('/')[0],'project_files.data_rights'); + assert.equal(body.expected_checkpoint,C);assert.equal(body.revision,1);assert.equal(body.note,'Authored permission note');assert.match(body.request_key,/^[a-f0-9]{32}$/); + assert.match(h.nodes.get('project-files-dirty').textContent,/unsent or unacknowledged/);assert.equal(h.nodes.get('project-search-name').value,'Keep this independent draft'); + assert.equal(h.nodes.get('project-rights-fields').disabled,true,'Old selected metadata is fenced after publication'); + await h.click('project-files-discard');assert.equal(h.nodes.get('project-search-name').value,''); + } + // Missing/corrupt artifact observations are readable facts. A superseded M2 + // request cannot attach its payload or authoring metadata to a new binding. + { + const h=mount();await tick();h.nodes.get('project-explore-kind').value='artifacts'; + const facts={sha256:C,bytes:99,availability:{status:'corrupt',integrity:'mismatch',reason:'Exact unique bytes differ'}}; + h.answers.push(reply({binding:B,project_id:P,checkpoint:C,value:{items:[facts],offset:0,limit:40,total:1}})); + await h.click('project-explore-load');await h.nodes.get('project-explore-list').querySelectorAll('button')[0].onclick();await tick(); + const strings=node=>[node.textContent,...node.children.flatMap(strings)]; + assert.ok(strings(h.nodes.get('project-detail-facts')).includes('corrupt')); + assert.ok(strings(h.nodes.get('project-detail-facts')).includes('Exact unique bytes differ')); + h.nodes.get('project-explore-kind').value='data_query';const delayed=defer();h.answers.push(delayed.promise);const loading=h.click('project-explore-load');await tick(); + h.answers.push(reply(state(P,N)),reply(records(P,N)));h.events.get('tuldok-session')();await tick(); + delayed.resolve(reply({binding:B,project_id:P,checkpoint:C,value:{items:[record('Obsolete history')],offset:0,limit:40,total:1}}));await loading;await tick(); + assert.equal(h.nodes.get('project-explore-list').children.length,0); + assert.notEqual(h.nodes.get('project-detail-title').textContent,'Obsolete history'); + } + // Model creation copies the current owner's complete inert template. Source + // registration selects declared scientific references without user hash edits. + { + const h=mount();await tick();const boundValue=(value,checkpoint=C)=>({binding:B,project_id:P,checkpoint,value}); + const template={name:'Owner template',task:{kind:'task',catalog_id:P,id:'classification',revision:1,sha256:C},kind:'template',specification:{template:'owned'},source_selector:null,preprocessing_policy:{owned:true},output_policy:{owned:true}}; + h.answers.push(reply(boundValue({recipes:[{model_template:template}]})));await h.click('project-definitions-load'); + h.input('project-model-create-name','My scientific model');h.answers.push(reply(boundValue({id:R,name:'My scientific model',revision:1},N)),reply(records(B,N))); + await h.submit('project-model-create-form');const created=JSON.parse(h.requests.at(-2).options.body); + assert.equal(created.name,'My scientific model');assert.deepEqual(created.specification,template.specification);assert.equal(created.source_selector,null);assert.equal(created.expected_checkpoint,C);assert.equal(created.task.catalog_id,P); + h.input('project-register-path','fixture/tuldok.json');h.answers.push(reply(boundValue({project_id:R,origin_catalog_id:P,manifest_sha256:C},C)),reply(boundValue({items:[{id:'measurements',label:'Captured scientific fields',sha256:N,observed_sha256:N,status:'available',integrity:'verified'}],offset:0,limit:40,total:1},C))); + await h.submit('project-register-form');assert.equal(h.nodes.get('project-scientific-fields').disabled,false); + h.input('project-scientific-name','Observed fields');h.answers.push(reply(boundValue({id:R,kind:'field_sequence'},N)),reply(records(B,N))); + await h.submit('project-scientific-form');const imported=JSON.parse(h.requests.at(-2).options.body); + assert.equal(imported.reference_id,'measurements');assert.equal(imported.expected_sha256,N);assert.equal(imported.snapshot.project_id,R);assert.equal(imported.snapshot.manifest_sha256,C);assert.equal(imported.expected_checkpoint,C);assert.equal(imported.document.name,'Observed fields'); + assert.ok(h.requests.every(request=>!/(execute|submit-external|capture-runtime)/.test(request.url))); + } + // Resolving an explicitly selected missing artifact imports a trusted-root + // file by identity; its durable observation is followed by a fresh status read. + { + const h=mount();await tick();h.nodes.get('project-explore-kind').value='artifacts'; + const boundValue=(value,checkpoint=C)=>({binding:B,project_id:P,checkpoint,value}); + h.answers.push(reply(boundValue({items:[{format:'project_artifact_v1',sha256:C,bytes:9,availability:{state:'missing'}}],offset:0,limit:40,total:1}))); + await h.click('project-explore-load');await h.nodes.get('project-explore-list').querySelectorAll('button')[0].onclick();await tick(); + assert.equal(h.nodes.get('project-artifact-resolve-form').hidden,false);assert.equal(h.nodes.get('project-artifact-root').value,'fixture'); + h.input('project-artifact-path','unique/observation.bin');h.answers.push(reply(boundValue({format:'project_artifact_resolution_v1',sha256:C,bytes:9,actor_id:P,observed_at:'2026-10-22T12:00:00+00:00'},N)),reply(boundValue({state:'verified',expected_sha256:C,expected_bytes:9,observed_sha256:C,observed_bytes:9},N))); + await h.submit('project-artifact-resolve-form'); + const resolve=h.requests.at(-2),body=JSON.parse(resolve.options.body),status=h.requests.at(-1); + assert.equal(resolve.options.headers['Tuldok-Operation-Contract'].split('/')[0],'project_files.artifact_resolve'); + assert.equal(body.root,'fixture');assert.equal(body.path,'unique/observation.bin');assert.equal(body.sha256,C);assert.equal(body.expected_checkpoint,C);assert.match(body.request_key,/^[a-f0-9]{32}$/); + assert.equal(status.options.headers['Tuldok-Operation-Contract'].split('/')[0],'project_files.artifact_status');assert.equal(JSON.parse(status.options.body).checkpoint,N); + assert.equal(h.nodes.get('project-artifact-resolve-form').hidden,true);assert.equal(h.nodes.get('project-artifact-path').value,'');assert.equal(h.nodes.get('project-files-dirty').textContent,'No unsubmitted edits.'); + } + // Native saved selections contain items without pagination fields. Their + // successful create and get results must stay distinct from page contracts. + { + const h=mount();await tick();const boundValue=(value,checkpoint=C)=>({binding:B,project_id:P,checkpoint,value}); + const data={...record(),source_available:true},selection={id:P,name:'Exact revision',revision:1,mode:'fixed',schema_version:1,items:[{id:R,revision:1,source_revision:1}]}; + h.answers.push(reply(boundValue({items:[data],total:1,offset:0,limit:40,analysis:{},criteria:{}})));await h.click('project-explore-load'); + h.answers.push(reply(boundValue(data)));await h.nodes.get('project-explore-list').querySelectorAll('button')[0].onclick();await tick(); + h.input('project-selection-name','Exact revision');h.answers.push(reply(boundValue(selection,N)),reply(records(B,N))); + await h.submit('project-selection-form');assert.equal(h.nodes.get('project-files-error').textContent,'');assert.equal(h.nodes.get('project-files-dirty').textContent,'No unsubmitted edits.'); + assert.equal(JSON.parse(h.requests.at(-2).options.body).expected_checkpoint,C);assert.ok(h.requests.at(-1).url.includes('checkpoint='+N)); + h.nodes.get('project-explore-kind').value='selections';h.answers.push(reply(boundValue({items:[selection],total:1,offset:0,limit:40},N)));await h.click('project-explore-load'); + h.answers.push(reply(boundValue(selection,N)));await h.nodes.get('project-explore-list').querySelectorAll('button')[0].onclick();await tick(); + assert.equal(h.nodes.get('project-files-error').textContent,'');assert.equal(h.nodes.get('project-detail-title').textContent,'Exact revision'); + // Strict continuation checks still apply to actual pages. + h.answers.push(reply(boundValue({items:[selection],total:2,offset:0,limit:40},N)));await h.click('project-explore-load');assert.match(h.nodes.get('project-files-error').textContent,/Saved page differs/); + } + // Unpinned references use the owner's actual observation and retain their + // unverified meaning. A declared digest mismatch is never selectable/imported. + { + const h=mount();await tick();const boundValue=(value,checkpoint=C)=>({binding:B,project_id:P,checkpoint,value}); + const refs=[{id:'unpinned',label:'Observed data',status:'available',integrity:'unverified',observed_sha256:N}, + {id:'mismatch',label:'Changed declared data',status:'available',integrity:'mismatch',sha256:C,observed_sha256:N}, + {id:'missing',label:'Missing declared data',status:'missing',integrity:'unverified',sha256:C}]; + h.input('project-register-path','fixture/tuldok.json');h.answers.push(reply(boundValue({project_id:R,origin_catalog_id:P,manifest_sha256:C},N)),reply(boundValue({items:refs,total:3,offset:0,limit:40},N))); + await h.submit('project-register-form');const choices=h.nodes.get('project-scientific-reference').children; + assert.equal(choices[0].disabled,false);assert.match(choices[0].textContent,/unverified; not pinned/);assert.equal(choices[1].disabled,true);assert.match(choices[1].textContent,/mismatch/);assert.equal(choices[2].disabled,true); + h.nodes.get('project-scientific-reference').value='mismatch';const before=h.requests.length;await h.submit('project-scientific-form');assert.equal(h.requests.length,before);assert.match(h.nodes.get('project-files-error').textContent,/Declared digests must match/); + h.nodes.get('project-scientific-reference').value='unpinned';h.input('project-scientific-name','Observed fields');h.answers.push(reply(boundValue({id:R,kind:'field_sequence'},C)),reply(records(B,C))); + await h.submit('project-scientific-form');assert.equal(h.nodes.get('project-files-error').textContent,'');const imported=JSON.parse(h.requests.at(-2).options.body); + assert.equal(imported.reference_id,'unpinned');assert.equal(imported.expected_sha256,N);assert.equal(imported.expected_checkpoint,N); + } + // Current material review, local grant and durable admission are separate + // named actions. Reconciliation has no fabricated receipt key. + { + const h=mount();await tick();const boundValue=(value,checkpoint=C)=>({binding:B,project_id:P,checkpoint,value}); + assert.equal(h.requests.length,2,'Opening status never starts execution'); + h.answers.push(reply(boundValue({format:'project_execution_health_v1',active_run_ids:[],active_count:0,offset:0,limit:40,local_owner_active:false,available:true,drain_required:false})));await h.click('project-execution-health');assert.match(h.nodes.get('project-execution-summary').textContent,/0 current/); + h.answers.push(reply(boundValue({items:[{id:R,name:'Explicit recipe',revision:3,recipe:'external_recipe'}],total:1,offset:0,limit:40})));await h.click('project-execution-graphs'); + h.answers.push(reply(boundValue({graph_revision:3,material_sha256:N,material:{runtime:'current observed runtime'}})));await h.submit('project-execution-preflight-form');assert.equal(h.nodes.get('project-execution-submit-fields').disabled,true); + h.input('project-execution-consumer',P);await h.submit('project-execution-approval-form');assert.match(h.nodes.get('project-files-error').textContent,/Explicitly approve/); + const grant={id:R,catalog_id:P,approver_actor_id:P,approver_credential_id:null,consumer_actor_id:P,material_sha256:N,created_at:1,expires_at:3601,revoked_at:null}; + h.nodes.get('project-execution-confirm').checked=true;h.answers.push(reply(boundValue({format:'project_current_execution_approval_v1',current_local:true,grant},N)));await h.submit('project-execution-approval-form');assert.equal(h.nodes.get('project-files-error').textContent,'');assert.equal(h.nodes.get('project-execution-submit-fields').disabled,false);assert.equal(h.nodes.get('project-files-dirty').textContent,'No unsubmitted edits.'); + const approval=JSON.parse(h.requests.at(-1).options.body);assert.equal(approval.expected_checkpoint,C);assert.equal(approval.expected_digest,N);assert.equal(approval.consumer_actor_id,P); + h.answers.push(reply(boundValue({format:'project_run_admission_v1',origin_catalog_id:P,run_id:R,request_sha256:N},C)));await h.submit('project-execution-submit-form');assert.match(h.nodes.get('project-files-status').textContent,/durable intent/);assert.equal(h.nodes.get('project-execution-submit-fields').disabled,true);assert.equal(JSON.parse(h.requests.at(-1).options.body).approval_id,R); + h.answers.push(reply(boundValue({format:'project_run_admission_v1',origin_catalog_id:P,run_id:R,request_sha256:N},N)));await h.submit('project-execution-cancel-form');assert.match(h.nodes.get('project-files-status').textContent,/intent saved/); + h.answers.push(reply(boundValue({format:'project_execution_reconciliation_v1',origin_catalog_id:P,run_ids:[R],available:true},C)));await h.click('project-execution-reconcile');const reconciled=JSON.parse(h.requests.at(-1).options.body);assert.deepEqual(Object.keys(reconciled).sort(),['binding','expected_checkpoint']);assert.equal(reconciled.expected_checkpoint,N);assert.match(h.nodes.get('project-files-status').textContent,/reconciled and drained/); + } + // A typed model request uses the saved source revisions and an explicit + // endpoint. A missing acknowledgement preserves its authored request. + { + const h=mount();await tick();const boundValue=(value,checkpoint=C)=>({binding:B,project_id:P,checkpoint,value}); + const source={...record(),source_available:true};h.answers.push(reply(boundValue({items:[source],total:1,offset:0,limit:40,analysis:{},criteria:{}})));await h.click('project-explore-load');h.answers.push(reply(boundValue(source)));await h.nodes.get('project-explore-list').querySelectorAll('button')[0].onclick();await tick(); + h.nodes.get('project-producer-task').value='text_proposal_history';h.answers.push(reply(boundValue(source)));await h.click('project-producer-source-load'); + h.input('project-producer-url','http://127.0.0.1:12345');h.input('project-producer-model','exact-current-model');h.input('project-producer-instruction','Classify the exact frozen source');h.input('project-producer-labels','accepted\nrejected');h.nodes.get('project-producer-transport').value='typed';h.nodes.get('project-producer-confirm').checked=true; + h.answers.push(new Response('{broken',{status:200}));await h.submit('project-producer-start-form');const sent=JSON.parse(h.requests.at(-1).options.body);assert.equal(sent.family,'text_proposal_history');assert.equal(sent.document.source_id,R);assert.equal(sent.document.revision,1);assert.equal(sent.document.seed,null);assert.equal(sent.document.protocol,'pumas_typed_v1');assert.deepEqual(sent.document.labels,['accepted','rejected']);assert.match(h.nodes.get('project-files-error').textContent,/Acknowledgement unavailable/);assert.equal(h.nodes.get('project-producer-instruction').value,'Classify the exact frozen source');assert.equal(h.nodes.get('project-files-close').disabled,true); + } + console.log('PASS project-files controller: checkpoint requests, submit/history projection, dirty preservation, superseded replies, typed errors, unknown acknowledgements'); +})().catch(error=>{console.error(error);process.exitCode=1;}); diff --git a/tests/test_workspace_project_files_m4.cjs b/tests/test_workspace_project_files_m4.cjs new file mode 100644 index 00000000..c1a8cdec --- /dev/null +++ b/tests/test_workspace_project_files_m4.cjs @@ -0,0 +1,97 @@ +// M4 controller fault boundaries. This small DOM substitute proves requests and +// inert projection; keyboard/layout and actual storage require real browser QA. +'use strict'; +const assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),vm=require('node:vm'),crypto=require('node:crypto'); +const html=fs.readFileSync(path.join(__dirname,'../static/project-files.html'),'utf8'),source=fs.readFileSync(path.join(__dirname,'../static/project-files.js'),'utf8'); +const pins=JSON.parse(source.match(/PROJECT_FILES_CONTRACTS_START \*\/\s*([\s\S]*?)\s*\/\* PROJECT_FILES_CONTRACTS_END/)[1]); +const versionPins=JSON.parse(source.match(/PROJECT_FILES_VERSIONS_START \*\/\s*([\s\S]*?)\s*\/\* PROJECT_FILES_VERSIONS_END/)[1]); +const B='b'.repeat(32),P='c'.repeat(32),ACTOR='d'.repeat(32),FORK='a'.repeat(32),C='e'.repeat(64),NEXT='f'.repeat(64); +const actions=['new','open','save','close','query','get','import_text','annotate']; +const capabilities={format:'agent_capabilities_v1',envelope_version:1,catalog_id:'a'.repeat(32),operations:Object.entries(pins).map(([id,sha256])=>({id,availability:'available',contracts:[{version:1,sha256,input_schema:{},output_schema:{},scopes:['inspect'],authority:'agent',effects:'read',retry:'read',bounds:{request_bytes:1048576,response_bytes:16777216,inline_decoded_bytes:524288,json_depth:64}}]})),limits:{request_bytes:1048576,response_bytes:16777216,max_operations:256}}; +const state=(binding=B,checkpoint=C,project=P,name='Text project')=>({format:'project_files_state_v1',binding,state:'open',roots:['fixture'],project:{project_id:project,name,checkpoint},available_actions:actions}); +const records=(binding=B,checkpoint=C,project=P)=>({binding,project_id:project,checkpoint,items:[],next_after_id:null}); +const bound=(value,checkpoint=C)=>({binding:B,project_id:P,checkpoint,value}); +const response=(value,status=200)=>new Response(JSON.stringify(value),{status,headers:{'Content-Type':'application/json'}}); +const tick=async()=>{for(let n=0;n<4;n++)await new Promise(done=>setImmediate(done));}; +const hash=raw=>crypto.createHash('sha256').update(raw).digest('hex'),fileID=hash(P+'\0script\0scripts/inert.py'); +const version=(text='print("only text")\0Ω\r\n',revision=1)=>({revision,sha256:hash(Buffer.from(text)),bytes:Buffer.byteLength(text),adopted_at:'2026-10-22T12:00:00+00:00',actor_id:ACTOR,text}); +const selected=(text,revision)=>({id:fileID,project_id:P,kind:'script',path:'scripts/inert.py',version:version(text,revision)}); +function mount(capabilityValue=capabilities){ + const nodes=new Map(),requests=[],answers=[response(state()),response(records())];let focused=null; + class Element{ + constructor(tag){Object.assign(this,{tagName:tag,value:'',children:[],dataset:{},attrs:{},listeners:new Map(),disabled:false,hidden:false,textContent:''});} + append(...children){this.children.push(...children);if(this.tagName==='select'&&!this.value)this.value=children[0]?.value??'';} + replaceChildren(...children){this.children=children;if(this.tagName==='select')this.value=children[0]?.value??'';} + setAttribute(key,value){this.attrs[key]=value;} + addEventListener(name,handler){this.listeners.set(name,handler);} + focus(){focused=this;} + querySelectorAll(tag){return this.children.flatMap(child=>[...(child.tagName===tag?[child]:[]),...child.querySelectorAll(tag)]);} + } + for(const match of html.matchAll(/<([a-z][a-z0-9]*)\b([^>]*\bid="([^"]+)"[^>]*)>/g)){ + const node=new Element(match[1]);node.value=match[2].match(/\bvalue="([^"]*)"/)?.[1]??'';nodes.set(match[3],node); + } + for(const [id,value] of Object.entries({'annotation-task':'text_classification','record-review':'draft','project-explore-kind':'data_query','project-source-kind':'image','project-data-review':'draft','project-working-kind':'script'}))nodes.get(id).value=value; + const context={document:{getElementById:id=>{assert.ok(nodes.has(id),id);return nodes.get(id);},createElement:tag=>new Element(tag)},URLSearchParams,TextEncoder,TextDecoder,crypto,console,window:null};context.window=context; + context.addEventListener=()=>{}; + context.TuldokAuth={canReview:()=>true,fetch:async(url,options)=>{ + if(url==='/api/agent/capabilities')return response(capabilityValue); + const [operation,version,digest]=options.headers['Tuldok-Operation-Contract'].split('/');assert.equal(digest,versionPins[operation]?.[version]??pins[operation]); + const request={url,options};requests.push(request);assert.ok(answers.length,'Unexpected fetch '+url); + const next=answers.shift();return typeof next==='function'?next(request):next; + }}; + vm.createContext(context);vm.runInContext(source,context); + return {nodes,requests,answers,focused:()=>focused, + input(id,value){const node=nodes.get(id);node.value=value;node.listeners.get('input')?.({target:node});}, + async click(id){await nodes.get(id).onclick();await tick();}, + async submit(id){nodes.get(id).onsubmit({preventDefault(){}});await tick();for(let n=0;n<100&&nodes.get('project-save-as-fields').disabled&&!nodes.get('project-files-error').textContent;n++)await new Promise(done=>setTimeout(done,2));}, + async row(id,index=0){await nodes.get(id).querySelectorAll('button')[index].onclick();await tick();}}; +} +const inspection=()=>{const v=version();return {id:fileID,kind:'script',path:'scripts/inert.py',revision:1,expected_sha256:v.sha256,expected_bytes:v.bytes,observed_sha256:v.sha256,observed_bytes:v.bytes,state:'unchanged',reason:null};}; +module.exports={mount,state,records,bound,response,tick,B,P,ACTOR,C,NEXT,capabilities,versionPins}; +if(require.main===module)(async()=>{ + // Save As changes only after a correlated independent selection receipt. + { + const h=mount();await tick();h.input('project-save-as-directory','independent');h.input('project-save-as-name','Fork Ω'); + h.answers.push(({options})=>{const body=JSON.parse(options.body);assert.equal(body.binding,B);assert.equal(body.expected_checkpoint,C);assert.equal(body.root,'fixture');return response({state:state(ACTOR,NEXT,FORK,'Fork Ω'),fork:{source_project_id:P,source_checkpoint:C,project_id:FORK,checkpoint:NEXT,root:body.root,directory:body.directory,request_key:body.request_key,reconciled:false,mapping_count:2,copied_count:1,missing_regenerable_count:1}});},response(records(ACTOR,NEXT,FORK))); + await h.submit('project-save-as-form');assert.equal(h.nodes.get('project-files-error').textContent,'');assert.match(h.nodes.get('project-save-as-summary').textContent,/2 editable mappings/);assert.equal(h.focused(),h.nodes.get('project-fork-load')); + assert.ok(h.requests.at(-1).url.includes('binding='+ACTOR));assert.ok(h.requests.at(-1).url.includes('checkpoint='+NEXT)); + } + // Lost acknowledgement retains the exact source/destination/key for explicit + // retry, even after status refresh observes a different selection. + { + const h=mount();await tick();h.input('project-save-as-directory','pending');h.input('project-save-as-name','Pending'); + h.answers.push(()=>Promise.reject(Error('lost reply')));await h.submit('project-save-as-form');const original=JSON.parse(h.requests.at(-1).options.body); + assert.match(h.nodes.get('project-files-error').textContent,/may already be saved/);assert.equal(h.nodes.get('project-save-as-directory').value,'pending'); + h.answers.push(response(state(ACTOR,NEXT,FORK,'Pending')),response(records(ACTOR,NEXT,FORK)));await h.click('project-files-refresh'); + h.input('project-save-as-directory','different');const before=h.requests.length;await h.submit('project-save-as-form');assert.equal(h.requests.length,before);assert.match(h.nodes.get('project-files-error').textContent,/pending Save As destination/); + h.input('project-save-as-directory','pending');h.answers.push(({options})=>{assert.deepEqual(JSON.parse(options.body),original);return response({error:'Inspect original source before recovery.',code:'stale_binding'},409);});await h.submit('project-save-as-form');assert.equal(h.requests.length,before+1); + } + // Captured scripts render only after exact byte/revision checks. A corrupt + // reply cannot replace the prior visible revision, and no script is evaluated. + { + const h=mount();await tick();h.answers.push(response(bound({items:[inspection()],offset:0,limit:40,total:1})));await h.click('project-working-load'); + h.answers.push(response(bound(selected())));await h.row('project-working-list');assert.equal(h.nodes.get('project-working-text').value,version().text);assert.equal(h.nodes.get('project-working-path').value,'scripts/inert.py'); + const meta=version();delete meta.text;h.answers.push(response(bound({id:fileID,items:[meta],offset:0,limit:40,total:1})));await h.click('project-working-history'); + assert.equal(h.nodes.get('project-working-history-list').children.length,1);assert.equal(h.nodes.get('project-working-history-more').hidden,true); + const corrupt=selected('changed',1);corrupt.version.sha256=C;h.answers.push(response(bound(corrupt)));await h.row('project-working-history-list'); + assert.equal(h.nodes.get('project-working-text').value,version().text);assert.match(h.nodes.get('project-files-error').textContent,/exact bytes/); + } + // Explicit adoption transports the reviewed SHA and optimistic revision. The + // current file is read by the server owner; the browser sends no source text. + { + const h=mount();await tick();const v=version('new exact text',1); + for(const [id,value] of Object.entries({'project-working-path':'scripts/inert.py','project-working-revision':'0','project-working-sha':v.sha256}))h.input(id,value); + h.answers.push(({options})=>{const body=JSON.parse(options.body);assert.equal(body.expected_checkpoint,C);assert.equal(body.expected_revision,0);assert.equal(body.expected_sha256,v.sha256);assert.equal(body.path,'scripts/inert.py');assert.ok(!Object.hasOwn(body,'text'));return response(bound({id:fileID,project_id:P,kind:'script',path:body.path,revision:1,sha256:v.sha256,bytes:v.bytes,adopted_at:v.adopted_at,changed:true},NEXT));},response(bound({items:[],offset:0,limit:40,total:0},NEXT)),response(bound(selected('new exact text',1),NEXT))); + await h.submit('project-working-adopt-form');assert.equal(h.nodes.get('project-files-error').textContent,'');assert.equal(h.nodes.get('project-working-text').value,'new exact text');assert.equal(h.nodes.get('project-working-revision').value,'1');assert.match(h.nodes.get('project-files-status').textContent,/remains inert/); + } + // Fork metadata pages and unresolved binding details remain read-only. The + // original definition is fetched only after a user selects that exact row. + { + const h=mount();await tick();const origin={project_id:FORK,checkpoint:NEXT,family:'model_definition',id:ACTOR,revision:2,created_at:'2026-10-22T12:00:00+00:00'},current={format:'project_material_fork_intent_v1',catalog_id:P,id:ACTOR,revision:1,name:'Retained