diff --git a/mise-tasks/logseq/entity/proxy/page/lib/companions.py b/mise-tasks/logseq/entity/proxy/page/lib/task_imports.py similarity index 92% rename from mise-tasks/logseq/entity/proxy/page/lib/companions.py rename to mise-tasks/logseq/entity/proxy/page/lib/task_imports.py index 7e67f6ee95..f5adc9eebf 100644 --- a/mise-tasks/logseq/entity/proxy/page/lib/companions.py +++ b/mise-tasks/logseq/entity/proxy/page/lib/task_imports.py @@ -1,4 +1,13 @@ -"""Import explicitly declared file tasks with conservative update ownership.""" +"""Plan imports of entity definitions, task references, and executable task files. + +Follow logseq-entity, entity-tasks, and task-dependencies links from the pages +already selected for sync. Read each task reference's task-files mapping to +locate the entrypoint and helpers, and add their copies to the page-sync plan. +Before replacing imported task files, check the destination manifest's source +ownership, last-imported content hashes, and file modes. Update the manifest +in the same plan so later syncs can distinguish upstream changes from local +edits. core.apply_plan writes the completed plan as one recoverable update. +""" from __future__ import annotations import hashlib @@ -64,6 +73,14 @@ def digest(data): def compatible_config(root): + """Check that the destination Mise config can discover imported file tasks. + + Read mise.toml or .mise.toml at the graph root. When task_config.includes + is set, require mise-tasks (with an optional ./ prefix or trailing slash). + An absent includes setting uses Mise's default file-task discovery. + Reject an explicit list that excludes mise-tasks before planning writes; + malformed TOML also stops the import. + """ for filename in ('mise.toml', '.mise.toml'): config = root / filename if config.exists(): diff --git a/mise-tasks/logseq/entity/proxy/page/sync b/mise-tasks/logseq/entity/proxy/page/sync index 5f0bec84a3..508bfb587d 100755 --- a/mise-tasks/logseq/entity/proxy/page/sync +++ b/mise-tasks/logseq/entity/proxy/page/sync @@ -51,13 +51,13 @@ def main(): plan = build_page_plan(options.source, options.destination, options.page, options.codeforge_url, options.follow_embeds) try: - import companions + import task_imports except ModuleNotFoundError as error: - if error.name != 'companions': + if error.name != 'task_imports': raise - raise SyncError('Missing companion importer lib/companions.py; restore the task implementation before syncing') from error + raise SyncError('Missing task importer lib/task_imports.py; restore the task implementation before syncing') from error else: - companions.extend_plan(plan) + task_imports.extend_plan(plan) print('\n'.join(plan.details)) for warning in plan.warnings: print('Warning: ' + warning) diff --git a/mise-tasks/logseq/entity/proxy/page/tests/test_core.py b/mise-tasks/logseq/entity/proxy/page/tests/test_core.py index e68dfdbfbd..75fe4afba4 100644 --- a/mise-tasks/logseq/entity/proxy/page/tests/test_core.py +++ b/mise-tasks/logseq/entity/proxy/page/tests/test_core.py @@ -277,7 +277,7 @@ def test_codeforge_inference_nested_graph_encoded_filename(self): self.assertIn('/blob/main/sub/source/pages/Book___A%253F%20B.md', text) def test_manifest_records_repository_relative_graph(self): - import companions + import task_imports repo = self.root / 'checkout' repo.mkdir() nested = self.graph('checkout/sub/source') @@ -285,7 +285,7 @@ def test_manifest_records_repository_relative_graph(self): subprocess.run(['git', 'init', '-q', str(repo)], check=True) subprocess.run(['git', '-C', str(repo), 'remote', 'add', 'origin', 'git@github.com:owner/repo.git'], check=True) plan = build_page_plan(nested, self.destination, 'Example') - companions.extend_plan(plan) + task_imports.extend_plan(plan) manifest = plan.writes['.logseq-proxy/manifest.json'].decode() self.assertNotIn(str(self.root), manifest) record = json.loads(manifest)['imports']['github.com/owner/repo::sub/source::Example'] @@ -321,9 +321,9 @@ def commit(self, repo): self.git(repo, 'commit', '-qm', 'fixture') def sync(self, source, destination): - import companions + import task_imports plan = build_page_plan(source, destination, 'Example') - companions.extend_plan(plan) + task_imports.extend_plan(plan) apply_plan(plan) return plan @@ -398,5 +398,71 @@ def test_detached_source_worktree_url_names_commit(self): self.assertIn('/blob/' + self.git(repo, 'rev-parse', 'HEAD') + '/', props['logseq-proxy-codeforge-url']) +class TaskImporterTests(unittest.TestCase): + setUp = PageSyncTests.setUp + tearDown = PageSyncTests.tearDown + graph = PageSyncTests.graph + plan = PageSyncTests.plan + + def test_imported_sync_runs_after_helper_rename_and_retains_cleanup_candidate(self): + import task_imports + import shutil + source_files = Path('mise-tasks/logseq/entity/proxy/page') + task_dir = self.source / source_files + (task_dir / 'lib').mkdir(parents=True) + mapping = {str(source_files / 'sync'): str(source_files / 'sync'), + str(source_files / 'lib/core.py'): str(source_files / 'lib/core.py'), + str(source_files / 'lib/companions.py'): str(source_files / 'lib/companions.py')} + shutil.copyfile(LIB / 'core.py', task_dir / 'lib/core.py') + shutil.copyfile(LIB / 'task_imports.py', task_dir / 'lib/companions.py') + entrypoint = (LIB.parent / 'sync').read_text() + (task_dir / 'sync').write_text(entrypoint.replace('task_imports', 'companions')) + (task_dir / 'sync').chmod(0o755) + self.source_page.write_text('logseq-entity:: [[Example/Definition]]\n- body\n') + (self.source / 'pages/Example___Definition.md').write_text('entity-tasks:: [[Example/mise/Task/sync]]\n- Definition\n') + reference = self.source / 'pages/Example___mise___Task___sync.md' + declaration = ('task-owner:: [[Owner]]\ntask-config-root:: .\n' + 'task-name:: logseq:entity:proxy:page:sync\n' + f'task-entrypoint:: {source_files}/sync\n' + f'task-files:: {json.dumps(mapping)}\n' + 'source-link:: https://example.test/owner/repo/blob/main/sync\n- Sync\n') + reference.write_text(declaration) + first = self.plan() + task_imports.extend_plan(first) + apply_plan(first) + (task_dir / 'lib/companions.py').rename(task_dir / 'lib/task_imports.py') + (task_dir / 'sync').write_text(entrypoint) + reference.write_text(declaration.replace('companions.py', 'task_imports.py')) + refreshed = self.plan() + task_imports.extend_plan(refreshed) + self.assertTrue(any('companions.py: removed upstream; cleanup candidate' in item for item in refreshed.warnings)) + apply_plan(refreshed) + self.assertTrue((self.destination / source_files / 'lib/companions.py').is_file()) + self.assertTrue((self.destination / source_files / 'lib/task_imports.py').is_file()) + result = subprocess.run([sys.executable, str(self.destination / source_files / 'sync'), + '--source', str(self.source), '--destination', str(self.destination), + '--page', self.page], text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('unchanged: ' + str(source_files / 'lib/task_imports.py'), result.stdout) + repeat = self.plan() + task_imports.extend_plan(repeat) + self.assertTrue(all(data == repeat.expected[path] for path, data in repeat.writes.items())) + + def test_destination_task_discovery_config_matches_documented_variants(self): + import task_imports + config = self.destination / 'mise.toml' + for text in ('', '[task_config]\nincludes = ["mise-tasks"]\n', + '[task_config]\nincludes = ["./mise-tasks/"]\n'): + with self.subTest(text=text): + config.write_text(text) + task_imports.compatible_config(self.destination) + config.write_text('[task_config]\nincludes = ["elsewhere"]\n') + with self.assertRaisesRegex(ValueError, 'must include mise-tasks'): + task_imports.compatible_config(self.destination) + config.write_text('malformed = [') + with self.assertRaises(ValueError): + task_imports.compatible_config(self.destination) + + if __name__ == '__main__': unittest.main() diff --git a/pages/Logseq___Entity___Proxy___Page.md b/pages/Logseq___Entity___Proxy___Page.md index bab2818bb8..ac77b9dc8c 100644 --- a/pages/Logseq___Entity___Proxy___Page.md +++ b/pages/Logseq___Entity___Proxy___Page.md @@ -63,11 +63,15 @@ entity-proxy-destination-properties:: public - ## Executable sync - [[Logseq/Entity/Proxy/Page/mise/Task/sync]] previews the page, entity definitions, companion task references, implementation files, and assets imported by a sync. `--apply` applies the previewed changes. - An instance's `logseq-entity::` links lead to its source entity definitions. Each definition's `entity-tasks::` declares companion task references, whose file mappings and task dependencies follow [[Logseq/Entity/Mise/Task]]. The sync visits shared and cyclic dependencies once. - - `.logseq-proxy/manifest.json` in the destination graph records source repository and graph root, logical page identity, source-to-destination file mappings, ownership, and last imported content hashes. - - The manifest retains the latest source companion declarations. When a proxy becomes the source for another garden, task discovery uses those declarations and resolves imported implementation paths through the source manifest. - Re-sync refreshes source changes where imported implementation files still match their recorded hashes. Local implementation edits, unowned destination files, and competing source claims produce conflicts before application. - Page properties and bodies refresh from the source per **Invariants** above. Files removed from upstream task mappings are reported as cleanup candidates and retained locally. - A preview reports logical entity definitions without files separately from missing declared tasks or implementation files. Missing source assets remain warnings; missing task requirements stop application. + - ## Import manifest + - `.logseq-proxy/manifest.json` in the destination graph records source repository and graph path, logical page identity, source-to-destination file mappings, ownership, and last imported content hashes and modes. It accompanies the imported pages and task files in version control. + - `files` records implementation ownership and the last import's hash and mode; `pages` records source identity for each logical page. `tasks` retains each task's identity and file mapping; `imports` records the implementation paths associated with each requested page. + - `declarations` retains the latest source entity and task properties. When a proxy becomes the source for another garden, task discovery uses those saved declarations and resolves imported implementation paths through the source manifest. + - A re-sync compares each imported implementation file with its recorded hash and mode before replacing it. Deleting the manifest loses that baseline; existing task files then appear unowned and require reconciliation before importing again. + - [The illustrated manifest explanation](../mise-tasks/logseq/entity/proxy/page/docs/index.html#manifest) describes the sections, declaration precedence, and update checks. [[Logseq/Entity/Mise/Task]] specifies the `source-link::` and `task-files::` declarations from which task imports are planned. - ## Examples in this garden - [[Book/ML with PyTorch and Scikit-Learn]] - [[Person/Thomas Parr/Book/2022/Active Inference]] (legacy name shape) \ No newline at end of file diff --git a/pages/Logseq___Entity___Proxy___Page___mise___Task___sync.md b/pages/Logseq___Entity___Proxy___Page___mise___Task___sync.md index 19f4380dfc..b8b8129deb 100644 --- a/pages/Logseq___Entity___Proxy___Page___mise___Task___sync.md +++ b/pages/Logseq___Entity___Proxy___Page___mise___Task___sync.md @@ -3,7 +3,7 @@ task-owner:: [[Person/codekiln/GitHub/logseq-encode-garden]] task-config-root:: . task-name:: logseq:entity:proxy:page:sync task-entrypoint:: mise-tasks/logseq/entity/proxy/page/sync -task-files:: {"mise-tasks/logseq/entity/proxy/page/sync":"mise-tasks/logseq/entity/proxy/page/sync","mise-tasks/logseq/entity/proxy/page/lib/core.py":"mise-tasks/logseq/entity/proxy/page/lib/core.py","mise-tasks/logseq/entity/proxy/page/lib/companions.py":"mise-tasks/logseq/entity/proxy/page/lib/companions.py"} +task-files:: {"mise-tasks/logseq/entity/proxy/page/sync":"mise-tasks/logseq/entity/proxy/page/sync","mise-tasks/logseq/entity/proxy/page/lib/core.py":"mise-tasks/logseq/entity/proxy/page/lib/core.py","mise-tasks/logseq/entity/proxy/page/lib/task_imports.py":"mise-tasks/logseq/entity/proxy/page/lib/task_imports.py"} task-dependencies:: [[Logseq/Entity/Proxy/Page/mise/Task/docs/serve]] source-link:: https://github.com/codekiln/logseq-encode-garden/blob/main/mise-tasks/logseq/entity/proxy/page/sync see-also:: [[Logseq/Entity/Proxy/Page]], [[Logseq/Entity/Definition]] @@ -63,7 +63,7 @@ see-also:: [[Logseq/Entity/Proxy/Page]], [[Logseq/Entity/Definition]] ~~~ - The server binds to `127.0.0.1`; `--port` selects a port, `--no-open` keeps browser selection manual, and Ctrl-C stops the server. - ## Source and help - - [Sync file task](https://github.com/codekiln/logseq-encode-garden/blob/main/mise-tasks/logseq/entity/proxy/page/sync), [page sync implementation](https://github.com/codekiln/logseq-encode-garden/blob/main/mise-tasks/logseq/entity/proxy/page/lib/core.py), and [companion import implementation](https://github.com/codekiln/logseq-encode-garden/blob/main/mise-tasks/logseq/entity/proxy/page/lib/companions.py). + - [Sync file task](https://github.com/codekiln/logseq-encode-garden/blob/main/mise-tasks/logseq/entity/proxy/page/sync), [page sync implementation](https://github.com/codekiln/logseq-encode-garden/blob/main/mise-tasks/logseq/entity/proxy/page/lib/core.py), and [task import and manifest implementation](https://github.com/codekiln/logseq-encode-garden/blob/main/mise-tasks/logseq/entity/proxy/page/lib/task_imports.py). - ~~~sh mise run logseq:entity:proxy:page:sync --help ~~~