talos-code 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +111 -3
- package/THIRD_PARTY_NOTICES.md +40 -0
- package/dist/archive/zip.js +73 -0
- package/dist/args.js +97 -0
- package/dist/automations/history-store.js +34 -0
- package/dist/automations/policy-store.js +43 -0
- package/dist/automations/runner.js +107 -0
- package/dist/automations/schedule.js +85 -0
- package/dist/automations/windows-task.js +53 -0
- package/dist/commands/advanced-cli.js +233 -0
- package/dist/commands/checkpoint-cli.js +45 -0
- package/dist/commands/config-cli.js +107 -0
- package/dist/commands/context.js +17 -0
- package/dist/commands/extensions-cli.js +207 -0
- package/dist/commands/project-cli.js +74 -0
- package/dist/commands/project-commands.js +143 -0
- package/dist/commands/provider-cli.js +185 -0
- package/dist/commands/services-cli.js +248 -0
- package/dist/commands/session-cli.js +171 -0
- package/dist/commands/system-cli.js +405 -0
- package/dist/config/commands.js +60 -0
- package/dist/config/load.js +382 -0
- package/dist/config/migrations.js +48 -0
- package/dist/config/types.js +11 -0
- package/dist/diagnostics/development-log.js +154 -0
- package/dist/diagnostics/doctor.js +104 -0
- package/dist/diagnostics/redact.js +80 -0
- package/dist/diagnostics/zip.js +52 -0
- package/dist/errors.js +156 -0
- package/dist/events/bridge.js +130 -0
- package/dist/extensions/installer.js +178 -0
- package/dist/extensions/package-schema.js +24 -0
- package/dist/headless/result.js +84 -0
- package/dist/headless/run.js +230 -0
- package/dist/i18n/en/approval.js +43 -0
- package/dist/i18n/en/common.js +9 -0
- package/dist/i18n/en/credentials.js +82 -0
- package/dist/i18n/en/errors.js +246 -0
- package/dist/i18n/en/firstrun.js +89 -0
- package/dist/i18n/en/providers.js +37 -0
- package/dist/i18n/en/screen.js +736 -0
- package/dist/i18n/en/tools.js +127 -0
- package/dist/i18n/en.js +14 -0
- package/dist/i18n/error-view.js +307 -0
- package/dist/i18n/index.js +19 -0
- package/dist/i18n/kernel-map.js +139 -0
- package/dist/io.js +45 -0
- package/dist/main.js +330 -0
- package/dist/paths.js +41 -0
- package/dist/protocol/v2/codec.js +9 -0
- package/dist/protocol/v2/events.js +555 -0
- package/dist/protocol/v2/index.js +4 -0
- package/dist/protocol/v2/replay.js +28 -0
- package/dist/protocol/v2/types.js +1 -0
- package/dist/provider/control-plane.js +135 -0
- package/dist/provider/environment-keys.js +275 -0
- package/dist/provider/health.js +110 -0
- package/dist/provider/missing-key.js +48 -0
- package/dist/provider/model-catalog.js +168 -0
- package/dist/provider/provider-text.js +13 -0
- package/dist/provider/store.js +95 -0
- package/dist/provider/system-keyring.js +214 -0
- package/dist/runtime/active-run.js +63 -0
- package/dist/runtime/agent-tree.js +74 -0
- package/dist/runtime/attachments.js +84 -0
- package/dist/runtime/brokered-executor.js +395 -0
- package/dist/runtime/context-status.js +76 -0
- package/dist/runtime/create-runtime.js +111 -0
- package/dist/runtime/model-profile.js +361 -0
- package/dist/runtime/output-store.js +137 -0
- package/dist/runtime/provider-attempts.js +185 -0
- package/dist/runtime/replay-buffer.js +153 -0
- package/dist/runtime/repo.js +95 -0
- package/dist/runtime/session-facade.js +135 -0
- package/dist/runtime/session-summary.js +125 -0
- package/dist/runtime/supervisor.js +262 -0
- package/dist/runtime/talos-composition.js +1006 -0
- package/dist/runtime/types.js +5 -0
- package/dist/runtime/usage-snapshot.js +82 -0
- package/dist/security/auto-classifier.js +38 -0
- package/dist/security/credential-free-environment.js +54 -0
- package/dist/security/evaluate.js +243 -0
- package/dist/security/execution-backends.js +236 -0
- package/dist/security/execution-broker.js +102 -0
- package/dist/security/forge-scan.js +74 -0
- package/dist/security/from-approval.js +39 -0
- package/dist/security/mxc-execution-backend.js +281 -0
- package/dist/security/permission-engine.js +138 -0
- package/dist/security/permission-explanation.js +54 -0
- package/dist/security/persist.js +179 -0
- package/dist/security/plugin-guard.js +230 -0
- package/dist/security/process-tree-evidence-store.js +74 -0
- package/dist/security/process-tree-probe.js +554 -0
- package/dist/security/project-resource-inventory.js +186 -0
- package/dist/security/project-trust-gate.js +38 -0
- package/dist/security/project-trust.js +367 -0
- package/dist/security/rule-parser.js +201 -0
- package/dist/security/shell-segmentation.js +68 -0
- package/dist/security/trust-authority.js +324 -0
- package/dist/security/types.js +1 -0
- package/dist/security/workspace-identity.js +102 -0
- package/dist/services/automation-facade.js +59 -0
- package/dist/services/forge-facade.js +77 -0
- package/dist/services/hook-facade.js +240 -0
- package/dist/services/index.js +16 -0
- package/dist/services/library-facade.js +174 -0
- package/dist/services/mcp-facade.js +348 -0
- package/dist/services/memory-facade.js +126 -0
- package/dist/services/notes-facade.js +157 -0
- package/dist/services/plugin-facade.js +308 -0
- package/dist/services/research-facade.js +110 -0
- package/dist/services/task-board-facade.js +236 -0
- package/dist/services/workflow-catalog.js +246 -0
- package/dist/sessions/export-format.js +98 -0
- package/dist/sessions/metadata-store.js +160 -0
- package/dist/sessions/share.js +122 -0
- package/dist/sessions/transfer.js +16 -0
- package/dist/subcommands.js +62 -0
- package/dist/tui/agent-roster.js +36 -0
- package/dist/tui/app.js +3761 -0
- package/dist/tui/approval.js +36 -0
- package/dist/tui/boot/boot-sequence.js +76 -0
- package/dist/tui/boot/cinematic.js +243 -0
- package/dist/tui/boot/desktop-logo.js +82 -0
- package/dist/tui/boot.js +3 -0
- package/dist/tui/busy-input.js +58 -0
- package/dist/tui/catalog-service.js +559 -0
- package/dist/tui/components/assistant-stream.js +1 -0
- package/dist/tui/components/command-menu.js +68 -0
- package/dist/tui/components/composer.js +219 -0
- package/dist/tui/components/diff.js +35 -0
- package/dist/tui/components/footer.js +48 -0
- package/dist/tui/components/header.js +6 -0
- package/dist/tui/components/markdown.js +305 -0
- package/dist/tui/components/status-indicator.js +32 -0
- package/dist/tui/components/terminal-shell.js +205 -0
- package/dist/tui/components/thinking-row.js +19 -0
- package/dist/tui/components/tool-row.js +97 -0
- package/dist/tui/components/transcript-virtualizer.js +165 -0
- package/dist/tui/components/transcript.js +43 -0
- package/dist/tui/diff-model.js +77 -0
- package/dist/tui/editor-history.js +21 -0
- package/dist/tui/editor.js +210 -0
- package/dist/tui/event-adapter.js +436 -0
- package/dist/tui/exit-output.js +58 -0
- package/dist/tui/external-editor.js +53 -0
- package/dist/tui/file-completion.js +89 -0
- package/dist/tui/focus-manager.js +5 -0
- package/dist/tui/highlight.js +30 -0
- package/dist/tui/input-router.js +18 -0
- package/dist/tui/interrupt.js +99 -0
- package/dist/tui/keybindings.js +194 -0
- package/dist/tui/keymap-resolver.js +91 -0
- package/dist/tui/launch-state.js +139 -0
- package/dist/tui/line-diff.js +57 -0
- package/dist/tui/live-activity.js +45 -0
- package/dist/tui/metrics.js +123 -0
- package/dist/tui/onboarding.js +36 -0
- package/dist/tui/overlays/agent-tree.js +39 -0
- package/dist/tui/overlays/approval-dialog.js +640 -0
- package/dist/tui/overlays/automation-center.js +51 -0
- package/dist/tui/overlays/checkpoint-picker.js +47 -0
- package/dist/tui/overlays/context-inspector.js +36 -0
- package/dist/tui/overlays/effort-line.js +110 -0
- package/dist/tui/overlays/forge-center.js +46 -0
- package/dist/tui/overlays/help-dialog.js +16 -0
- package/dist/tui/overlays/history-picker.js +37 -0
- package/dist/tui/overlays/hook-center.js +70 -0
- package/dist/tui/overlays/library-center.js +55 -0
- package/dist/tui/overlays/mcp-center.js +60 -0
- package/dist/tui/overlays/memory-center.js +63 -0
- package/dist/tui/overlays/model-picker.js +72 -0
- package/dist/tui/overlays/notes-center.js +53 -0
- package/dist/tui/overlays/overlay-host.js +8 -0
- package/dist/tui/overlays/plugin-center.js +76 -0
- package/dist/tui/overlays/provider-picker.js +145 -0
- package/dist/tui/overlays/queue-editor.js +32 -0
- package/dist/tui/overlays/research-center.js +59 -0
- package/dist/tui/overlays/scroll-window.js +234 -0
- package/dist/tui/overlays/session-picker.js +115 -0
- package/dist/tui/overlays/tasks-center.js +83 -0
- package/dist/tui/overlays/theme-picker.js +21 -0
- package/dist/tui/overlays/transcript-search.js +58 -0
- package/dist/tui/overlays/trust-center.js +29 -0
- package/dist/tui/overlays/workflow-center.js +46 -0
- package/dist/tui/project-file-index.js +214 -0
- package/dist/tui/project-references.js +85 -0
- package/dist/tui/project-trust-prompt.js +287 -0
- package/dist/tui/prompt-history-store.js +116 -0
- package/dist/tui/queue-store.js +110 -0
- package/dist/tui/regions/budget.js +59 -0
- package/dist/tui/regions/views.js +96 -0
- package/dist/tui/render-coordinator.js +148 -0
- package/dist/tui/render-scheduler.js +4 -0
- package/dist/tui/run.js +69 -0
- package/dist/tui/selection-list.js +29 -0
- package/dist/tui/session-controller.js +778 -0
- package/dist/tui/session-export.js +54 -0
- package/dist/tui/shell-input.js +35 -0
- package/dist/tui/shell-layout.js +48 -0
- package/dist/tui/shell-model.js +98 -0
- package/dist/tui/slash-commands.js +80 -0
- package/dist/tui/state.js +151 -0
- package/dist/tui/status-bar.js +125 -0
- package/dist/tui/status-view.js +40 -0
- package/dist/tui/terminal-capabilities.js +9 -0
- package/dist/tui/terminal-session.js +11 -0
- package/dist/tui/text-width.js +66 -0
- package/dist/tui/theme-catalog.js +25 -0
- package/dist/tui/theme-store.js +23 -0
- package/dist/tui/theme.js +23 -0
- package/dist/tui/tool-display.js +135 -0
- package/dist/tui/tool-facts.js +1 -0
- package/dist/tui/tools/bash-renderer.js +32 -0
- package/dist/tui/tools/change.js +61 -0
- package/dist/tui/tools/edit-renderer.js +17 -0
- package/dist/tui/tools/generic-renderer.js +13 -0
- package/dist/tui/tools/list-renderer.js +14 -0
- package/dist/tui/tools/read-renderer.js +13 -0
- package/dist/tui/tools/registry.js +20 -0
- package/dist/tui/tools/row-format.js +187 -0
- package/dist/tui/tools/search-renderer.js +38 -0
- package/dist/tui/tools/shared.js +97 -0
- package/dist/tui/tools/write-renderer.js +15 -0
- package/dist/tui/transcript-model.js +441 -0
- package/dist/tui/ui-preferences.js +79 -0
- package/dist/tui/usage-view.js +77 -0
- package/dist/tui/vim-mode.js +99 -0
- package/dist/update/check.js +15 -0
- package/dist/update/npm.js +51 -0
- package/dist/update/run.js +129 -0
- package/dist/version.js +2 -0
- package/dist/workspace/checkpoint-store.js +210 -0
- package/dist/workspace/checkpoint.js +413 -0
- package/dist/workspace/restore.js +232 -0
- package/package.json +63 -5
- package/vendor/context-engine/package.json +11 -0
- package/vendor/context-engine/src/compaction-planner.mjs +57 -0
- package/vendor/context-engine/src/contracts.mjs +48 -0
- package/vendor/context-engine/src/engine.mjs +445 -0
- package/vendor/context-engine/src/node/context-export.mjs +164 -0
- package/vendor/context-engine/src/node/legacy-import.mjs +92 -0
- package/vendor/context-engine/src/node/migrations/001-context.sql +126 -0
- package/vendor/context-engine/src/node/sqlite-store.mjs +85 -0
- package/vendor/context-engine/src/node/sqlite-worker.mjs +559 -0
- package/vendor/context-engine/src/profiles.mjs +10 -0
- package/vendor/context-engine/src/retrieval.mjs +104 -0
- package/vendor/context-engine/src/summary.mjs +97 -0
- package/vendor/context-engine/src/usage.mjs +34 -0
- package/vendor/harness-ui/package.json +38 -0
- package/vendor/harness-ui/src/acp-agent.mjs +350 -0
- package/vendor/harness-ui/src/agent-service.mjs +2188 -0
- package/vendor/harness-ui/src/agui-events.mjs +452 -0
- package/vendor/harness-ui/src/ambiente-solo-server.mjs +121 -0
- package/vendor/harness-ui/src/artifact-store.mjs +39 -0
- package/vendor/harness-ui/src/assistenza.mjs +123 -0
- package/vendor/harness-ui/src/automation-scheduler.mjs +69 -0
- package/vendor/harness-ui/src/automation-store.mjs +145 -0
- package/vendor/harness-ui/src/browser-annota.mjs +639 -0
- package/vendor/harness-ui/src/browser-frame.mjs +211 -0
- package/vendor/harness-ui/src/browser-proxy-universale.mjs +519 -0
- package/vendor/harness-ui/src/browser-proxy.mjs +87 -0
- package/vendor/harness-ui/src/browser-sessione-viva.mjs +329 -0
- package/vendor/harness-ui/src/browser-stream.mjs +445 -0
- package/vendor/harness-ui/src/browser-vivo.mjs +694 -0
- package/vendor/harness-ui/src/chat-image-attachments.mjs +72 -0
- package/vendor/harness-ui/src/config.mjs +697 -0
- package/vendor/harness-ui/src/contesto-del-progetto.mjs +385 -0
- package/vendor/harness-ui/src/context-asset-adapter.mjs +72 -0
- package/vendor/harness-ui/src/context-desktop-service.mjs +253 -0
- package/vendor/harness-ui/src/context-embedding-runtime.mjs +252 -0
- package/vendor/harness-ui/src/context-inference-scheduler.mjs +81 -0
- package/vendor/harness-ui/src/context-native-compaction.mjs +75 -0
- package/vendor/harness-ui/src/context-provider-adapter.mjs +141 -0
- package/vendor/harness-ui/src/context-runtime.mjs +118 -0
- package/vendor/harness-ui/src/context-token-counters.mjs +184 -0
- package/vendor/harness-ui/src/context-tool-catalog.mjs +86 -0
- package/vendor/harness-ui/src/context-tool-output.mjs +72 -0
- package/vendor/harness-ui/src/costo-elenco.mjs +252 -0
- package/vendor/harness-ui/src/custom-task.mjs +171 -0
- package/vendor/harness-ui/src/doctor.mjs +142 -0
- package/vendor/harness-ui/src/document-filename.mjs +97 -0
- package/vendor/harness-ui/src/document-generator.mjs +493 -0
- package/vendor/harness-ui/src/document-report.mjs +331 -0
- package/vendor/harness-ui/src/duckduckgo-search.mjs +155 -0
- package/vendor/harness-ui/src/elenco-profondo.mjs +337 -0
- package/vendor/harness-ui/src/favicon-proxy.mjs +113 -0
- package/vendor/harness-ui/src/forge-contract.mjs +221 -0
- package/vendor/harness-ui/src/frequent-dirs.mjs +134 -0
- package/vendor/harness-ui/src/generated-image-store.mjs +147 -0
- package/vendor/harness-ui/src/generation-idle.mjs +332 -0
- package/vendor/harness-ui/src/gguf-header.mjs +207 -0
- package/vendor/harness-ui/src/git-service.mjs +626 -0
- package/vendor/harness-ui/src/gitignore-elenco.mjs +604 -0
- package/vendor/harness-ui/src/harness-receipt-keypair.mjs +207 -0
- package/vendor/harness-ui/src/hf-direct-transfer.mjs +170 -0
- package/vendor/harness-ui/src/hf-hub-client.mjs +106 -0
- package/vendor/harness-ui/src/hf-image-proxy.mjs +105 -0
- package/vendor/harness-ui/src/hf-model-transfer.mjs +245 -0
- package/vendor/harness-ui/src/hook-registry.mjs +186 -0
- package/vendor/harness-ui/src/http-app.mjs +6259 -0
- package/vendor/harness-ui/src/http-lifecycle.mjs +132 -0
- package/vendor/harness-ui/src/id-archivio.mjs +27 -0
- package/vendor/harness-ui/src/image-generator.mjs +143 -0
- package/vendor/harness-ui/src/istruzioni-di-progetto.mjs +234 -0
- package/vendor/harness-ui/src/kernel/dist/kernelPerIlBanco.js +518 -0
- package/vendor/harness-ui/src/kernel/talosHarness.mjs +10437 -0
- package/vendor/harness-ui/src/library-policy-store.mjs +175 -0
- package/vendor/harness-ui/src/library-store.mjs +652 -0
- package/vendor/harness-ui/src/llama-server-supervisor.mjs +629 -0
- package/vendor/harness-ui/src/local-model-store.mjs +339 -0
- package/vendor/harness-ui/src/local-runtime-contract.mjs +66 -0
- package/vendor/harness-ui/src/local-runtime-events.mjs +44 -0
- package/vendor/harness-ui/src/local-runtime-llama-server.mjs +244 -0
- package/vendor/harness-ui/src/local-runtime-probe.mjs +401 -0
- package/vendor/harness-ui/src/machine-capacity.mjs +66 -0
- package/vendor/harness-ui/src/mappa-cartelle.mjs +491 -0
- package/vendor/harness-ui/src/mcp-client.mjs +98 -0
- package/vendor/harness-ui/src/mcp-registry.mjs +157 -0
- package/vendor/harness-ui/src/mcp-session.mjs +177 -0
- package/vendor/harness-ui/src/memory-store.mjs +227 -0
- package/vendor/harness-ui/src/model-catalog-models-dev.mjs +276 -0
- package/vendor/harness-ui/src/model-catalog.mjs +129 -0
- package/vendor/harness-ui/src/model-destination.mjs +189 -0
- package/vendor/harness-ui/src/modifica-ancorata.mjs +177 -0
- package/vendor/harness-ui/src/native-provider-adapter.mjs +205 -0
- package/vendor/harness-ui/src/notes-store.mjs +250 -0
- package/vendor/harness-ui/src/openai-compatible-runtime.mjs +428 -0
- package/vendor/harness-ui/src/openrouter-oauth.mjs +339 -0
- package/vendor/harness-ui/src/path-policy.mjs +442 -0
- package/vendor/harness-ui/src/plugin-registry.mjs +780 -0
- package/vendor/harness-ui/src/plugin-session.mjs +180 -0
- package/vendor/harness-ui/src/process-policy.mjs +345 -0
- package/vendor/harness-ui/src/prompt-enhancer-provider.mjs +94 -0
- package/vendor/harness-ui/src/provider-auth-cloud.mjs +95 -0
- package/vendor/harness-ui/src/provider-credential-store.mjs +541 -0
- package/vendor/harness-ui/src/provider-probe.mjs +582 -0
- package/vendor/harness-ui/src/provider-registry.mjs +1633 -0
- package/vendor/harness-ui/src/pty-terminal.mjs +312 -0
- package/vendor/harness-ui/src/public-problem.mjs +109 -0
- package/vendor/harness-ui/src/research/card.mjs +235 -0
- package/vendor/harness-ui/src/research/citations.mjs +142 -0
- package/vendor/harness-ui/src/research/collector.mjs +275 -0
- package/vendor/harness-ui/src/research/deposito-a-pezzi.mjs +139 -0
- package/vendor/harness-ui/src/research/dossier.mjs +114 -0
- package/vendor/harness-ui/src/research/esportazioni.mjs +560 -0
- package/vendor/harness-ui/src/research/fetch-cache.mjs +465 -0
- package/vendor/harness-ui/src/research/fidelity.mjs +122 -0
- package/vendor/harness-ui/src/research/independence.mjs +159 -0
- package/vendor/harness-ui/src/research/ledger.mjs +166 -0
- package/vendor/harness-ui/src/research/markdown-server.mjs +565 -0
- package/vendor/harness-ui/src/research/narration.mjs +181 -0
- package/vendor/harness-ui/src/research/open-cards.mjs +131 -0
- package/vendor/harness-ui/src/research/opposing.mjs +305 -0
- package/vendor/harness-ui/src/research/outline.mjs +111 -0
- package/vendor/harness-ui/src/research/page-budget.mjs +209 -0
- package/vendor/harness-ui/src/research/pdf.mjs +291 -0
- package/vendor/harness-ui/src/research/plan.mjs +301 -0
- package/vendor/harness-ui/src/research/raccolta-viva.mjs +452 -0
- package/vendor/harness-ui/src/research/recheck-document.mjs +69 -0
- package/vendor/harness-ui/src/research/recheck-history.mjs +192 -0
- package/vendor/harness-ui/src/research/recheck.mjs +194 -0
- package/vendor/harness-ui/src/research/report.mjs +203 -0
- package/vendor/harness-ui/src/research/run.mjs +527 -0
- package/vendor/harness-ui/src/research/synthesis.mjs +318 -0
- package/vendor/harness-ui/src/research/verification.mjs +572 -0
- package/vendor/harness-ui/src/research-orchestrator.mjs +2679 -0
- package/vendor/harness-ui/src/research-store.mjs +1133 -0
- package/vendor/harness-ui/src/runtime-build-manifest.mjs +26 -0
- package/vendor/harness-ui/src/runtime-contract.mjs +59 -0
- package/vendor/harness-ui/src/runtime-owner-adapter.mjs +1348 -0
- package/vendor/harness-ui/src/runtime-owner-contract.mjs +32 -0
- package/vendor/harness-ui/src/scheda-di-lavoro.mjs +249 -0
- package/vendor/harness-ui/src/search-source-store.mjs +172 -0
- package/vendor/harness-ui/src/session-registry.mjs +6095 -0
- package/vendor/harness-ui/src/session-store.mjs +220 -0
- package/vendor/harness-ui/src/sessione-pronta.mjs +73 -0
- package/vendor/harness-ui/src/setup-stato.mjs +31 -0
- package/vendor/harness-ui/src/sezioni-istruzioni.mjs +204 -0
- package/vendor/harness-ui/src/skill-registry.mjs +120 -0
- package/vendor/harness-ui/src/sse-replay-coalescente.mjs +0 -0
- package/vendor/harness-ui/src/static-files.mjs +96 -0
- package/vendor/harness-ui/src/stream-partition.mjs +123 -0
- package/vendor/harness-ui/src/subagent-orchestrator.mjs +453 -0
- package/vendor/harness-ui/src/task-catalog.mjs +65 -0
- package/vendor/harness-ui/src/tasks-store.mjs +220 -0
- package/vendor/harness-ui/src/terminal-registry.mjs +312 -0
- package/vendor/harness-ui/src/terminal-ws.mjs +170 -0
- package/vendor/harness-ui/src/tool-forge-store.mjs +299 -0
- package/vendor/harness-ui/src/tool-schema-normalize.mjs +100 -0
- package/vendor/harness-ui/src/usage-cache.mjs +315 -0
- package/vendor/harness-ui/src/workspace-browser.mjs +213 -0
- package/vendor/harness-ui/src/workspace-context.mjs +124 -0
- package/vendor/harness-ui/src/workspace-disk.mjs +62 -0
- package/vendor/harness-ui/src/workspace-files.mjs +589 -0
- package/vendor/harness-ui/src/workspace-info.mjs +189 -0
- package/vendor/harness-ui/src/workspace-launch-store.mjs +150 -0
- package/vendor/harness-ui/src/workspace-tree.mjs +67 -0
- package/vendor/harness-ui/src/workspace-watcher.mjs +161 -0
- package/vendor/manifest.json +170 -0
|
@@ -0,0 +1,1133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* research-store.mjs — FASE N, ottavo sistema, "Fetta onesta" di Deep
|
|
3
|
+
* Research (owner, AskUserQuestion 30/8: "Fetta onesta (consigliato)").
|
|
4
|
+
* Letto alla fonte PRIMA di disegnare questa fetta: `mobile/src/lib/
|
|
5
|
+
* research/*` (21 file, macchina a stati event-sourced — `researchRun.ts`
|
|
6
|
+
* — con verifica/indipendenza/citazioni/approvazione piano) e il
|
|
7
|
+
* CONTRATTO vero del tool, `mobile/src/lib/tools/researchTools.ts` (8
|
|
8
|
+
* tool, letti verbatim, non presunti). L'owner ha scelto esplicitamente
|
|
9
|
+
* la fetta scoperta: stesso CONTRATTO (nomi/schema/semantica dei tool),
|
|
10
|
+
* esecuzione riusando la macchina GIÀ COSTRUITA di Harness Desktop
|
|
11
|
+
* (talosLavora via avviaESegui, l'abort già usato da ferma() per la
|
|
12
|
+
* pausa, resume() per la ripresa, library-store.mjs per il rapporto
|
|
13
|
+
* finale) invece del motore event-sourced mobile (pianificazione a più
|
|
14
|
+
* linee di indagine, verifica/indipendenza/citazioni/approvazione piano)
|
|
15
|
+
* — dichiarato debito, non perso in silenzio.
|
|
16
|
+
*
|
|
17
|
+
* ⭐⭐⭐ Deep Research è PER-PROGETTO, non GLOBALE come Notes/Tasks/Memory
|
|
18
|
+
* — decisione DIVERSA dagli ultimi tre sistemi, verificata non presunta:
|
|
19
|
+
* il tool mobile stesso dice "i rapporti di ricerca SONO file di
|
|
20
|
+
* Libreria" (`library_list` li trova, li elenca come documenti — vedi
|
|
21
|
+
* il commento di testa di researchTools.ts) — e la Libreria desktop è
|
|
22
|
+
* PER-PROGETTO (FASE N, prima fetta: `.harness-ui-library/` dentro il
|
|
23
|
+
* workspace, mai cross-chat come sul mobile). Una ricerca avviata
|
|
24
|
+
* mentre si lavora sul progetto C deve finire nella Libreria di QUEL
|
|
25
|
+
* progetto, raggiungibile dalle sessioni future sullo STESSO progetto —
|
|
26
|
+
* non in un limbo globale che nessun `library_list` di progetto
|
|
27
|
+
* vedrebbe mai. Storage quindi accanto a `.harness-ui-library/`, dentro
|
|
28
|
+
* il workspace: `.harness-ui-research/<id>/`, una CARTELLA per ricerca
|
|
29
|
+
* dall'11/09/2026 (era `<id>.json`, un file solo: vedi il blocco «L4 —
|
|
30
|
+
* DA UN FILE A UNA CARTELLA» più sotto per cosa c'è dentro adesso e per
|
|
31
|
+
* come si leggono ancora le ricerche nate nella forma vecchia). Mai un
|
|
32
|
+
* array riscritto — la classe di bug già evitata altrove.
|
|
33
|
+
*
|
|
34
|
+
* ⛔⛔⛔ Deliberatamente SENZA lo stato "sta girando ORA" come campo:
|
|
35
|
+
* mobile stesso separa "il giornale" (`sources.list()`) da "sta girando
|
|
36
|
+
* adesso" (`sources.isRunning(id)`, calcolato al volo — vedi
|
|
37
|
+
* `TalosResearchToolSources.isRunning` in researchTools.ts) — questo
|
|
38
|
+
* store tiene SOLO `terminata` (null finché non è definitivamente
|
|
39
|
+
* conclusa: 'done'|'failed'|'cancelled'). "In corso" vs "in pausa" si
|
|
40
|
+
* derivano DAL VIVO in research-orchestrator.mjs confrontando
|
|
41
|
+
* `terminata` con lo stato reale della sessione in session-registry —
|
|
42
|
+
* per costruzione non può disallinearsi, mai duplicato qui.
|
|
43
|
+
*
|
|
44
|
+
* Stile DI: stesso pattern di library-store.mjs/notes-store.mjs —
|
|
45
|
+
* funzioni async con `deps` opzionali per i test, mai un vero
|
|
46
|
+
* filesystem mockato altrove.
|
|
47
|
+
*/
|
|
48
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
49
|
+
import { promises as fsp } from 'node:fs';
|
|
50
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
51
|
+
|
|
52
|
+
export class ResearchStoreError extends Error {
|
|
53
|
+
constructor(message, code = 'RESEARCH_INVALID') {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = 'ResearchStoreError';
|
|
56
|
+
this.code = code;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const CARTELLA_RICERCA = '.harness-ui-research';
|
|
61
|
+
|
|
62
|
+
/*
|
|
63
|
+
* ⭐⭐⭐ L2 (11/09/2026) — GLI STATI TERMINALI DI UNA RICERCA, e il perché ce ne vogliono sei.
|
|
64
|
+
*
|
|
65
|
+
* Prima di oggi erano tre (`done`/`failed`/`cancelled`) e `done` si calcolava da `comeFinita`
|
|
66
|
+
* del kernel: cioè dal PROCESSO («la corsa è finita da sola»), mai dal PRODOTTO («c'è un
|
|
67
|
+
* rapporto»). Sulla sessione `d2a453a8` del 11/09 le due cose divergevano — corsa riuscita,
|
|
68
|
+
* rapporto inesistente — e la sezione mostrava un timbro verde su una scusa di 290 byte.
|
|
69
|
+
*
|
|
70
|
+
* ⛔ `failed` da solo non bastava per la ragione opposta: metteva sotto una parola sola tre
|
|
71
|
+
* guasti che si curano in modo DIVERSO. «Bloccata dal permesso» si cura cambiando il
|
|
72
|
+
* permesso (ed è il guasto di stasera), «giri esauriti» si cura alzando i giri o
|
|
73
|
+
* snellendo il contesto (è un guasto già noto e misurato sul banco), «senza rapporto» si
|
|
74
|
+
* cura ri-chiedendo il deposito. Un nome solo per tre cure diverse non è azionabile.
|
|
75
|
+
*
|
|
76
|
+
* ⛔ E i valori vivono QUI, non in una stringa sparsa per il codice: il frontend li legge
|
|
77
|
+
* come contratto (`stato` di ogni voce), e una lista scritta due volte è una lista che
|
|
78
|
+
* diverge.
|
|
79
|
+
*/
|
|
80
|
+
export const STATI_TERMINATI = Object.freeze([
|
|
81
|
+
'done',
|
|
82
|
+
'failed',
|
|
83
|
+
'cancelled',
|
|
84
|
+
'senza-rapporto',
|
|
85
|
+
'bloccata-dal-permesso',
|
|
86
|
+
'giri-esauriti',
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
/*
|
|
90
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
91
|
+
* L4 (11/09/2026) — DA UN FILE A UNA CARTELLA, E IL GIORNALE CHE LA RENDE RIPRENDIBILE
|
|
92
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
93
|
+
*
|
|
94
|
+
* Prima di oggi una ricerca era UN file: `.harness-ui-research/<id>.json`, riscritto per intero
|
|
95
|
+
* a ogni aggiornamento. Va bene per quattro campi; non va bene per una cosa che costa denaro e
|
|
96
|
+
* che si deve poter RIPRENDERE dopo un riavvio, perché un file riscritto sopra sé stesso dice
|
|
97
|
+
* cosa crede sia vero adesso e non dice mai che un passo era finito prima che il processo
|
|
98
|
+
* morisse (`src/research/run.mjs`, testa del file: «la differenza fra pagare una ricerca una
|
|
99
|
+
* volta e pagarla due»).
|
|
100
|
+
*
|
|
101
|
+
* ⇒ La forma di §6.2 del disegno:
|
|
102
|
+
*
|
|
103
|
+
* .harness-ui-research/<id>/
|
|
104
|
+
* ├── meta.json la voce (era `<id>.json`) — riscritta, ma SEMPRE in modo atomico
|
|
105
|
+
* ├── giornale.jsonl gli eventi del motore — SOLO append, mai riscritto
|
|
106
|
+
* ├── piano.json il piano approvato — riscritto in modo atomico
|
|
107
|
+
* ├── fonti/<sha256>.txt il testo TENUTO di una fonte — scritto UNA volta, mai sopra
|
|
108
|
+
* └── rapporto.md il rapporto depositato da `research_deposit` (già qui da L1)
|
|
109
|
+
*
|
|
110
|
+
* ⛔⛔ IL VINCOLO CHE COMANDA TUTTO: ciò che è costato denaro non si sovrascrive mai. Lezione
|
|
111
|
+
* già pagata in questo repo — il rilancio che ha distrutto 56 righe e $2,64 con un
|
|
112
|
+
* `writeFileSync(dove,'')` riuscito, senza un errore da nessuna parte.
|
|
113
|
+
* ⇒ due regole, non una: (a) il giornale è **solo append**; (b) tutto il resto si scrive su
|
|
114
|
+
* un temporaneo e poi si `rename`, così un crash a metà lascia il file PRECEDENTE intatto.
|
|
115
|
+
*
|
|
116
|
+
* ⭐ RICERCA WEB PRIMA DI SCRIVERE (obbligo owner) — `WebSearch` era esaurito (200/200, come per
|
|
117
|
+
* L1-L3), quindi fonti primarie via `WebFetch`, lette l'11/09/2026:
|
|
118
|
+
*
|
|
119
|
+
* - **LWN, «Ensuring data reaches disk»** (<https://lwn.net/Articles/457667/>): la sequenza
|
|
120
|
+
* sicura è **cinque** passi, non due — «1. create a new temp file (on the same file
|
|
121
|
+
* system!) 2. write data to the temp file 3. fsync() the temp file 4. rename the temp file
|
|
122
|
+
* to the appropriate name 5. fsync() the containing directory». I due `fsync` fanno lavori
|
|
123
|
+
* diversi: il primo porta i DATI su disco prima del rename, il secondo la VOCE di
|
|
124
|
+
* directory. ⇒ `scriviAtomico` qui sotto fa 1-2-3-4; il punto 5 è dichiarato e **non
|
|
125
|
+
* fatto**, vedi sotto il perché su Windows.
|
|
126
|
+
* - **`rename(2)`** (<https://man7.org/linux/man-pages/man2/rename.2.html>): «If newpath
|
|
127
|
+
* already exists, it will be atomically replaced, so that there is no point at which
|
|
128
|
+
* another process attempting to access newpath will find it missing». È la garanzia su cui
|
|
129
|
+
* poggia tutto: un lettore vede il vecchio o il nuovo, mai mezzo file.
|
|
130
|
+
* - **`MoveFileExW` / `MOVEFILE_REPLACE_EXISTING`**
|
|
131
|
+
* (<https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw>) +
|
|
132
|
+
* **libuv `src/win/fs.c`** (letto alla fonte: `fs__rename` chiama
|
|
133
|
+
* `MoveFileExW(..., MOVEFILE_REPLACE_EXISTING)`, e `fs__fsync` chiama `FlushFileBuffers`).
|
|
134
|
+
* ⛔ **Questo è il vincolo che NON conoscevo e che la ricerca ha aggiunto**: la pagina
|
|
135
|
+
* Windows promette che il contenuto viene **sostituito** «provided that security
|
|
136
|
+
* requirements regarding ACLs are met» — NON usa mai la parola «atomically» come fa POSIX,
|
|
137
|
+
* e non c'è modo di fare l'`fsync` della *directory* (su Windows non si apre una directory
|
|
138
|
+
* come file descriptor). ⇒ il punto 5 di LWN qui non è disponibile, e lo scrivo invece di
|
|
139
|
+
* lasciar credere che la ricetta sia applicata per intero. Ciò che resta garantito su
|
|
140
|
+
* entrambe le piattaforme è quello che serve davvero al vincolo: **se il rename non
|
|
141
|
+
* riesce, il file vecchio è ancora intatto** — ed è la prova che il test «crash fra
|
|
142
|
+
* temporaneo e rename» misura.
|
|
143
|
+
* - **GraphFlow** — [arXiv:2605.14968](https://arxiv.org/abs/2605.14968), 14/05/2026: «a
|
|
144
|
+
* durable engine records outcomes in an **append-only event log** and can enforce contracts
|
|
145
|
+
* at system boundaries, **supporting replay, retries, and audit**». ⇒ conferma la forma, e
|
|
146
|
+
* nomina le tre cose che il giornale compra insieme: ripresa, ritentativi e verificabilità.
|
|
147
|
+
* - **Verified Detection … in Multi-Agent LLM Systems** —
|
|
148
|
+
* [arXiv:2606.17182](https://arxiv.org/abs/2606.17182), 15/06/2026: le macchine a esecuzione
|
|
149
|
+
* durevole impongono la semantica «by **deterministic replay**». ⇒ è la ragione per cui il
|
|
150
|
+
* giornale porta SOLO fatti (`resultRef`, non il carico) e per cui rigiocare due volte lo
|
|
151
|
+
* stesso file deve dare lo stesso stato — provato, non dichiarato.
|
|
152
|
+
*
|
|
153
|
+
* ⭐ E dentro il PROPRIO codebase, prima ancora che fuori (lezione 06/09 «chi guarda da fuori
|
|
154
|
+
* inventa quello che dentro aveva già»): `session-store.mjs` ha già il giornale JSONL con la
|
|
155
|
+
* coda per percorso (W0-07, 04/09 — due `appendFile` concorrenti intrecciati su un record da
|
|
156
|
+
* 1,5 MiB) e la lettura che tollera l'ultima riga spezzata; `local-model-store.mjs`,
|
|
157
|
+
* `harness-receipt-keypair.mjs` e `generated-image-store.mjs` hanno già temporaneo+`rename`.
|
|
158
|
+
* Qui NON si inventa un sesto modo: si riusa la stessa forma, con le due differenze
|
|
159
|
+
* dichiarate più sotto (`flush` sempre acceso; una riga rotta **in mezzo** si salta invece di
|
|
160
|
+
* far fallire la lettura).
|
|
161
|
+
*/
|
|
162
|
+
|
|
163
|
+
/** La voce di metadata, dentro la cartella della ricerca. Era `<id>.json` accanto ad essa. */
|
|
164
|
+
export const NOME_META = 'meta.json';
|
|
165
|
+
/** Il giornale degli eventi — SOLO append. */
|
|
166
|
+
export const NOME_GIORNALE = 'giornale.jsonl';
|
|
167
|
+
/** Il piano approvato. */
|
|
168
|
+
export const NOME_PIANO = 'piano.json';
|
|
169
|
+
/** La cartella del testo TENUTO delle fonti, indirizzato dal contenuto. */
|
|
170
|
+
export const CARTELLA_FONTI = 'fonti';
|
|
171
|
+
/**
|
|
172
|
+
* ⭐⭐⭐ L9 (12/09/2026) — L'INDICE url → `fonti/<sha256>.txt`, e perché serve un file in più.
|
|
173
|
+
*
|
|
174
|
+
* `fonti/` è indirizzata dal CONTENUTO: è la proprietà che rende impossibile sovrascrivere una
|
|
175
|
+
* pagina già pagata, ed è quella che vogliamo tenere. Ma un'impronta non dice da quale indirizzo
|
|
176
|
+
* quel testo venga, e la verifica ha esattamente quella domanda: «l'affermazione cita
|
|
177
|
+
* <https://…>: dov'è il testo di quella pagina?». Finché il processo vive la risposta sta in
|
|
178
|
+
* memoria; dopo un riavvio non c'è più — e una ricerca ripresa consegnerebbe affermazioni «non
|
|
179
|
+
* verificate» per un motivo che non è vero (il testo c'è, non si sa solo di chi sia).
|
|
180
|
+
*
|
|
181
|
+
* ⛔ Non si mette l'URL nel NOME del file: un indirizzo non è un nome di file (lunghezza,
|
|
182
|
+
* caratteri vietati su Windows, due indirizzi che normalizzano uguale) e si perderebbe
|
|
183
|
+
* l'indirizzamento per contenuto. Un indice a parte costa una scrittura atomica e non tocca
|
|
184
|
+
* niente di ciò che già funziona.
|
|
185
|
+
* ⛔ E l'indice è un RISPARMIO, non una prova: se manca, la verifica lo dice invece di
|
|
186
|
+
* inventare — mai il contrario.
|
|
187
|
+
*/
|
|
188
|
+
export const NOME_INDICE_FONTI = 'indice-fonti.json';
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* ⭐ Il numero di formato vive sulla VOCE, non su un file a parte, e serve a una cosa sola: dire
|
|
192
|
+
* se una ricerca è nata prima o dopo il record recintato. `2` = nata con la cartella e col
|
|
193
|
+
* giornale; assente o `1` = migrata da `<id>.json`, cioè una ricerca il cui rapporto può essere
|
|
194
|
+
* solo prosa perché il record recintato non esisteva quando è stata fatta. Il cancello di
|
|
195
|
+
* consegna (`research-orchestrator.mjs`) legge questo campo per decidere se il **ripiego** sulla
|
|
196
|
+
* forma minima è lecito — mai per decidere se lo stato è `done`.
|
|
197
|
+
*/
|
|
198
|
+
export const FORMATO_CORRENTE = 2;
|
|
199
|
+
|
|
200
|
+
/** La voce, nella forma di oggi: `<progetto>/.harness-ui-research/<id>/meta.json`. */
|
|
201
|
+
export function percorsoMeta(cartella, id) {
|
|
202
|
+
return join(cartella, CARTELLA_RICERCA, id, NOME_META);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** La voce, nella forma di ieri: `<progetto>/.harness-ui-research/<id>.json`. Si legge ancora. */
|
|
206
|
+
export function percorsoVoceLegacy(cartella, id) {
|
|
207
|
+
return join(cartella, CARTELLA_RICERCA, `${id}.json`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function percorsoGiornale(cartella, id) {
|
|
211
|
+
return join(cartella, CARTELLA_RICERCA, id, NOME_GIORNALE);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function percorsoPiano(cartella, id) {
|
|
215
|
+
return join(cartella, CARTELLA_RICERCA, id, NOME_PIANO);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function cartellaDelleFonti(cartella, id) {
|
|
219
|
+
return join(cartella, CARTELLA_RICERCA, id, CARTELLA_FONTI);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function percorsoIndiceFonti(cartella, id) {
|
|
223
|
+
return join(cartella, CARTELLA_RICERCA, id, NOME_INDICE_FONTI);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* ⭐⭐⭐ LA SCRITTURA CHE NON PUÒ DISTRUGGERE QUELLA DI PRIMA.
|
|
228
|
+
*
|
|
229
|
+
* Temporaneo **nella stessa cartella** (LWN: «on the same file system!» — un temporaneo in
|
|
230
|
+
* `%TEMP%` renderebbe il `rename` una copia, che non è atomica), `flush:true` per portare i byte
|
|
231
|
+
* su disco prima del rename (LWN passo 3; su Node è `FlushFileBuffers`/`fsync` sotto), poi
|
|
232
|
+
* `rename`.
|
|
233
|
+
*
|
|
234
|
+
* ⛔ Il `catch` **non degrada in silenzio**: pulisce il temporaneo e **rilancia**. Un temporaneo
|
|
235
|
+
* lasciato lì sporcherebbe la cartella della ricerca a ogni guasto, e un errore inghiottito
|
|
236
|
+
* qui vorrebbe dire «salvato» su una voce mai salvata — la bugia esatta che L2 ha tolto dallo
|
|
237
|
+
* stato. (Lezione 10/09: «il catch GIUSTO nasconde il bug SBAGLIATO».)
|
|
238
|
+
*
|
|
239
|
+
* ⛔ Il punto 5 di LWN (`fsync` della directory) **non c'è**, ed è dichiarato: su Windows non si
|
|
240
|
+
* apre una directory per farne il flush, e questo prodotto gira lì. Conseguenza onesta: dopo
|
|
241
|
+
* un crash del SISTEMA (non del processo) la voce di directory potrebbe non essere ancora
|
|
242
|
+
* durevole. Il file vecchio resta comunque intatto: nessuna perdita di ciò che era già pagato.
|
|
243
|
+
*/
|
|
244
|
+
/*
|
|
245
|
+
* ════════════════════════════════════════════════════════════════════════════════════════════
|
|
246
|
+
* ⛔⛔⛔ 12/09/2026 — SU WINDOWS UN LETTORE FA FALLIRE IL RENAME. IL RITENTO.
|
|
247
|
+
* ════════════════════════════════════════════════════════════════════════════════════════════
|
|
248
|
+
*
|
|
249
|
+
* Il difetto, **riprodotto** (non dedotto) eseguendo la suite intera e poi isolato in sei righe:
|
|
250
|
+
*
|
|
251
|
+
* const h = fs.openSync(meta, 'r'); // un LETTORE qualunque, in sola lettura
|
|
252
|
+
* fs.renameSync(tmp, meta); // → EPERM: operation not permitted, rename
|
|
253
|
+
*
|
|
254
|
+
* Senza il lettore aperto, lo stesso rename **riesce**. ⇒ non è il disco, non è un permesso:
|
|
255
|
+
* è la contesa. Su Windows `MoveFileExW` non può sostituire una destinazione che qualcun altro
|
|
256
|
+
* tiene aperta, e libuv apre i file **senza** `FILE_SHARE_DELETE`.
|
|
257
|
+
*
|
|
258
|
+
* ⛔ E chi era il lettore, in produzione? **La sezione Ricerca**, che interroga l'elenco e la
|
|
259
|
+
* scheda **mentre** la ricerca gira. Quando la ricerca finiva, `onConclusioneRicerca` chiamava
|
|
260
|
+
* `aggiornaRicerca` → qui → EPERM → l'eccezione usciva e la voce restava **`running` sul disco
|
|
261
|
+
* per sempre**: una ricerca conclusa e pagata che a schermo non finiva mai. Visto in due corse
|
|
262
|
+
* su tre della suite intera; mai eseguendo il file da solo (è una gara, e la vince chi ha il
|
|
263
|
+
* disco più lento).
|
|
264
|
+
*
|
|
265
|
+
* ── Ricerca web PRIMA di scrivere (fonte + data) ────────────────────────────────────────────
|
|
266
|
+
* · **graceful-fs, `polyfills.js`** (isaacs) — letto il 12/09/2026. È il pattern di riferimento,
|
|
267
|
+
* quello che npm usa da anni: ritenta il `rename` su **`EACCES`, `EPERM`, `EBUSY`**, perché
|
|
268
|
+
* «on Windows, A/V software can lock the directory, causing this to fail with an EACCES or
|
|
269
|
+
* EPERM if the directory contains newly created files».
|
|
270
|
+
* ⛔ **Il vincolo che non conoscevo e che ha cambiato il codice**: si aspetta con `setTimeout`
|
|
271
|
+
* e mai con un ciclo stretto, perché «Windows scheduling gives CPU to a busy looping
|
|
272
|
+
* process, which can cause the program causing the lock contention to be **starved of CPU**
|
|
273
|
+
* by node, so the contention doesn't resolve». Un ritento che gira a vuoto **impedisce** al
|
|
274
|
+
* lettore di chiudere il suo handle: la cura diventerebbe la causa.
|
|
275
|
+
* ⛔ **E una cosa di graceful-fs che NON si copia**: prima di ogni ritento lui controlla che la
|
|
276
|
+
* destinazione non esista e, se esiste, si ferma. Serve al caso di npm, dove la
|
|
277
|
+
* destinazione **non deve** esserci. Qui la destinazione esiste **sempre** (stiamo
|
|
278
|
+
* sostituendo `meta.json`): copiarlo farebbe uscire ogni ritento al primo giro, cioè non
|
|
279
|
+
* ritentare affatto.
|
|
280
|
+
* ⛔ La sua finestra è di **60 secondi** (pensata per Parity bit9, che «may lock files for up
|
|
281
|
+
* to a minute»). Qui no: questa scrittura sta **dentro una richiesta HTTP** e dentro la
|
|
282
|
+
* conclusione di una ricerca. Dieci tentativi, attese 20→200 ms, **1,3 s** in tutto.
|
|
283
|
+
* · **MoveFileExW / `MOVEFILE_REPLACE_EXISTING`** — <https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw>,
|
|
284
|
+
* riletta il 12/09/2026: sostituisce «provided that security requirements regarding access
|
|
285
|
+
* control lists (ACLs) are met», e «to delete or rename a file, you must have either delete
|
|
286
|
+
* permission on the file or delete child permission in the parent directory». ⛔ **Va detto
|
|
287
|
+
* quello che NON dice**: la pagina non nomina gli handle aperti né l'errore che ne esce. Il
|
|
288
|
+
* legame «lettore aperto ⇒ EPERM» qui non viene da lei: viene dalla **riproduzione** qui
|
|
289
|
+
* sopra e dal test che la esegue su disco vero.
|
|
290
|
+
* · **Node, `fs.rename`/`fsPromises.rename`** — <https://nodejs.org/docs/latest/api/fs.html>:
|
|
291
|
+
* ⚠️ lettura **NON riuscita**. La pagina è tornata troncata da `WebFetch` due volte e le
|
|
292
|
+
* sezioni dei due metodi non si sono lette alla lettera. Segnato come lettura mancata, non
|
|
293
|
+
* come lettura fatta: il comportamento su cui poggia questo codice è **misurato**, non citato.
|
|
294
|
+
*/
|
|
295
|
+
const RENAME_TENTATIVI = 10;
|
|
296
|
+
const RENAME_ATTESA_INIZIALE_MS = 20;
|
|
297
|
+
const RENAME_ATTESA_MASSIMA_MS = 200;
|
|
298
|
+
/** ⛔ I tre di graceful-fs, e nessuno in più: un `ENOSPC` o un `EROFS` ritentati sono 1,3 s buttati. */
|
|
299
|
+
const CODICI_DI_CONTESA = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
|
300
|
+
/** Il nome DICHIARATO che prende un temporaneo quando il rename non riesce mai. Vedi `scriviAtomico`. */
|
|
301
|
+
export const SUFFISSO_NON_RINOMINATO = '.non-rinominato';
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Il `rename`, ritentato finché la contesa non passa.
|
|
305
|
+
*
|
|
306
|
+
* ⛔ Torna **quanti ritenti sono serviti** invece di `undefined`: una cura che non si può contare
|
|
307
|
+
* è una cura di cui nessuno saprà mai se è servita. Il test se ne serve, e domani una sonda
|
|
308
|
+
* potrà dirlo all'owner.
|
|
309
|
+
* ⛔ Un codice che non è di contesa **non si ritenta**: si rilancia subito. Aspettare 1,3 secondi
|
|
310
|
+
* per un disco pieno è tempo rubato a chi sta guardando lo schermo.
|
|
311
|
+
*
|
|
312
|
+
* @returns {Promise<number>} quanti ritenti sono serviti (0 = è andata al primo colpo)
|
|
313
|
+
*/
|
|
314
|
+
export async function rinominaConRitento(temporaneo, percorso, deps = {}) {
|
|
315
|
+
const renameFn = deps.renameFn ?? fsp.rename;
|
|
316
|
+
// ⛔ `setTimeout`, MAI un ciclo stretto: vedi graceful-fs sopra — un ciclo affamerebbe di CPU
|
|
317
|
+
// proprio il processo che tiene il file aperto, e la contesa non si scioglierebbe mai.
|
|
318
|
+
const attendiFn = deps.attendiFn ?? ((ms) => new Promise((risolvi) => { setTimeout(risolvi, ms); }));
|
|
319
|
+
const tentativi = Number.isSafeInteger(deps.tentativiRename) && deps.tentativiRename > 0
|
|
320
|
+
? deps.tentativiRename
|
|
321
|
+
: RENAME_TENTATIVI;
|
|
322
|
+
|
|
323
|
+
for (let tentativo = 0; ; tentativo += 1) {
|
|
324
|
+
try {
|
|
325
|
+
await renameFn(temporaneo, percorso);
|
|
326
|
+
return tentativo;
|
|
327
|
+
} catch (errore) {
|
|
328
|
+
if (!CODICI_DI_CONTESA.has(errore?.code) || tentativo >= tentativi - 1) throw errore;
|
|
329
|
+
// 20, 40, 80, 160, poi 200 fisso: 1,3 s in tutto su dieci tentativi.
|
|
330
|
+
await attendiFn(Math.min(RENAME_ATTESA_INIZIALE_MS * (2 ** tentativo), RENAME_ATTESA_MASSIMA_MS));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export async function scriviAtomico(percorso, contenuto, deps = {}) {
|
|
336
|
+
const mkdirFn = deps.mkdirFn ?? fsp.mkdir;
|
|
337
|
+
const writeFileFn = deps.writeFileFn ?? fsp.writeFile;
|
|
338
|
+
const rmFn = deps.rmFn ?? fsp.rm;
|
|
339
|
+
const renameFn = deps.renameFn ?? fsp.rename;
|
|
340
|
+
const randomUUIDFn = deps.randomUUIDFn ?? randomUUID;
|
|
341
|
+
await mkdirFn(dirname(percorso), { recursive: true });
|
|
342
|
+
const temporaneo = `${percorso}.tmp-${process.pid}-${randomUUIDFn()}`;
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
await writeFileFn(temporaneo, contenuto, { encoding: 'utf8', flush: true });
|
|
346
|
+
} catch (errore) {
|
|
347
|
+
/* ⛔ Qui il temporaneo SI PULISCE, ed è l'unico caso in cui è giusto: una scrittura fallita
|
|
348
|
+
lascia byte a metà, e dei byte a metà non si salva niente. */
|
|
349
|
+
try { await rmFn(temporaneo, { force: true }); } catch { /* può non essere mai nato: pulire è un di più, non una condizione. */ }
|
|
350
|
+
throw errore;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
await rinominaConRitento(temporaneo, percorso, { ...deps, renameFn });
|
|
355
|
+
} catch (errore) {
|
|
356
|
+
/*
|
|
357
|
+
* ⛔⛔⛔ QUI IL TEMPORANEO NON SI BUTTA PIÙ, ed è un cambio di contratto voluto (12/09).
|
|
358
|
+
*
|
|
359
|
+
* Fino a stamattina questo ramo faceva `rm` del temporaneo, col motivo scritto accanto: «una
|
|
360
|
+
* cartella di ricerca piena di `.tmp-` è il segno di un guasto inghiottito». Il motivo era
|
|
361
|
+
* buono, la conclusione no: a questo punto la scrittura è **riuscita** — i byte sono interi e
|
|
362
|
+
* già sul disco — ed è solo il rename a non essere passato. Cancellarli butta lavoro **già
|
|
363
|
+
* pagato** per tenere pulita una cartella, che è esattamente lo scambio che il vincolo di
|
|
364
|
+
* questo file vieta («ciò che è costato denaro non si sovrascrive mai»).
|
|
365
|
+
* ⛔ E la cura al disordine non è buttare: è **dare un nome**. Il file resta accanto come
|
|
366
|
+
* `<nome>.non-rinominato` — dichiarato, riconoscibile, e **uno solo**: un secondo guasto
|
|
367
|
+
* sovrascrive quello di prima invece di accumulare scorie con un UUID diverso ogni volta.
|
|
368
|
+
* ⛔ Se anche il parcheggio fallisce (la cartella intera è bloccata) non si lancia da qui: si
|
|
369
|
+
* tiene il nome casuale e lo si **dice**. Un errore di recupero che copre l'errore vero è
|
|
370
|
+
* il difetto che questo repo ha già pagato («il catch giusto nasconde il bug sbagliato»).
|
|
371
|
+
*/
|
|
372
|
+
let dove = temporaneo;
|
|
373
|
+
try {
|
|
374
|
+
await renameFn(temporaneo, `${percorso}${SUFFISSO_NON_RINOMINATO}`);
|
|
375
|
+
dove = `${percorso}${SUFFISSO_NON_RINOMINATO}`;
|
|
376
|
+
} catch { /* resta col nome casuale, e il messaggio qui sotto lo dice per esteso. */ }
|
|
377
|
+
/* ⛔ Si ARRICCHISCE l'errore originale invece di crearne uno nuovo: `code`, `errno`, `path` e
|
|
378
|
+
la pila appartengono al guasto vero, e un chiamante che filtra sul codice deve continuare
|
|
379
|
+
a vederlo. */
|
|
380
|
+
errore.message = `${errore.message} — il file vecchio è intatto e il nuovo contenuto NON è perso: sta in ${dove}`;
|
|
381
|
+
throw errore;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return percorso;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* @returns {Promise<Array<object>>} — [] se la cartella non esiste ancora
|
|
389
|
+
* (mai un errore: nessuna ricerca avviata su questo progetto è uno
|
|
390
|
+
* stato onesto, non un guasto). Più recenti prime, come `research_list`
|
|
391
|
+
* mobile ("«che ricerche ho fatto» quasi sempre vuol dire «le ultime»").
|
|
392
|
+
*/
|
|
393
|
+
export async function elencaRicerche({ cartella }, deps = {}) {
|
|
394
|
+
const readdirFn = deps.readdirFn ?? fsp.readdir;
|
|
395
|
+
const readFileFn = deps.readFileFn ?? fsp.readFile;
|
|
396
|
+
const cartellaRicerca = join(cartella, CARTELLA_RICERCA);
|
|
397
|
+
let voci;
|
|
398
|
+
try {
|
|
399
|
+
voci = await readdirFn(cartellaRicerca, { withFileTypes: true });
|
|
400
|
+
} catch {
|
|
401
|
+
return [];
|
|
402
|
+
}
|
|
403
|
+
/*
|
|
404
|
+
* ⭐ L4 — DUE FORME SULLO STESSO DISCO, e l'elenco le vede entrambe.
|
|
405
|
+
* ⛔ La chiave è l'ID, non il file: durante una migrazione interrotta (meta.json già scritto,
|
|
406
|
+
* `<id>.json` non ancora tolto) la stessa ricerca esiste in due posti, e mostrarla due volte
|
|
407
|
+
* sarebbe un elenco che mente. Vince la CARTELLA — è la forma nuova, ed è quella che l'ultima
|
|
408
|
+
* scrittura ha prodotto.
|
|
409
|
+
*/
|
|
410
|
+
const perId = new Map();
|
|
411
|
+
for (const voce of voci) {
|
|
412
|
+
const nome = typeof voce === 'string' ? voce : voce.name;
|
|
413
|
+
const eCartella = typeof voce === 'string' ? false : voce.isDirectory();
|
|
414
|
+
const percorso = eCartella ? join(cartellaRicerca, nome, NOME_META) : join(cartellaRicerca, nome);
|
|
415
|
+
if (!eCartella && !nome.endsWith('.json')) continue;
|
|
416
|
+
const id = eCartella ? nome : nome.slice(0, -'.json'.length);
|
|
417
|
+
if (!eCartella && perId.has(id)) continue; // la cartella, già letta, vince sul file legacy.
|
|
418
|
+
try {
|
|
419
|
+
const letta = JSON.parse(await readFileFn(percorso, 'utf8'));
|
|
420
|
+
if (eCartella || !perId.has(id)) perId.set(id, letta);
|
|
421
|
+
} catch {
|
|
422
|
+
// ⛔ una voce corrotta (o una cartella senza meta.json: una ricerca nuova può avere solo
|
|
423
|
+
// il rapporto se la metadata non è ancora stata migrata) non impedisce di vedere le altre
|
|
424
|
+
// — stesso principio di leggiRegistro (session-store.mjs) su un'ultima riga tollerata.
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const ricerche = [...perId.values()];
|
|
428
|
+
ricerche.sort((a, b) => String(b.avviataAlle || '').localeCompare(String(a.avviataAlle || '')));
|
|
429
|
+
return ricerche;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* ⭐ L4 — legge la voce nella forma di oggi (`<id>/meta.json`) e, se non c'è, in quella di ieri
|
|
434
|
+
* (`<id>.json`). **Leggere non migra**: la migrazione costa una scrittura, e una scrittura su
|
|
435
|
+
* venti voci solo per disegnare un elenco sarebbe esattamente la migrazione «in blocco» che il
|
|
436
|
+
* disegno vieta. Si migra al primo tocco che scrive — vedi `migraRicerca`.
|
|
437
|
+
*
|
|
438
|
+
* @returns {Promise<object|null>} — null se l'id non esiste in nessuna delle due forme.
|
|
439
|
+
*/
|
|
440
|
+
export async function leggiRicerca({ cartella, id }, deps = {}) {
|
|
441
|
+
const readFileFn = deps.readFileFn ?? fsp.readFile;
|
|
442
|
+
for (const percorso of [percorsoMeta(cartella, id), percorsoVoceLegacy(cartella, id)]) {
|
|
443
|
+
let grezzo;
|
|
444
|
+
try {
|
|
445
|
+
grezzo = await readFileFn(percorso, 'utf8');
|
|
446
|
+
} catch (errore) {
|
|
447
|
+
// ⛔ Non solo ENOENT: su Windows chiedere `<id>/meta.json` quando `<id>` è un FILE dà
|
|
448
|
+
// ENOTDIR/ENOENT a seconda del punto, e su POSIX dà ENOTDIR. Entrambi vogliono dire «in
|
|
449
|
+
// questa forma non c'è», non «il disco è rotto»: si prova l'altra forma.
|
|
450
|
+
if (errore?.code === 'ENOENT' || errore?.code === 'ENOTDIR') continue;
|
|
451
|
+
throw new ResearchStoreError(`${id}: metadata presente ma illeggibile: ${errore.message}`, 'RESEARCH_READ_FAILED');
|
|
452
|
+
}
|
|
453
|
+
return JSON.parse(grezzo);
|
|
454
|
+
}
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Crea la voce di metadata — chiamata da research-orchestrator.mjs SUBITO
|
|
460
|
+
* dopo che avviaESegui ha già assegnato un sessionId: l'id di una
|
|
461
|
+
* ricerca È il sessionId della sessione che la esegue (nessuna doppia
|
|
462
|
+
* mappatura ricerca→sessione, un solo spazio di identità, mai
|
|
463
|
+
* disallineabile).
|
|
464
|
+
*/
|
|
465
|
+
export async function creaRicerca({ cartella, id, domanda, profondita, padreId = null, nome = null, modello = null, modelloGiudice = null }, deps = {}) {
|
|
466
|
+
const mkdirFn = deps.mkdirFn ?? fsp.mkdir;
|
|
467
|
+
const writeFileFn = deps.writeFileFn ?? fsp.writeFile;
|
|
468
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
469
|
+
throw new ResearchStoreError('Una ricerca vuole un id', 'RESEARCH_INVALID');
|
|
470
|
+
}
|
|
471
|
+
if (typeof domanda !== 'string' || domanda.trim().length === 0) {
|
|
472
|
+
throw new ResearchStoreError('Una ricerca vuole una domanda', 'RESEARCH_INVALID');
|
|
473
|
+
}
|
|
474
|
+
if (!idRicercaValido(id)) {
|
|
475
|
+
// ⛔ L4 — l'id è diventato un NOME DI CARTELLA: quello che prima poteva al più sporcare un
|
|
476
|
+
// nome di file adesso può attraversare il disco. Il controllo c'era già a valle (nel
|
|
477
|
+
// kernel, per il deposito); qui è a monte, sul dato, dove nasce.
|
|
478
|
+
throw new ResearchStoreError(`id di ricerca non valido: ${String(id)}`, 'RESEARCH_INVALID');
|
|
479
|
+
}
|
|
480
|
+
const cartellaRicerca = cartellaDellaRicerca(cartella, id);
|
|
481
|
+
await mkdirFn(cartellaRicerca, { recursive: true });
|
|
482
|
+
const adesso = new Date().toISOString();
|
|
483
|
+
const voce = {
|
|
484
|
+
id, domanda: domanda.trim(), profondita: profondita || 'deep',
|
|
485
|
+
titolo: null, avviataAlle: adesso, aggiornataAlle: adesso,
|
|
486
|
+
terminata: null, reportLibraryId: null,
|
|
487
|
+
/*
|
|
488
|
+
* ⭐ BC-44 (12/09/2026) — nasce `null`, e resta `null` su ogni voce nata prima di oggi: il
|
|
489
|
+
* lettore lo normalizza, nessuna migrazione, nessuna riga già pagata riscritta.
|
|
490
|
+
*/
|
|
491
|
+
motivoErrore: null,
|
|
492
|
+
/*
|
|
493
|
+
* ⭐ L4 — il numero di formato. `2` = nata nella cartella, col giornale. Una voce senza
|
|
494
|
+
* questo campo è nata prima dell'11/09 e il suo rapporto non può contenere il record
|
|
495
|
+
* recintato: è l'unico caso in cui il cancello accetta il ripiego sulla forma minima.
|
|
496
|
+
*/
|
|
497
|
+
formato: FORMATO_CORRENTE,
|
|
498
|
+
/*
|
|
499
|
+
* ⭐ L1/§6.6 (11/09) — DUE campi nuovi, entrambi `null` per ogni voce nata prima di oggi
|
|
500
|
+
* (il lettore li normalizza, vedi `research-orchestrator.elenca`): non serve nessuna
|
|
501
|
+
* migrazione, e nessuna riga già pagata viene riscritta.
|
|
502
|
+
*
|
|
503
|
+
* `padreId`: la sessione che ha chiamato `research_start`. Il 11/09 la ricerca
|
|
504
|
+
* `d2a453a8` risultava `padreId:null` e all'owner è sembrata «una sessione nuova»: lo
|
|
505
|
+
* era davvero, anche nel registro. Qui il legame è persistito ANCHE sulla metadata della
|
|
506
|
+
* ricerca, non solo sulla voce di sessione, perché la sezione «Ricerca approfondita» la
|
|
507
|
+
* legge dal disco anche dopo un riavvio, quando la voce di sessione non c'è più.
|
|
508
|
+
*
|
|
509
|
+
* `nome`: la domanda troncata — l'etichetta umana della riga in elenco. Stessa ragione:
|
|
510
|
+
* il nome della SESSIONE vive in memoria (`voce.nome`), questo sopravvive al riavvio.
|
|
511
|
+
*/
|
|
512
|
+
padreId: typeof padreId === 'string' && padreId.length > 0 ? padreId : null,
|
|
513
|
+
nome: typeof nome === 'string' && nome.trim().length > 0 ? nome.trim() : null,
|
|
514
|
+
/*
|
|
515
|
+
* ⭐⭐⭐ L8 (12/09/2026) — CON QUALE MODELLO È STATA FATTA. Scritto qui e non dedotto.
|
|
516
|
+
*
|
|
517
|
+
* Il 12/09 la ricerca `3029dea2` è partita con `z-ai/glm-4.7-flash` mentre la chat che
|
|
518
|
+
* l'aveva ordinata girava con `z-ai/glm-5.3-flash` (intestazioni delle due sessioni nello
|
|
519
|
+
* store: `modello` riga 1 di ciascun `.jsonl`). Nessuno poteva accorgersene dalla sezione,
|
|
520
|
+
* perché la voce della ricerca non diceva con che cosa fosse stata fatta — e due ricerche
|
|
521
|
+
* fatte con due modelli diversi non sono confrontabili.
|
|
522
|
+
*
|
|
523
|
+
* ⛔ `null` per ogni voce nata prima di oggi: onesto, mai il modello di oggi attribuito a
|
|
524
|
+
* una corsa di ieri. E sulla METADATA, non solo sulla voce di sessione, perché la voce
|
|
525
|
+
* di sessione vive in memoria e la sezione legge dal disco anche dopo un riavvio.
|
|
526
|
+
*/
|
|
527
|
+
modello: typeof modello === 'string' && modello.trim().length > 0 ? modello.trim() : null,
|
|
528
|
+
/*
|
|
529
|
+
* ⭐⭐⭐ L9 (12/09/2026) — CHI GIUDICHERÀ, scelto alla NASCITA e non al deposito.
|
|
530
|
+
*
|
|
531
|
+
* ⛔ Perché qui e non dopo: la scelta del giudice è «chiunque tranne l'autore»
|
|
532
|
+
* (`verification.mjs:talosResearchPickJudge`), e l'autore è il modello di QUESTA corsa.
|
|
533
|
+
* Deciderlo al momento del deposito vorrebbe dire rileggere quale modello fosse
|
|
534
|
+
* configurato allora — cioè un'altra ora, un'altra impostazione, un altro giudice, e un
|
|
535
|
+
* rapporto che non sa dire chi l'ha controllato. Scritto alla nascita, sopravvive a un
|
|
536
|
+
* riavvio come tutto il resto della metadata.
|
|
537
|
+
* ⛔ `null` è una risposta VERA e frequente: nessun altro modello ammesso oltre all'autore.
|
|
538
|
+
* Allora il rapporto esce con `judge: null` e lo DICE («nessun giudice indipendente
|
|
539
|
+
* disponibile: l'autore non può verificare sé stesso»), invece di far timbrare al modello
|
|
540
|
+
* le proprie affermazioni. La misura che lo vieta è vecchia e netta: Panickssery, Bowman
|
|
541
|
+
* e Feng, «LLM Evaluators Recognize and Favor Their Own Generations» (arXiv:2404.13076,
|
|
542
|
+
* 15/04/2024, letta il 12/09/2026) — «a linear correlation between self-recognition
|
|
543
|
+
* capability and the strength of self-preference bias».
|
|
544
|
+
*/
|
|
545
|
+
modelloGiudice: typeof modelloGiudice === 'string' && modelloGiudice.trim().length > 0 ? modelloGiudice.trim() : null,
|
|
546
|
+
};
|
|
547
|
+
// ⛔ L4 — atomica anche alla nascita: una voce scritta a metà è una ricerca che l'elenco non
|
|
548
|
+
// vede più, e la sessione che la esegue sta già spendendo denaro.
|
|
549
|
+
await scriviAtomico(percorsoMeta(cartella, id), JSON.stringify(voce, null, 2), { ...deps, writeFileFn, mkdirFn });
|
|
550
|
+
return voce;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* ⭐⭐⭐ L4 — LA MIGRAZIONE, AL PRIMO TOCCO CHE SCRIVE. Mai in blocco.
|
|
555
|
+
*
|
|
556
|
+
* Una ricerca vecchia vive in `<id>.json`. Quando qualcosa la aggiorna (e solo allora) la voce
|
|
557
|
+
* passa in `<id>/meta.json`, e il file vecchio si toglie **dopo** che il nuovo è sul disco.
|
|
558
|
+
*
|
|
559
|
+
* ⛔ L'ordine non è arbitrario ed è tutto il punto: se il processo muore fra le due operazioni,
|
|
560
|
+
* sul disco ci sono ENTRAMBE le copie — `elencaRicerche` dà la precedenza alla cartella,
|
|
561
|
+
* quindi la ricerca appare una volta sola e con i dati NUOVI. L'ordine opposto (togliere prima
|
|
562
|
+
* di scrivere) avrebbe una finestra in cui la ricerca non esiste: è la classe di guasto di
|
|
563
|
+
* `writeFileSync(dove,'')`, e non si ripete.
|
|
564
|
+
* ⛔ `formato` resta **assente** su ciò che è migrato, e non è una dimenticanza: quella voce è
|
|
565
|
+
* nata quando il record recintato non esisteva, e il cancello di consegna deve continuare a
|
|
566
|
+
* saperlo per sempre. Un `formato: 2` messo qui trasformerebbe una migrazione di contenitore
|
|
567
|
+
* in una promessa sul contenuto.
|
|
568
|
+
*
|
|
569
|
+
* @returns {Promise<object|null>} — la voce migrata, o `null` se non c'era niente da migrare.
|
|
570
|
+
*/
|
|
571
|
+
export async function migraRicerca({ cartella, id }, deps = {}) {
|
|
572
|
+
const readFileFn = deps.readFileFn ?? fsp.readFile;
|
|
573
|
+
const rmFn = deps.rmFn ?? fsp.rm;
|
|
574
|
+
if (!idRicercaValido(id)) return null;
|
|
575
|
+
const legacy = percorsoVoceLegacy(cartella, id);
|
|
576
|
+
let grezzo;
|
|
577
|
+
try {
|
|
578
|
+
grezzo = await readFileFn(legacy, 'utf8');
|
|
579
|
+
} catch {
|
|
580
|
+
return null; // niente forma vecchia: o è già migrata, o non è mai esistita.
|
|
581
|
+
}
|
|
582
|
+
let voce;
|
|
583
|
+
try {
|
|
584
|
+
voce = JSON.parse(grezzo);
|
|
585
|
+
} catch (errore) {
|
|
586
|
+
// ⛔ Un `<id>.json` illeggibile NON si cancella e NON si sostituisce: è l'unica copia di
|
|
587
|
+
// qualcosa che è costato denaro. Si dice, e si lascia dov'è.
|
|
588
|
+
throw new ResearchStoreError(`${id}: la voce da migrare è illeggibile, lasciata dov'era: ${errore.message}`, 'RESEARCH_READ_FAILED');
|
|
589
|
+
}
|
|
590
|
+
await scriviAtomico(percorsoMeta(cartella, id), JSON.stringify({ ...voce, migrataDa: `${id}.json` }, null, 2), deps);
|
|
591
|
+
await rmFn(legacy, { force: true });
|
|
592
|
+
return { ...voce, migrataDa: `${id}.json` };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Aggiorna campi (titolo/terminata/reportLibraryId) — `null` se l'id non
|
|
597
|
+
* esiste. `undefined` per un campo significa "non toccarlo" (mai
|
|
598
|
+
* confuso con `null`, un valore esplicito — stesso principio già
|
|
599
|
+
* imparato sul clamping di `limit` in Notes: non trattare "assente"
|
|
600
|
+
* come "falso"). Mai il campo "in corso": si deriva dal vivo (vedi la
|
|
601
|
+
* doc di testa), questa funzione non può farlo disallineare.
|
|
602
|
+
*
|
|
603
|
+
* ⭐⭐⭐ L2 (11/09) — `terminata` non è più solo `'done'|'failed'|'cancelled'`: vedi
|
|
604
|
+
* `STATI_TERMINATI` qui sotto. Questa funzione NON valida il valore, di proposito — è lo
|
|
605
|
+
* stesso principio già in uso per `titolo`: lo store scrive ciò che il chiamante decide, e
|
|
606
|
+
* la macchina degli stati vive tutta in `research-orchestrator.mjs`, in un posto solo.
|
|
607
|
+
*/
|
|
608
|
+
export async function aggiornaRicerca({ cartella, id, titolo, terminata, reportLibraryId, conclusaAlle, ultimoMessaggio, motivoDettaglio, motivoErrore }, deps = {}) {
|
|
609
|
+
/*
|
|
610
|
+
* ⭐⭐⭐ L4 — QUESTO È «IL PRIMO TOCCO». Un aggiornamento è una scrittura: se la voce è ancora
|
|
611
|
+
* nella forma vecchia, qui si migra — una volta, per quella ricerca, e solo perché stavamo
|
|
612
|
+
* comunque per scrivere. Nessun costo aggiunto a chi legge.
|
|
613
|
+
*/
|
|
614
|
+
await migraRicerca({ cartella, id }, deps);
|
|
615
|
+
const voce = await leggiRicerca({ cartella, id }, deps);
|
|
616
|
+
if (!voce) return null;
|
|
617
|
+
const aggiornata = {
|
|
618
|
+
...voce,
|
|
619
|
+
titolo: titolo !== undefined ? titolo : voce.titolo,
|
|
620
|
+
terminata: terminata !== undefined ? terminata : voce.terminata,
|
|
621
|
+
reportLibraryId: reportLibraryId !== undefined ? reportLibraryId : voce.reportLibraryId,
|
|
622
|
+
/*
|
|
623
|
+
* ⭐ L2 (11/09) — TRE campi nuovi, stessa disciplina «undefined = non toccarlo» degli altri.
|
|
624
|
+
* `conclusaAlle`: l'istante in cui la ricerca è finita DAVVERO. Prima si leggeva
|
|
625
|
+
* `aggiornataAlle`, che però si muove a ogni rinomina: una ricerca rinominata sembrava
|
|
626
|
+
* essersi conclusa il giorno della rinomina.
|
|
627
|
+
* `ultimoMessaggio`: ciò che il modello ha detto alla fine — un ALLEGATO, mai il rapporto.
|
|
628
|
+
* `motivoDettaglio`: il pezzo variabile della frase umana (es. «il rapporto non elenca
|
|
629
|
+
* nessuna fonte»), perché la frase la compone il lettore e il dettaglio lo sa solo chi ha
|
|
630
|
+
* riletto il file in quel momento.
|
|
631
|
+
*/
|
|
632
|
+
conclusaAlle: conclusaAlle !== undefined ? conclusaAlle : (voce.conclusaAlle ?? null),
|
|
633
|
+
ultimoMessaggio: ultimoMessaggio !== undefined ? ultimoMessaggio : (voce.ultimoMessaggio ?? null),
|
|
634
|
+
motivoDettaglio: motivoDettaglio !== undefined ? motivoDettaglio : (voce.motivoDettaglio ?? null),
|
|
635
|
+
/*
|
|
636
|
+
* ⭐⭐⭐ BC-44 (12/09/2026) — PERCHÉ LA CORSA È CADUTA, e non solo CHE è caduta.
|
|
637
|
+
*
|
|
638
|
+
* `{classe, transitorio, codice, messaggio}` oppure `null`. Stessa disciplina degli altri:
|
|
639
|
+
* `undefined` = non toccarlo, `null` = azzeralo (lo fa la ripresa, che riapre la corsa).
|
|
640
|
+
*
|
|
641
|
+
* ⛔ Questo file NON classifica e non valida: scrive ciò che il chiamante decide, come per
|
|
642
|
+
* `terminata`. La tabella transitorio/non transitorio vive in un posto solo
|
|
643
|
+
* (`research-orchestrator.mjs`, `classificaErroreDiCorsa`) — due classificatori che
|
|
644
|
+
* divergono sarebbero due verità sullo stesso guasto.
|
|
645
|
+
* ⛔ `messaggio` è la frase GREZZA del fornitore (es. «Upstream idle timeout exceeded»):
|
|
646
|
+
* resta qui, sul disco, per la diagnosi, e NON esce dalla voce esposta — a schermo va la
|
|
647
|
+
* frase italiana che compone `motivoDelloStato`.
|
|
648
|
+
*/
|
|
649
|
+
motivoErrore: motivoErrore !== undefined ? motivoErrore : (voce.motivoErrore ?? null),
|
|
650
|
+
aggiornataAlle: new Date().toISOString(),
|
|
651
|
+
};
|
|
652
|
+
await scriviAtomico(percorsoMeta(cartella, id), JSON.stringify(aggiornata, null, 2), deps);
|
|
653
|
+
return aggiornata;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Elimina SOLO la metadata — il rapporto in Libreria (se esiste) è
|
|
658
|
+
* responsabilità del CHIAMANTE (research-orchestrator.mjs, che conosce
|
|
659
|
+
* `reportLibraryId` PRIMA di chiamare questa funzione e compone le due
|
|
660
|
+
* cancellazioni, stesso principio "questo file non sa COME" già in uso
|
|
661
|
+
* altrove). `null` se l'id non esiste già (idempotente, mai
|
|
662
|
+
* un'eccezione — stesso principio di `eliminaVoce` in library-store.mjs,
|
|
663
|
+
* "It may already be gone").
|
|
664
|
+
*/
|
|
665
|
+
export async function eliminaRicerca({ cartella, id }, deps = {}) {
|
|
666
|
+
const rmFn = deps.rmFn ?? fsp.rm;
|
|
667
|
+
if (!idRicercaValido(id)) return null;
|
|
668
|
+
const esistente = await leggiRicerca({ cartella, id }, deps);
|
|
669
|
+
if (!esistente) return null;
|
|
670
|
+
/*
|
|
671
|
+
* ⭐ L4 — adesso una ricerca è una CARTELLA: si toglie tutta (giornale, piano, fonti,
|
|
672
|
+
* rapporto), non solo la voce. ⛔ Ed è una cancellazione CHIESTA da una persona (o dal
|
|
673
|
+
* modello via `research_delete`), non un effetto collaterale: è l'unico posto di questo file
|
|
674
|
+
* dove qualcosa di pagato sparisce, e sparisce perché qualcuno l'ha ordinato.
|
|
675
|
+
* ⛔ Si toglie anche il `<id>.json` di una ricerca mai migrata: altrimenti l'elenco
|
|
676
|
+
* continuerebbe a mostrare una ricerca dichiarata eliminata.
|
|
677
|
+
*/
|
|
678
|
+
await rmFn(cartellaDellaRicerca(cartella, id), { recursive: true, force: true });
|
|
679
|
+
await rmFn(percorsoVoceLegacy(cartella, id), { force: true });
|
|
680
|
+
return { id };
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/*
|
|
684
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
685
|
+
* L1+L2 (11/09/2026) — LA CARTELLA DELLA RICERCA, IL RAPPORTO E LA SUA FORMA
|
|
686
|
+
* ─────────────────────────────────────────────────────────────────────────
|
|
687
|
+
*
|
|
688
|
+
* ⛔ Perché esiste, riprodotto sui dati veri (sessione `d2a453a8-67e3-4c7a-85a0-c3e1dbe10b35`
|
|
689
|
+
* del 4174, 11/09, ore 18:56-19:00): la ricerca partiva `permessi:'Read only'`
|
|
690
|
+
* (`research-orchestrator.mjs:170`, scritto a mano), `document_create` le veniva offerto lo
|
|
691
|
+
* stesso e negato a runtime (`REFUSED. la sessione è in sola lettura…`, due volte), e «il
|
|
692
|
+
* rapporto» era l'ULTIMO MESSAGGIO con del testo — cioè 290 byte di scusa, salvati in
|
|
693
|
+
* Libreria come `Research - ….md` e timbrati `terminata:'done'`. 9 `web_search`, 14 `naviga`,
|
|
694
|
+
* 484.171 token di ingresso pagati, e il documento permanente era la frase con cui il modello
|
|
695
|
+
* si giustificava per non averlo scritto. Nessun errore da nessuna parte.
|
|
696
|
+
*
|
|
697
|
+
* ⇒ Il rapporto smette di essere «l'ultima frase» e diventa un ARTEFATTO su disco, depositato
|
|
698
|
+
* da un attrezzo dedicato (`research_deposit`) dentro la cartella della ricerca. Questo file
|
|
699
|
+
* sa DOVE vive e CHE FORMA MINIMA deve avere; non sa chi lo scrive.
|
|
700
|
+
*
|
|
701
|
+
* ⭐ Ricerca web PRIMA di scrivere (obbligo owner), fonte + data:
|
|
702
|
+
* - «Agent Safety Is Action Alignment», Li & Zhao — arXiv:2606.28739, 27/06/2026: il minimo
|
|
703
|
+
* privilegio va imposto «outside the model at the action boundary», non con l'allineamento
|
|
704
|
+
* del modello. ⇒ il confine è il cancello del kernel; la forma del rapporto qui sotto è un
|
|
705
|
+
* controllo di PRODOTTO, mai il confine di sicurezza.
|
|
706
|
+
* - «When Lower Privileges Suffice: Investigating Over-Privileged Tool Selection in LLM
|
|
707
|
+
* Agents», Yang, Bu, Yi et al. — arXiv:2606.20023, 18/06/2026 (v2 07/07): la scelta di un
|
|
708
|
+
* attrezzo a privilegio più alto quando ne basterebbe uno più basso è comune «and is further
|
|
709
|
+
* amplified by transient failures». ⇒ è ESATTAMENTE la corsa di stasera: dopo il primo
|
|
710
|
+
* REFUSED il modello ha insistito con `document_create` invece di cercare una via più
|
|
711
|
+
* stretta. La cura non è un prompt migliore («prompt-level controls provide only limited
|
|
712
|
+
* mitigation»): è togliere dalla lista l'attrezzo più largo e offrirne uno più stretto.
|
|
713
|
+
*/
|
|
714
|
+
|
|
715
|
+
/** La cartella di UNA ricerca: `<progetto>/.harness-ui-research/<id>/`. Mai l'id nudo dal modello — vedi `idRicercaValido`. */
|
|
716
|
+
export function cartellaDellaRicerca(cartella, id) {
|
|
717
|
+
return join(cartella, CARTELLA_RICERCA, id);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Il rapporto di UNA ricerca. Un nome solo, deciso qui: chi scrive e chi rilegge non possono divergere. */
|
|
721
|
+
export const NOME_RAPPORTO = 'rapporto.md';
|
|
722
|
+
|
|
723
|
+
export function percorsoRapporto(cartella, id) {
|
|
724
|
+
return join(cartellaDellaRicerca(cartella, id), NOME_RAPPORTO);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* ⛔ Un id di ricerca è UN SOLO segmento di percorso, e solo caratteri che non possono
|
|
729
|
+
* attraversare una cartella: niente `/`, niente backslash, niente `:`, niente `.`/`..`. È la
|
|
730
|
+
* stessa disciplina di `resolve`+`startsWith` che il kernel applica al percorso finale, ma
|
|
731
|
+
* spostata a monte, sul DATO — due controlli indipendenti sullo stesso confine, non uno
|
|
732
|
+
* ripetuto. (Gli UUID di `randomUUID()` passano; qualunque tentativo di uscire no.)
|
|
733
|
+
*/
|
|
734
|
+
export function idRicercaValido(id) {
|
|
735
|
+
return typeof id === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(id);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* `resolve` + `startsWith(radice + sep)` — la STESSA forma del controllo di
|
|
740
|
+
* `livello-scrittura-area` nel kernel (`talosHarness.mjs`), scritta una volta sola qui perché
|
|
741
|
+
* la usano sia il deposito sia i test. ⛔ `radice` assente non è un «vince tutto»: senza una
|
|
742
|
+
* radice da controllare un percorso non è verificabile, quindi è FUORI.
|
|
743
|
+
*/
|
|
744
|
+
export function dentroLaRadice(radice, percorso) {
|
|
745
|
+
if (typeof radice !== 'string' || radice.length === 0) return false;
|
|
746
|
+
if (typeof percorso !== 'string' || percorso.length === 0) return false;
|
|
747
|
+
const r = resolve(radice);
|
|
748
|
+
const p = resolve(percorso);
|
|
749
|
+
return p === r || p.startsWith(r + sep);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Salva il rapporto di una ricerca — usato dall'attrezzo `research_deposit`.
|
|
754
|
+
* ⛔ Il percorso NON viene mai dal modello: si ricostruisce qui da `cartella` (del progetto) e
|
|
755
|
+
* `id` (della ricerca, che il server conosce). Il controllo `dentroLaRadice` resta comunque,
|
|
756
|
+
* in seconda battuta: un cancello si prova anche quando la prima difesa dovrebbe bastare.
|
|
757
|
+
* @returns {Promise<{percorso:string, byte:number}>}
|
|
758
|
+
*/
|
|
759
|
+
export async function scriviRapporto({ cartella, id, testo }, deps = {}) {
|
|
760
|
+
const mkdirFn = deps.mkdirFn ?? fsp.mkdir;
|
|
761
|
+
const writeFileFn = deps.writeFileFn ?? fsp.writeFile;
|
|
762
|
+
if (!idRicercaValido(id)) throw new ResearchStoreError(`id di ricerca non valido: ${String(id)}`, 'RESEARCH_INVALID');
|
|
763
|
+
if (typeof testo !== 'string' || testo.trim().length === 0) {
|
|
764
|
+
throw new ResearchStoreError('Un rapporto vuole del testo', 'RESEARCH_INVALID');
|
|
765
|
+
}
|
|
766
|
+
const radice = cartellaDellaRicerca(cartella, id);
|
|
767
|
+
const percorso = percorsoRapporto(cartella, id);
|
|
768
|
+
if (!dentroLaRadice(radice, percorso)) {
|
|
769
|
+
throw new ResearchStoreError(`${percorso} non risolve dentro ${radice}`, 'RESEARCH_INVALID');
|
|
770
|
+
}
|
|
771
|
+
await mkdirFn(radice, { recursive: true });
|
|
772
|
+
// ⛔ L4 — atomica: un rapporto è il prodotto per cui la ricerca è stata pagata. Se la scrittura
|
|
773
|
+
// muore a metà, quello che c'era prima (un deposito precedente) resta leggibile.
|
|
774
|
+
await scriviAtomico(percorso, testo, { ...deps, mkdirFn, writeFileFn });
|
|
775
|
+
return { percorso, byte: Buffer.byteLength(testo, 'utf8') };
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** @returns {Promise<string|null>} — il testo del rapporto, `null` se non esiste (mai un'eccezione per un'assenza). */
|
|
779
|
+
export async function leggiRapporto({ cartella, id }, deps = {}) {
|
|
780
|
+
const readFileFn = deps.readFileFn ?? fsp.readFile;
|
|
781
|
+
if (!idRicercaValido(id)) return null;
|
|
782
|
+
try {
|
|
783
|
+
return await readFileFn(percorsoRapporto(cartella, id), 'utf8');
|
|
784
|
+
} catch {
|
|
785
|
+
return null;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* ⭐⭐⭐ LA FORMA MINIMA DI UN RAPPORTO — il cancello di consegna del disegno §6.5.
|
|
791
|
+
*
|
|
792
|
+
* ⛔ È DICHIARATAMENTE MINIMA, e il perché sta scritto qui invece che in una nota: il record
|
|
793
|
+
* recintato vero (piano, linee, affermazioni con verdetto, fonti con citazione) arriva da
|
|
794
|
+
* `src/research/report.mjs`, che un altro agente sta portando dal mobile in parallelo. Fino
|
|
795
|
+
* ad allora questa funzione controlla TRE cose sole, e `research-orchestrator.mjs` accetta un
|
|
796
|
+
* `rileggiRapportoFn` iniettabile perché il giorno in cui quel record esiste si cambia il
|
|
797
|
+
* PUNTO DI INNESTO, non la macchina degli stati.
|
|
798
|
+
*
|
|
799
|
+
* Le tre cose, e perché proprio queste:
|
|
800
|
+
* 1. un'INTESTAZIONE (una riga che comincia con `#`) — un rapporto ha un titolo; una scusa no;
|
|
801
|
+
* 2. almeno un'AFFERMAZIONE — una riga di prosa fuori dalla sezione fonti;
|
|
802
|
+
* 3. almeno una FONTE — un URL http(s) dentro una sezione fonti/sources/riferimenti.
|
|
803
|
+
*
|
|
804
|
+
* ⛔ Il punto 3 è quello che la scusa di stasera non poteva superare in nessun modo, ed è anche
|
|
805
|
+
* quello che la ricerca accademica indica come la misura da mostrare: «Sci-MMR»
|
|
806
|
+
* (arXiv:2609.11243, 10/09/2026) misura che l'accuratezza della risposta supera di oltre 20
|
|
807
|
+
* punti il recupero completo delle PROVE — cioè si risponde bene senza avere le prove. Un
|
|
808
|
+
* rapporto senza una fonte non è un rapporto corto: è un rapporto senza il pezzo che conta.
|
|
809
|
+
*
|
|
810
|
+
* ⛔ NON controlla la lunghezza minima e NON giudica la qualità: un rapporto breve ma
|
|
811
|
+
* documentato passa, ed è giusto così. Il cancello dice «c'è un artefatto rileggibile», non
|
|
812
|
+
* «è un buon artefatto» — confonderli produrrebbe la bugia opposta.
|
|
813
|
+
*
|
|
814
|
+
* @returns {{ok:boolean, intestazione:string|null, affermazioni:number, fonti:string[], motivo:string|null}}
|
|
815
|
+
*/
|
|
816
|
+
export function rileggiRapportoMinimo(testo) {
|
|
817
|
+
if (typeof testo !== 'string' || testo.trim().length === 0) {
|
|
818
|
+
return { ok: false, intestazione: null, affermazioni: 0, fonti: [], motivo: 'il rapporto è vuoto' };
|
|
819
|
+
}
|
|
820
|
+
const righe = testo.split(/\r?\n/);
|
|
821
|
+
const rigaTitolo = righe.find((r) => /^#{1,6}\s+\S/.test(r.trim()));
|
|
822
|
+
const intestazione = rigaTitolo ? rigaTitolo.trim().replace(/^#{1,6}\s+/, '') : null;
|
|
823
|
+
/*
|
|
824
|
+
* ⛔ «Dentro la sezione fonti» si decide riga per riga, non cercando gli URL ovunque: un URL
|
|
825
|
+
* citato in mezzo alla prosa è una citazione, non la bibliografia — e un rapporto che elenca
|
|
826
|
+
* le sue fonti è esattamente ciò che «Cited but Not Verified» (arXiv:2605.06635, 07/05/2026)
|
|
827
|
+
* misura come il pezzo che gli agenti di ricerca profonda sbagliano di più: la fonte sostiene
|
|
828
|
+
* davvero l'affermazione solo il 39-77% delle volte. Verificarle richiede prima di AVERLE.
|
|
829
|
+
*/
|
|
830
|
+
const INTESTAZIONE_FONTI = /^#{1,6}\s*(fonti|sources|riferimenti|references|bibliografia)\b/i;
|
|
831
|
+
let inFonti = false;
|
|
832
|
+
const fonti = [];
|
|
833
|
+
let affermazioni = 0;
|
|
834
|
+
for (const grezza of righe) {
|
|
835
|
+
const riga = grezza.trim();
|
|
836
|
+
if (/^#{1,6}\s+\S/.test(riga)) {
|
|
837
|
+
inFonti = INTESTAZIONE_FONTI.test(riga);
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
if (riga.length === 0) continue;
|
|
841
|
+
if (inFonti) {
|
|
842
|
+
const url = riga.match(/https?:\/\/[^\s)<>\]"']+/g);
|
|
843
|
+
if (url) fonti.push(...url);
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
// una riga di prosa: non un separatore orizzontale, non una riga vuota.
|
|
847
|
+
if (/^[-*_=\s|]+$/.test(riga)) continue;
|
|
848
|
+
affermazioni += 1;
|
|
849
|
+
}
|
|
850
|
+
if (!intestazione) return { ok: false, intestazione: null, affermazioni, fonti, motivo: 'il rapporto non ha un\'intestazione' };
|
|
851
|
+
if (affermazioni === 0) return { ok: false, intestazione, affermazioni, fonti, motivo: 'il rapporto non contiene nessuna affermazione' };
|
|
852
|
+
if (fonti.length === 0) return { ok: false, intestazione, affermazioni, fonti, motivo: 'il rapporto non elenca nessuna fonte' };
|
|
853
|
+
return { ok: true, intestazione, affermazioni, fonti, motivo: null };
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/*
|
|
857
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
858
|
+
* L4 — IL GIORNALE, IL PIANO E LE FONTI TENUTE
|
|
859
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
860
|
+
*/
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* ⭐⭐⭐ LA CODA PER PERCORSO — copiata, non reinventata, da `session-store.mjs` (W0-07, 04/09).
|
|
864
|
+
*
|
|
865
|
+
* ⛔ Perché serve, e non è prudenza teorica: nello store dell'owner il file `b7b1b7d2…` (31/08)
|
|
866
|
+
* aveva quattro righe rotte, la prima spezzata a **1.572.866 byte, esattamente 1,5 MiB**,
|
|
867
|
+
* perché due `appendFile` concorrenti si erano intrecciati. `O_APPEND` è atomico per SINGOLA
|
|
868
|
+
* chiamata di scrittura: un record grande viene spezzato in più chiamate, ed è lì che due
|
|
869
|
+
* scrittori si infilano l'uno dentro l'altro. ⇒ le scritture sullo STESSO file si mettono in
|
|
870
|
+
* fila; file diversi non si aspettano (la chiave è il percorso), così una ricerca lenta non
|
|
871
|
+
* rallenta le altre.
|
|
872
|
+
* ⛔ Un errore su una scrittura non blocca la coda: la successiva parte comunque.
|
|
873
|
+
*/
|
|
874
|
+
const codeDelGiornale = new Map();
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* ⭐⭐⭐ UN EVENTO NEL GIORNALE — SOLO APPEND, MAI UNA RISCRITTURA.
|
|
878
|
+
*
|
|
879
|
+
* `evento` è uno degli undici che `src/research/run.mjs` conosce (`TalosResearchEvent`): questo
|
|
880
|
+
* file non li interpreta, li mette a registro. ⛔ Il giornale porta FATTI e RIFERIMENTI, mai il
|
|
881
|
+
* carico: il testo di una pagina sta in `fonti/<sha256>.txt` e nel giornale ne compare il nome
|
|
882
|
+
* (`resultRef`). Un giornale che porta cento kilobyte per riga è un giornale che nessuno
|
|
883
|
+
* rigioca.
|
|
884
|
+
*
|
|
885
|
+
* ⭐ `flush: true` di default — ed è la DIFFERENZA dichiarata rispetto a `session-store.mjs`,
|
|
886
|
+
* che di proposito non fa `fsync` a ogni riga. La ragione è che le due cose non hanno lo
|
|
887
|
+
* stesso valore: là ogni riga è un evento di interfaccia fra migliaia, qui una riga è un passo
|
|
888
|
+
* di ricerca PAGATO, e sono pochi per corsa. La ricerca del 04/09 lo diceva già e allora era
|
|
889
|
+
* restato debito: «una scrittura riuscita vive nella cache del kernel finché non c'è un
|
|
890
|
+
* `fsync`». Qui il debito si chiude, perché qui si può permettere.
|
|
891
|
+
*
|
|
892
|
+
* @returns {Promise<void>}
|
|
893
|
+
*/
|
|
894
|
+
export async function accodaEvento({ cartella, id, evento, durevole = true, separaRiga = false }, deps = {}) {
|
|
895
|
+
const mkdirFn = deps.mkdirFn ?? fsp.mkdir;
|
|
896
|
+
const appendFileFn = deps.appendFileFn ?? fsp.appendFile;
|
|
897
|
+
if (!idRicercaValido(id)) throw new ResearchStoreError(`id di ricerca non valido: ${String(id)}`, 'RESEARCH_INVALID');
|
|
898
|
+
if (!evento || typeof evento !== 'object' || typeof evento.kind !== 'string' || evento.kind.length === 0) {
|
|
899
|
+
throw new ResearchStoreError('Un evento del giornale vuole un `kind`', 'RESEARCH_INVALID');
|
|
900
|
+
}
|
|
901
|
+
const percorso = percorsoGiornale(cartella, id);
|
|
902
|
+
// ⛔ Serializzato SUBITO, non dentro la coda: `evento` potrebbe cambiare mentre aspetta il turno.
|
|
903
|
+
// BC-49: una riga mozzata dal crash non deve assorbire il checkpoint successivo.
|
|
904
|
+
const riga = `${separaRiga ? '\n' : ''}${JSON.stringify(evento)}\n`;
|
|
905
|
+
const precedente = codeDelGiornale.get(percorso) ?? Promise.resolve();
|
|
906
|
+
const corrente = precedente.catch(() => {}).then(async () => {
|
|
907
|
+
await mkdirFn(dirname(percorso), { recursive: true });
|
|
908
|
+
await appendFileFn(percorso, riga, durevole ? { encoding: 'utf8', flush: true } : 'utf8');
|
|
909
|
+
});
|
|
910
|
+
codeDelGiornale.set(percorso, corrente);
|
|
911
|
+
corrente.catch(() => {}).finally(() => {
|
|
912
|
+
if (codeDelGiornale.get(percorso) === corrente) codeDelGiornale.delete(percorso);
|
|
913
|
+
});
|
|
914
|
+
return corrente;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* ⭐⭐⭐ IL GIORNALE RILETTO — E NON SI RIFIUTA MAI DI CARICARE.
|
|
919
|
+
*
|
|
920
|
+
* ⛔ Una riga illeggibile si **salta**, ovunque sia, e si conta. È una deviazione VOLUTA da
|
|
921
|
+
* `session-store.leggiRegistro`, che invece lancia su una riga rotta che non sia l'ultima, e
|
|
922
|
+
* il motivo è scritto nella testa di `src/research/run.mjs`: «l'unica cosa che non deve fare
|
|
923
|
+
* mai è rifiutarsi di caricare: un giro che non si può rigiocare è un giro il cui lavoro
|
|
924
|
+
* pagato è perso». Là il file è una trascrizione da mostrare; qui è la prova di ciò che è
|
|
925
|
+
* stato speso, e perderla tutta per una riga è il guasto peggiore dei due.
|
|
926
|
+
* ⛔ `righeSaltate` non è decorazione: senza quel numero «il giornale si è caricato» e «il
|
|
927
|
+
* giornale si è caricato per intero» sarebbero la stessa frase, e non lo sono.
|
|
928
|
+
*
|
|
929
|
+
* @returns {Promise<{eventi: object[], righeSaltate: number, byte: number}>}
|
|
930
|
+
*/
|
|
931
|
+
export async function leggiGiornale({ cartella, id, rigoroso = false }, deps = {}) {
|
|
932
|
+
const readFileFn = deps.readFileFn ?? fsp.readFile;
|
|
933
|
+
const vuoto = { eventi: [], righeSaltate: 0, byte: 0 };
|
|
934
|
+
if (!idRicercaValido(id)) return vuoto;
|
|
935
|
+
let testo;
|
|
936
|
+
try {
|
|
937
|
+
testo = await readFileFn(percorsoGiornale(cartella, id), 'utf8');
|
|
938
|
+
} catch (errore) {
|
|
939
|
+
if (rigoroso && errore?.code !== 'ENOENT') throw errore;
|
|
940
|
+
return vuoto; // nessun giornale è uno stato onesto: una ricerca vecchia non ne ha mai avuto uno.
|
|
941
|
+
}
|
|
942
|
+
const eventi = [];
|
|
943
|
+
let righeSaltate = 0;
|
|
944
|
+
for (const riga of testo.split('\n')) {
|
|
945
|
+
if (riga.trim() === '') continue;
|
|
946
|
+
try {
|
|
947
|
+
const letto = JSON.parse(riga);
|
|
948
|
+
if (letto && typeof letto === 'object' && typeof letto.kind === 'string') eventi.push(letto);
|
|
949
|
+
else righeSaltate += 1;
|
|
950
|
+
} catch {
|
|
951
|
+
righeSaltate += 1; // riga mozzata da un processo morto a metà `appendFile`, o rumore: si salta.
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
return { eventi, righeSaltate, byte: Buffer.byteLength(testo, 'utf8') };
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/** Il piano approvato, scritto atomicamente. `piano` è `TalosResearchBranch[]` (vedi `run.mjs`). */
|
|
958
|
+
export async function scriviPiano({ cartella, id, piano }, deps = {}) {
|
|
959
|
+
if (!idRicercaValido(id)) throw new ResearchStoreError(`id di ricerca non valido: ${String(id)}`, 'RESEARCH_INVALID');
|
|
960
|
+
await scriviAtomico(percorsoPiano(cartella, id), JSON.stringify(piano, null, 2), deps);
|
|
961
|
+
return percorsoPiano(cartella, id);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** @returns {Promise<object|null>} — `null` se non c'è (o è illeggibile): un piano assente non è un guasto. */
|
|
965
|
+
export async function leggiPiano({ cartella, id }, deps = {}) {
|
|
966
|
+
const readFileFn = deps.readFileFn ?? fsp.readFile;
|
|
967
|
+
if (!idRicercaValido(id)) return null;
|
|
968
|
+
try {
|
|
969
|
+
return JSON.parse(await readFileFn(percorsoPiano(cartella, id), 'utf8'));
|
|
970
|
+
} catch {
|
|
971
|
+
return null;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* ⭐⭐⭐ IL TESTO TENUTO DI UNA FONTE, INDIRIZZATO DAL CONTENUTO.
|
|
977
|
+
*
|
|
978
|
+
* Il nome del file è lo `sha256` del testo. Tre cose vengono gratis, e nessuna è un vezzo:
|
|
979
|
+
* 1. **due linee d'indagine che leggono la stessa pagina la tengono una volta sola** — è il
|
|
980
|
+
* caso normale (§6.2, e la cache del fetch di L6 punta allo stesso risparmio);
|
|
981
|
+
* 2. **niente si sovrascrive mai**: stesso contenuto ⇒ stesso nome ⇒ la seconda scrittura non
|
|
982
|
+
* ha niente da cambiare, e si salta. È il vincolo «ciò che è costato denaro» applicato
|
|
983
|
+
* senza dover ricordare di applicarlo;
|
|
984
|
+
* 3. il `resultRef` del giornale è un nome **stabile e verificabile**: chi rilegge può
|
|
985
|
+
* ricalcolare l'impronta e accorgersi se il file è stato manomesso.
|
|
986
|
+
*
|
|
987
|
+
* @returns {Promise<{ref:string, percorso:string, byte:number, giaPresente:boolean}>}
|
|
988
|
+
*/
|
|
989
|
+
export async function scriviFonte({ cartella, id, testo }, deps = {}) {
|
|
990
|
+
const mkdirFn = deps.mkdirFn ?? fsp.mkdir;
|
|
991
|
+
const statFn = deps.statFn ?? fsp.stat;
|
|
992
|
+
if (!idRicercaValido(id)) throw new ResearchStoreError(`id di ricerca non valido: ${String(id)}`, 'RESEARCH_INVALID');
|
|
993
|
+
if (typeof testo !== 'string' || testo.length === 0) {
|
|
994
|
+
throw new ResearchStoreError('Una fonte tenuta vuole del testo', 'RESEARCH_INVALID');
|
|
995
|
+
}
|
|
996
|
+
const impronta = createHash('sha256').update(testo, 'utf8').digest('hex');
|
|
997
|
+
const ref = `${CARTELLA_FONTI}/${impronta}.txt`;
|
|
998
|
+
const percorso = join(cartellaDelleFonti(cartella, id), `${impronta}.txt`);
|
|
999
|
+
await mkdirFn(cartellaDelleFonti(cartella, id), { recursive: true });
|
|
1000
|
+
try {
|
|
1001
|
+
await statFn(percorso);
|
|
1002
|
+
return { ref, percorso, byte: Buffer.byteLength(testo, 'utf8'), giaPresente: true };
|
|
1003
|
+
} catch { /* non c'è ancora: si scrive. */ }
|
|
1004
|
+
await scriviAtomico(percorso, testo, deps);
|
|
1005
|
+
return { ref, percorso, byte: Buffer.byteLength(testo, 'utf8'), giaPresente: false };
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Rilegge una fonte tenuta dal suo `ref` (`fonti/<sha256>.txt`).
|
|
1010
|
+
*
|
|
1011
|
+
* ⛔ Il `ref` arriva dal giornale, cioè da un file su disco che qualcuno potrebbe aver
|
|
1012
|
+
* modificato: si accetta **solo** la forma esatta `fonti/<64 esadecimali>.txt`, e in più il
|
|
1013
|
+
* percorso risolto si ricontrolla con `dentroLaRadice`. Due difese indipendenti sullo stesso
|
|
1014
|
+
* confine, come già per il deposito del rapporto (L1) — mai una sola.
|
|
1015
|
+
*
|
|
1016
|
+
* @returns {Promise<string|null>}
|
|
1017
|
+
*/
|
|
1018
|
+
export async function leggiFonte({ cartella, id, ref }, deps = {}) {
|
|
1019
|
+
const readFileFn = deps.readFileFn ?? fsp.readFile;
|
|
1020
|
+
if (!idRicercaValido(id)) return null;
|
|
1021
|
+
const combacia = typeof ref === 'string' ? ref.match(/^fonti\/([0-9a-f]{64})\.txt$/) : null;
|
|
1022
|
+
if (!combacia) return null;
|
|
1023
|
+
const percorso = join(cartellaDelleFonti(cartella, id), `${combacia[1]}.txt`);
|
|
1024
|
+
if (!dentroLaRadice(cartellaDellaRicerca(cartella, id), percorso)) return null;
|
|
1025
|
+
try {
|
|
1026
|
+
return await readFileFn(percorso, 'utf8');
|
|
1027
|
+
} catch {
|
|
1028
|
+
return null;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* ⭐⭐⭐ L9 — L'INDICE delle fonti tenute: url → ref, più ciò che la fonte dichiara di sé.
|
|
1034
|
+
*
|
|
1035
|
+
* ⛔ Si riscrive per intero e in modo ATOMICO, mai in append: è una mappa, non un registro, e
|
|
1036
|
+
* una mappa scritta a pezzi può finire con due voci per lo stesso indirizzo che si
|
|
1037
|
+
* contraddicono. Il registro solo-append è il giornale, e ha un altro mestiere.
|
|
1038
|
+
* ⛔ Una voce nuova NON cancella una vecchia con lo stesso url a meno che non sia più FORTE:
|
|
1039
|
+
* `page` batte `snippet`, e mai il contrario — una prova più debole non deve poter
|
|
1040
|
+
* sostituire una più forte (`raccolta-viva.mjs` fa la stessa scelta in memoria).
|
|
1041
|
+
*
|
|
1042
|
+
* @param {{cartella: string, id: string, voci: readonly object[]}} input
|
|
1043
|
+
*/
|
|
1044
|
+
export async function scriviIndiceFonti({ cartella, id, voci }, deps = {}) {
|
|
1045
|
+
if (!idRicercaValido(id)) throw new ResearchStoreError(`id di ricerca non valido: ${String(id)}`, 'RESEARCH_INVALID');
|
|
1046
|
+
const elenco = Array.isArray(voci) ? voci : [];
|
|
1047
|
+
await scriviAtomico(percorsoIndiceFonti(cartella, id), JSON.stringify(elenco, null, 2), deps);
|
|
1048
|
+
return percorsoIndiceFonti(cartella, id);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/** @returns {Promise<readonly object[]>} — `[]` se non c'è o è illeggibile: un indice assente non è un guasto. */
|
|
1052
|
+
export async function leggiIndiceFonti({ cartella, id }, deps = {}) {
|
|
1053
|
+
const readFileFn = deps.readFileFn ?? fsp.readFile;
|
|
1054
|
+
if (!idRicercaValido(id)) return [];
|
|
1055
|
+
try {
|
|
1056
|
+
const letto = JSON.parse(await readFileFn(percorsoIndiceFonti(cartella, id), 'utf8'));
|
|
1057
|
+
return Array.isArray(letto) ? letto : [];
|
|
1058
|
+
} catch {
|
|
1059
|
+
return [];
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/** I `ref` delle fonti tenute, in ordine stabile. `[]` se non ce ne sono (mai un errore). */
|
|
1064
|
+
export async function elencaFonti({ cartella, id }, deps = {}) {
|
|
1065
|
+
const readdirFn = deps.readdirFn ?? fsp.readdir;
|
|
1066
|
+
if (!idRicercaValido(id)) return [];
|
|
1067
|
+
try {
|
|
1068
|
+
const nomi = await readdirFn(cartellaDelleFonti(cartella, id));
|
|
1069
|
+
return nomi.filter((n) => /^[0-9a-f]{64}\.txt$/.test(n)).sort().map((n) => `${CARTELLA_FONTI}/${n}`);
|
|
1070
|
+
} catch {
|
|
1071
|
+
return [];
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* ⭐ L4 — L'IMPRONTA DEL RAPPORTO, per la cache dell'elenco.
|
|
1077
|
+
*
|
|
1078
|
+
* `elenca()` adesso rilegge il rapporto di ogni voce `done` (la lista mentiva: mostrava
|
|
1079
|
+
* «Conclusa» dove il dettaglio diceva «senza rapporto»). Venti letture a ogni apertura della
|
|
1080
|
+
* sezione però si pagano, e si pagherebbero anche quando non è cambiato niente: questa funzione
|
|
1081
|
+
* dà `mtimeMs` e `size`, che sono la chiave con cui il lettore sa se può riusare il giudizio già
|
|
1082
|
+
* dato. ⛔ `null` se il rapporto non c'è: un'assenza non è un guasto, ed è essa stessa una
|
|
1083
|
+
* risposta valida da mettere in cache.
|
|
1084
|
+
*
|
|
1085
|
+
* @returns {Promise<{mtimeMs:number, size:number}|null>}
|
|
1086
|
+
*/
|
|
1087
|
+
export async function statRapporto({ cartella, id }, deps = {}) {
|
|
1088
|
+
const statFn = deps.statFn ?? fsp.stat;
|
|
1089
|
+
if (!idRicercaValido(id)) return null;
|
|
1090
|
+
try {
|
|
1091
|
+
const s = await statFn(percorsoRapporto(cartella, id));
|
|
1092
|
+
return { mtimeMs: Number(s.mtimeMs), size: Number(s.size) };
|
|
1093
|
+
} catch {
|
|
1094
|
+
return null;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/** L'istantanea della cache del fetch, accanto al giornale. */
|
|
1099
|
+
export const NOME_CACHE_FETCH = 'cache.json';
|
|
1100
|
+
|
|
1101
|
+
export function percorsoCacheFetch(cartella, id) {
|
|
1102
|
+
return join(cartella, CARTELLA_RICERCA, id, NOME_CACHE_FETCH);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* ⭐⭐⭐ L4 + L6 — L'ISTANTANEA DELLA CACHE DEL FETCH, SU DISCO.
|
|
1107
|
+
*
|
|
1108
|
+
* `src/research/fetch-cache.mjs` produce con `snapshot()` un oggetto JSON-serializzabile e
|
|
1109
|
+
* dichiara, nella sua stessa doc, che «questo modulo non scrive niente su disco»: la scrittura
|
|
1110
|
+
* è di chi persiste, cioè di qui. ⛔ Atomica come tutto il resto — un'istantanea scritta a metà
|
|
1111
|
+
* farebbe ripagare pagine già pagate, che è esattamente il costo che esiste per evitare.
|
|
1112
|
+
*/
|
|
1113
|
+
export async function scriviIstantaneaCache({ cartella, id, istantanea }, deps = {}) {
|
|
1114
|
+
if (!idRicercaValido(id)) throw new ResearchStoreError(`id di ricerca non valido: ${String(id)}`, 'RESEARCH_INVALID');
|
|
1115
|
+
await scriviAtomico(percorsoCacheFetch(cartella, id), JSON.stringify(istantanea), deps);
|
|
1116
|
+
return percorsoCacheFetch(cartella, id);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* @returns {Promise<object|null>} — `null` se non c'è o non è JSON.
|
|
1121
|
+
* ⛔ Non valida la VERSIONE: quella la controlla `restore()` nel modulo che la possiede, e
|
|
1122
|
+
* duplicare qui il numero di versione creerebbe due posti che divergono. Qui si dice solo se
|
|
1123
|
+
* c'è qualcosa di leggibile; il giudizio su cosa farne è di chi sa cos'è.
|
|
1124
|
+
*/
|
|
1125
|
+
export async function leggiIstantaneaCache({ cartella, id }, deps = {}) {
|
|
1126
|
+
const readFileFn = deps.readFileFn ?? fsp.readFile;
|
|
1127
|
+
if (!idRicercaValido(id)) return null;
|
|
1128
|
+
try {
|
|
1129
|
+
return JSON.parse(await readFileFn(percorsoCacheFetch(cartella, id), 'utf8'));
|
|
1130
|
+
} catch {
|
|
1131
|
+
return null;
|
|
1132
|
+
}
|
|
1133
|
+
}
|