From 54cc1705ee4845c3086a034d53951045fb887321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:12:59 +0700 Subject: [PATCH 01/62] docs(folder-autopilot): add module release evidence checklist --- .../folder-autopilot-slice-2026-08-04.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/release-evidence/folder-autopilot-slice-2026-08-04.md diff --git a/docs/release-evidence/folder-autopilot-slice-2026-08-04.md b/docs/release-evidence/folder-autopilot-slice-2026-08-04.md new file mode 100644 index 00000000..daae794d --- /dev/null +++ b/docs/release-evidence/folder-autopilot-slice-2026-08-04.md @@ -0,0 +1,49 @@ +# Folder Autopilot module release evidence + +Status: implementation in progress. This record is updated only when the +corresponding code, contract, and test evidence exists in the same branch. + +## Scope + +This direct-to-`main` feature slice covers the first independently testable +Folder Autopilot boundary: content-free folder bindings, typed profile and +assignment validation, safe local observation and action planning, review-safe +Web/Android surfaces, and deterministic failure behavior. It does not create a +second JRA recipe/job/approval authority or copy DSO grants, paths, or +revocation state. + +## Acceptance evidence + +- [ ] FA-001–FA-007: binding/profile/assignment contracts contain only opaque + DSO/JRA references and are immutable, tenant scoped, revision guarded, and + idempotent. +- [ ] FA-008–FA-009: bounded previews expose collision, permission, resource, + recursion, and approval outcomes without source paths or values. +- [ ] FA-010–FA-017: Desktop stabilization, fingerprinting, path containment, + typed allowlisted actions, collision policy, derivative-only conversion, and + recovery-folder semantics are covered by tests. +- [ ] FA-018–FA-027: plan-bound approval, pre-commit revalidation, staged + compensation, idempotent execution projections, pause, and DSO revocation + fail closed. +- [ ] FA-028–FA-034: module intake, reconciliation, authorized retry/undo, + constraint narrowing, output-lineage prevention, health projections, and + redacted ledger export are covered or explicitly tracked for the next slice. +- [ ] Cross-runtime contract generation and drift checks pass for TypeScript, + Kotlin, and Python. +- [ ] Root repository checks, builds, accessibility checks, tenant isolation, + path-escape tests, restart/replay tests, and `git diff --check` pass. + +## Privacy and rollback notes + +Folder Autopilot never persists a canonical path, local handle, source bytes, +independent DSO grant/status/revocation fields, or an independent JRA recipe, +job, or approval decision. A failed or rolled-back step leaves the original +artifact and immutable audit history intact. Reverting this feature branch +removes the module-owned projections and adapters without deleting IAE, DSO, +JRA, or Desktop-local records. + +## Traceability + +The authoritative requirement records are `docs/plans/requirement-traceability.json`. +Statuses remain `planned` until each requirement has a concrete code path, test +path, and this evidence record is approved by the release gate. From 511468047b091c6405286a95ed84e0838f9c4b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:13:52 +0700 Subject: [PATCH 02/62] feat(engine): add content-free file observations --- .../databreeze_engine/processors/__init__.py | 8 ++ .../processors/folder_autopilot.py | 121 ++++++++++++++++++ .../test_folder_autopilot_observation.py | 101 +++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 services/engine/src/databreeze_engine/processors/folder_autopilot.py create mode 100644 services/engine/tests/test_folder_autopilot_observation.py diff --git a/services/engine/src/databreeze_engine/processors/__init__.py b/services/engine/src/databreeze_engine/processors/__init__.py index bbe0c545..b7589626 100644 --- a/services/engine/src/databreeze_engine/processors/__init__.py +++ b/services/engine/src/databreeze_engine/processors/__init__.py @@ -1,5 +1,10 @@ """Reviewed built-in processors composed into the closed registry.""" +from .folder_autopilot import ( + FileObservation, + build_file_observation, + fingerprint_bytes, +) from .spreadsheet_auditor import ( SpreadsheetAuditError, SpreadsheetAuditResult, @@ -24,12 +29,15 @@ __all__ = [ "SPREADSHEET_AUDITOR_ACTION_TYPE", "SPREADSHEET_AUDITOR_ACTION_VERSION", + "FileObservation", "SpreadsheetAuditError", "SpreadsheetAuditManifest", "SpreadsheetAuditManifestFinding", "SpreadsheetAuditManifestSheet", "SpreadsheetAuditResult", "audit_workbook", + "build_file_observation", "build_spreadsheet_audit_manifest", + "fingerprint_bytes", "handle_spreadsheet_auditor", ] diff --git a/services/engine/src/databreeze_engine/processors/folder_autopilot.py b/services/engine/src/databreeze_engine/processors/folder_autopilot.py new file mode 100644 index 00000000..dd2affa3 --- /dev/null +++ b/services/engine/src/databreeze_engine/processors/folder_autopilot.py @@ -0,0 +1,121 @@ +"""Content-free deterministic primitives for the Folder Autopilot local executor.""" + +from __future__ import annotations + +import hashlib +import json +import re +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr, field_validator + +MAX_AUTOPILOT_FILE_BYTES = 10 * 1024 * 1024 * 1024 +_SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$") +_DIGEST = re.compile(r"^[0-9a-f]{64}$") + + +def _invalid() -> ValueError: + return ValueError("INVALID_OBSERVATION") + + +class FileObservation(BaseModel): + """A bounded, value-free identity for one locally observed file.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + observationId: StrictStr + displayName: StrictStr = Field(min_length=1, max_length=255) + sizeBytes: StrictInt = Field(ge=0, le=MAX_AUTOPILOT_FILE_BYTES) + modifiedAtNs: StrictInt = Field(ge=0) + contentSha256: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") + stableExecutionKey: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") + + @field_validator("observationId") + @classmethod + def validate_observation_id(cls, value: str) -> str: + if _SAFE_ID.fullmatch(value) is None: + raise _invalid() + return value + + @field_validator("displayName") + @classmethod + def validate_display_name(cls, value: str) -> str: + if ( + value in {".", ".."} + or "/" in value + or "\\" in value + or any(ord(character) < 32 or ord(character) == 127 for character in value) + ): + raise _invalid() + return value + + @field_validator("contentSha256", "stableExecutionKey") + @classmethod + def validate_digest(cls, value: str) -> str: + if _DIGEST.fullmatch(value) is None: + raise _invalid() + return value + + +def fingerprint_bytes(content: bytes) -> str: + """Return a lowercase SHA-256 fingerprint without retaining the bytes.""" + if not isinstance(content, bytes): + raise ValueError("INVALID_OBSERVATION") + return hashlib.sha256(content).hexdigest() + + +def _stable_execution_key( + *, + observation_id: str, + display_name: str, + size_bytes: int, + modified_at_ns: int, + content_sha256: str, +) -> str: + canonical = json.dumps( + { + "contentSha256": content_sha256, + "displayName": display_name, + "modifiedAtNs": modified_at_ns, + "observationId": observation_id, + "sizeBytes": size_bytes, + }, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return hashlib.sha256(canonical).hexdigest() + + +def build_file_observation( + *, + observation_id: str, + display_name: str, + size_bytes: int, + modified_at_ns: int, + content_sha256: str, +) -> FileObservation: + """Build an immutable observation and derive its idempotency key.""" + stable_key = _stable_execution_key( + observation_id=observation_id, + display_name=display_name, + size_bytes=size_bytes, + modified_at_ns=modified_at_ns, + content_sha256=content_sha256, + ) + try: + return FileObservation( + observationId=observation_id, + displayName=display_name, + sizeBytes=size_bytes, + modifiedAtNs=modified_at_ns, + contentSha256=content_sha256, + stableExecutionKey=stable_key, + ) + except Exception as error: + raise _invalid() from error + + +ActionType = Literal["INSPECT", "VALIDATE", "RENAME", "COPY", "MOVE"] +CollisionPolicy = Literal["REVIEW", "SKIP", "UNIQUE_NAME"] diff --git a/services/engine/tests/test_folder_autopilot_observation.py b/services/engine/tests/test_folder_autopilot_observation.py new file mode 100644 index 00000000..c1b15089 --- /dev/null +++ b/services/engine/tests/test_folder_autopilot_observation.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import hashlib + +import pytest + +from databreeze_engine.processors.folder_autopilot import ( + FileObservation, + build_file_observation, + fingerprint_bytes, +) + + +def test_fingerprint_and_observation_are_deterministic_and_content_free() -> None: + content = b"invoice content" + fingerprint = fingerprint_bytes(content) + first = build_file_observation( + observation_id="obs-001", + display_name="Hóa đơn 01.xlsx", + size_bytes=len(content), + modified_at_ns=123, + content_sha256=fingerprint, + ) + second = build_file_observation( + observation_id="obs-001", + display_name="Hóa đơn 01.xlsx", + size_bytes=len(content), + modified_at_ns=123, + content_sha256=hashlib.sha256(content).hexdigest(), + ) + + assert first == second + assert first.stableExecutionKey == second.stableExecutionKey + assert first.contentSha256 == fingerprint + assert "path" not in first.model_dump() + assert "content" not in first.model_dump() + + +def test_observation_key_changes_when_fingerprint_or_timestamp_changes() -> None: + base = build_file_observation( + observation_id="obs-001", + display_name="report.csv", + size_bytes=4, + modified_at_ns=10, + content_sha256="a" * 64, + ) + changed_content = build_file_observation( + observation_id="obs-001", + display_name="report.csv", + size_bytes=4, + modified_at_ns=10, + content_sha256="b" * 64, + ) + changed_time = build_file_observation( + observation_id="obs-001", + display_name="report.csv", + size_bytes=4, + modified_at_ns=11, + content_sha256="a" * 64, + ) + + assert base.stableExecutionKey != changed_content.stableExecutionKey + assert base.stableExecutionKey != changed_time.stableExecutionKey + + +@pytest.mark.parametrize( + "name", ["..", ".", "nested\\file.csv", "nested/file.csv", "line\nfeed.csv"] +) +def test_observation_rejects_path_like_or_control_names(name: str) -> None: + with pytest.raises(ValueError, match="INVALID_OBSERVATION"): + build_file_observation( + observation_id="obs-001", + display_name=name, + size_bytes=1, + modified_at_ns=1, + content_sha256="a" * 64, + ) + + +def test_observation_rejects_invalid_fingerprint_and_bounds() -> None: + with pytest.raises(ValueError): + build_file_observation( + observation_id="obs-001", + display_name="report.csv", + size_bytes=10 * 1024 * 1024 * 1024 + 1, + modified_at_ns=1, + content_sha256="not-a-digest", + ) + + with pytest.raises(ValueError): + FileObservation.model_validate( + { + "observationId": "obs-001", + "displayName": "report.csv", + "sizeBytes": 1, + "modifiedAtNs": 1, + "contentSha256": "a" * 64, + "stableExecutionKey": "b" * 64, + "path": "C:\\secret", + } + ) From c7e3c412d07d5efc3e46d3e3de0481e5d98f8a0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:16:15 +0700 Subject: [PATCH 03/62] feat(engine): evaluate bounded autopilot plans --- .../databreeze_engine/processors/__init__.py | 16 ++ .../processors/folder_autopilot_plan.py | 249 ++++++++++++++++++ .../tests/test_folder_autopilot_plan.py | 155 +++++++++++ 3 files changed, 420 insertions(+) create mode 100644 services/engine/src/databreeze_engine/processors/folder_autopilot_plan.py create mode 100644 services/engine/tests/test_folder_autopilot_plan.py diff --git a/services/engine/src/databreeze_engine/processors/__init__.py b/services/engine/src/databreeze_engine/processors/__init__.py index b7589626..773cf4d6 100644 --- a/services/engine/src/databreeze_engine/processors/__init__.py +++ b/services/engine/src/databreeze_engine/processors/__init__.py @@ -5,6 +5,15 @@ build_file_observation, fingerprint_bytes, ) +from .folder_autopilot_plan import ( + AutopilotPlan, + AutopilotPlanRequest, + DestinationState, + PlanEvaluationError, + PlanOperation, + PlanStep, + evaluate_autopilot_plan, +) from .spreadsheet_auditor import ( SpreadsheetAuditError, SpreadsheetAuditResult, @@ -29,7 +38,13 @@ __all__ = [ "SPREADSHEET_AUDITOR_ACTION_TYPE", "SPREADSHEET_AUDITOR_ACTION_VERSION", + "AutopilotPlan", + "AutopilotPlanRequest", + "DestinationState", "FileObservation", + "PlanEvaluationError", + "PlanOperation", + "PlanStep", "SpreadsheetAuditError", "SpreadsheetAuditManifest", "SpreadsheetAuditManifestFinding", @@ -38,6 +53,7 @@ "audit_workbook", "build_file_observation", "build_spreadsheet_audit_manifest", + "evaluate_autopilot_plan", "fingerprint_bytes", "handle_spreadsheet_auditor", ] diff --git a/services/engine/src/databreeze_engine/processors/folder_autopilot_plan.py b/services/engine/src/databreeze_engine/processors/folder_autopilot_plan.py new file mode 100644 index 00000000..6af5be9c --- /dev/null +++ b/services/engine/src/databreeze_engine/processors/folder_autopilot_plan.py @@ -0,0 +1,249 @@ +"""Bounded typed Folder Autopilot plan evaluation without filesystem side effects.""" + +from __future__ import annotations + +import hashlib +import json +import re +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictStr, model_validator + +from .folder_autopilot import ActionType, CollisionPolicy, FileObservation + +MAX_PLAN_STEPS = 100 +MAX_DESTINATIONS = 10_000 +MAX_UNIQUE_NAME_ATTEMPTS = 1_000 +_SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$") + + +def _valid_name(value: str) -> bool: + return ( + bool(value) + and value not in {".", ".."} + and "/" not in value + and "\\" not in value + and all(ord(character) >= 32 and ord(character) != 127 for character in value) + ) + + +class PlanEvaluationError(ValueError): + """Stable, content-free plan rejection.""" + + def __init__(self, code: str) -> None: + super().__init__(code) + self.code = code + + +class DestinationState(BaseModel): + """Content-free occupancy state keyed by a Desktop-local output binding.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + bindingId: StrictStr = Field(min_length=1, max_length=128) + displayName: StrictStr = Field(min_length=1, max_length=255) + occupied: StrictBool + + @model_validator(mode="after") + def validate_destination(self) -> DestinationState: + if _SAFE_ID.fullmatch(self.bindingId) is None or not _valid_name(self.displayName): + raise ValueError("INVALID_DESTINATION") + return self + + +class PlanStep(BaseModel): + """A single action from the closed Folder Autopilot action catalog.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + stepId: StrictStr = Field(min_length=1, max_length=128) + action: ActionType + destinationBindingId: StrictStr | None = Field(default=None, max_length=128) + destinationName: StrictStr | None = Field(default=None, max_length=255) + collisionPolicy: CollisionPolicy = "REVIEW" + requiresApproval: StrictBool = False + + @model_validator(mode="after") + def validate_shape(self) -> PlanStep: + if _SAFE_ID.fullmatch(self.stepId) is None: + raise ValueError("INVALID_STEP") + writes_destination = self.action in {"RENAME", "COPY", "MOVE"} + if writes_destination: + if self.destinationBindingId is None or self.destinationName is None: + raise ValueError("DESTINATION_REQUIRED") + if _SAFE_ID.fullmatch(self.destinationBindingId) is None: + raise ValueError("INVALID_DESTINATION_BINDING") + if not _valid_name(self.destinationName): + raise ValueError("INVALID_DESTINATION") + elif self.destinationBindingId is not None or self.destinationName is not None: + raise ValueError("DESTINATION_FORBIDDEN") + return self + + +class AutopilotPlanRequest(BaseModel): + """Local evaluator input; it carries IDs and names, never bytes or OS paths.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + recipeVersionId: StrictStr = Field(min_length=1, max_length=128) + assignmentId: StrictStr = Field(min_length=1, max_length=128) + observation: FileObservation + allowedOutputBindingIds: tuple[StrictStr, ...] = Field(min_length=1, max_length=20) + existingDestinations: tuple[DestinationState, ...] = Field(max_length=MAX_DESTINATIONS) + steps: tuple[PlanStep, ...] = Field(min_length=1, max_length=MAX_PLAN_STEPS) + + @model_validator(mode="after") + def validate_bindings_and_steps(self) -> AutopilotPlanRequest: + if any(_SAFE_ID.fullmatch(binding) is None for binding in self.allowedOutputBindingIds): + raise ValueError("INVALID_DESTINATION_BINDING") + if len(set(self.allowedOutputBindingIds)) != len(self.allowedOutputBindingIds): + raise ValueError("DUPLICATE_DESTINATION_BINDING") + if len({step.stepId for step in self.steps}) != len(self.steps): + raise ValueError("DUPLICATE_STEP") + return self + + +class PlanOperation(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + sequence: int = Field(ge=0, le=MAX_PLAN_STEPS) + stepId: StrictStr + action: ActionType + sourceObservationId: StrictStr + destinationBindingId: StrictStr | None = None + destinationName: StrictStr | None = None + requiresApproval: StrictBool + + +PlanStatus = Literal["READY", "REVIEW", "SKIPPED"] +PlanReason = Literal[ + "DESTINATION_COLLISION", + "DESTINATION_COLLISION_SKIPPED", + "MOVE_REQUIRES_APPROVAL", +] + + +class AutopilotPlan(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + status: PlanStatus + operations: tuple[PlanOperation, ...] = Field(max_length=MAX_PLAN_STEPS) + reasonCodes: tuple[PlanReason, ...] = Field(max_length=MAX_PLAN_STEPS) + planHash: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") + + +def _unique_name(name: str, occupied: set[tuple[str, str]], binding_id: str) -> str | None: + stem, separator, extension = name.rpartition(".") + if not separator or not stem: + stem, extension = name, "" + suffix = f".{extension}" if extension else "" + for index in range(1, MAX_UNIQUE_NAME_ATTEMPTS + 1): + candidate = f"{stem} ({index}){suffix}" + if (binding_id, candidate) not in occupied: + return candidate + return None + + +def _plan_hash( + request: AutopilotPlanRequest, + status: PlanStatus, + operations: tuple[PlanOperation, ...], + reason_codes: tuple[PlanReason, ...], +) -> str: + canonical = json.dumps( + { + "assignmentId": request.assignmentId, + "observationKey": request.observation.stableExecutionKey, + "operations": [operation.model_dump(mode="json") for operation in operations], + "reasonCodes": reason_codes, + "recipeVersionId": request.recipeVersionId, + "status": status, + }, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return hashlib.sha256(canonical).hexdigest() + + +def evaluate_autopilot_plan(request: AutopilotPlanRequest) -> AutopilotPlan: + """Evaluate a bounded typed plan without reading, writing, or shelling out.""" + occupied = { + (destination.bindingId, destination.displayName) + for destination in request.existingDestinations + if destination.occupied + } + operations: list[PlanOperation] = [] + reason_codes: list[PlanReason] = [] + review_required = False + skipped = False + + for sequence, step in enumerate(request.steps): + if step.action in {"INSPECT", "VALIDATE"}: + operations.append( + PlanOperation( + sequence=sequence, + stepId=step.stepId, + action=step.action, + sourceObservationId=request.observation.observationId, + requiresApproval=step.requiresApproval, + ) + ) + review_required = review_required or step.requiresApproval + continue + + binding_id = step.destinationBindingId + destination_name = step.destinationName + if binding_id is None or destination_name is None: + raise PlanEvaluationError("DESTINATION_REQUIRED") + if binding_id not in request.allowedOutputBindingIds: + raise PlanEvaluationError("DESTINATION_BINDING_NOT_ALLOWED") + + requested_key = (binding_id, destination_name) + collision_review = False + if requested_key in occupied: + if step.collisionPolicy == "REVIEW": + collision_review = True + review_required = True + reason_codes.append("DESTINATION_COLLISION") + elif step.collisionPolicy == "SKIP": + skipped = True + reason_codes.append("DESTINATION_COLLISION_SKIPPED") + continue + else: + destination_name = _unique_name(destination_name, occupied, binding_id) + if destination_name is None: + raise PlanEvaluationError("UNIQUE_NAME_EXHAUSTED") + + requires_approval = step.requiresApproval or step.action == "MOVE" or collision_review + if step.action == "MOVE" and not step.requiresApproval: + reason_codes.append("MOVE_REQUIRES_APPROVAL") + review_required = review_required or requires_approval + operation = PlanOperation( + sequence=sequence, + stepId=step.stepId, + action=step.action, + sourceObservationId=request.observation.observationId, + destinationBindingId=binding_id, + destinationName=destination_name, + requiresApproval=requires_approval, + ) + operations.append(operation) + occupied.add((binding_id, destination_name)) + + status: PlanStatus + if review_required: + status = "REVIEW" + elif not operations and skipped: + status = "SKIPPED" + else: + status = "READY" + reason_tuple = tuple(dict.fromkeys(reason_codes)) + operation_tuple = tuple(operations) + return AutopilotPlan( + status=status, + operations=operation_tuple, + reasonCodes=reason_tuple, + planHash=_plan_hash(request, status, operation_tuple, reason_tuple), + ) diff --git a/services/engine/tests/test_folder_autopilot_plan.py b/services/engine/tests/test_folder_autopilot_plan.py new file mode 100644 index 00000000..ae25e993 --- /dev/null +++ b/services/engine/tests/test_folder_autopilot_plan.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import pytest + +from databreeze_engine.processors.folder_autopilot import build_file_observation +from databreeze_engine.processors.folder_autopilot_plan import ( + AutopilotPlanRequest, + CollisionPolicy, + DestinationState, + PlanEvaluationError, + PlanStep, + evaluate_autopilot_plan, +) + + +def _observation(): + return build_file_observation( + observation_id="obs-001", + display_name="invoice.csv", + size_bytes=12, + modified_at_ns=10, + content_sha256="a" * 64, + ) + + +def _request(*steps: PlanStep, destinations: tuple[DestinationState, ...] = ()): + return AutopilotPlanRequest( + recipeVersionId="recipe-001", + assignmentId="assignment-001", + observation=_observation(), + allowedOutputBindingIds=("binding-out",), + existingDestinations=destinations, + steps=steps, + ) + + +def test_evaluator_returns_typed_deterministic_operations_and_hash() -> None: + request = _request( + PlanStep(stepId="inspect", action="INSPECT"), + PlanStep(stepId="validate", action="VALIDATE"), + PlanStep( + stepId="rename", + action="RENAME", + destinationBindingId="binding-out", + destinationName="invoice-reviewed.csv", + ), + ) + + first = evaluate_autopilot_plan(request) + second = evaluate_autopilot_plan(request) + + assert first.status == "READY" + assert [operation.action for operation in first.operations] == [ + "INSPECT", + "VALIDATE", + "RENAME", + ] + assert first.operations[-1].destinationName == "invoice-reviewed.csv" + assert first.planHash == second.planHash + assert first.operations == second.operations + + +@pytest.mark.parametrize("policy", ["REVIEW", "SKIP", "UNIQUE_NAME"]) +def test_collision_policy_is_explicit_and_never_overwrites(policy: CollisionPolicy) -> None: + step = PlanStep( + stepId="copy", + action="COPY", + destinationBindingId="binding-out", + destinationName="invoice.csv", + collisionPolicy=policy, + ) + request = _request( + step, + destinations=( + DestinationState(bindingId="binding-out", displayName="invoice.csv", occupied=True), + ), + ) + + result = evaluate_autopilot_plan(request) + + if policy == "REVIEW": + assert result.status == "REVIEW" + assert result.operations[0].requiresApproval is True + assert "DESTINATION_COLLISION" in result.reasonCodes + elif policy == "SKIP": + assert result.status == "SKIPPED" + assert result.operations == () + assert result.reasonCodes == ("DESTINATION_COLLISION_SKIPPED",) + else: + assert result.status == "READY" + assert result.operations[0].destinationName == "invoice (1).csv" + assert result.operations[0].requiresApproval is False + + +def test_unique_name_generation_is_bounded_and_deterministic() -> None: + step = PlanStep( + stepId="copy", + action="COPY", + destinationBindingId="binding-out", + destinationName="invoice.csv", + collisionPolicy="UNIQUE_NAME", + ) + occupied = tuple( + [ + DestinationState(bindingId="binding-out", displayName="invoice.csv", occupied=True), + *( + DestinationState( + bindingId="binding-out", displayName=f"invoice ({i}).csv", occupied=True + ) + for i in range(1, 101) + ), + ] + ) + + result = evaluate_autopilot_plan(_request(step, destinations=occupied)) + + assert result.status == "READY" + assert result.operations[0].destinationName == "invoice (101).csv" + + +def test_evaluator_rejects_unbound_destinations_and_untyped_actions() -> None: + with pytest.raises(PlanEvaluationError, match="DESTINATION_BINDING_NOT_ALLOWED"): + evaluate_autopilot_plan( + _request( + PlanStep( + stepId="move", + action="MOVE", + destinationBindingId="other-binding", + destinationName="invoice.csv", + ) + ) + ) + + with pytest.raises(ValueError): + PlanStep(stepId="shell", action="RUN_SHELL") + + +def test_evaluator_rejects_unbounded_steps_and_path_like_destination_names() -> None: + with pytest.raises(ValueError): + AutopilotPlanRequest( + recipeVersionId="recipe-001", + assignmentId="assignment-001", + observation=_observation(), + allowedOutputBindingIds=("binding-out",), + existingDestinations=(), + steps=tuple(PlanStep(stepId=f"step-{i}", action="INSPECT") for i in range(101)), + ) + + with pytest.raises(ValueError): + PlanStep( + stepId="rename", + action="RENAME", + destinationBindingId="binding-out", + destinationName="..\\escape.txt", + ) From 92dd2ed70f27667f44fbe8ad6aa021565a0c27e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:14:11 +0700 Subject: [PATCH 04/62] test(web): specify safe Folder Autopilot API boundary --- apps/web/test/folder-autopilot-api.test.ts | 174 +++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 apps/web/test/folder-autopilot-api.test.ts diff --git a/apps/web/test/folder-autopilot-api.test.ts b/apps/web/test/folder-autopilot-api.test.ts new file mode 100644 index 00000000..e81a964e --- /dev/null +++ b/apps/web/test/folder-autopilot-api.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createFolderAutopilotProfile, + decideFolderAutopilotApproval, + getFolderAutopilotDashboard, + pauseFolderAutopilotAssignment, + requestFolderAutopilotUndo, +} from '../src/features/folder-autopilot/folder-autopilot-api.ts'; + +const ids = { + profile: '00000000-0000-4000-8000-000000000001', + assignment: '00000000-0000-4000-8000-000000000002', + recipe: '00000000-0000-4000-8000-000000000003', + device: '00000000-0000-4000-8000-000000000004', + inputBinding: '00000000-0000-4000-8000-000000000005', + outputBinding: '00000000-0000-4000-8000-000000000006', + preview: '00000000-0000-4000-8000-000000000007', + artifact: '00000000-0000-4000-8000-000000000008', + approval: '00000000-0000-4000-8000-000000000009', + execution: '00000000-0000-4000-8000-00000000000a', + job: '00000000-0000-4000-8000-00000000000b', + manifest: '00000000-0000-4000-8000-00000000000c', + exception: '00000000-0000-4000-8000-00000000000d', +}; + +const dashboard = { + schemaVersion: 1, + profiles: [ + { + profileId: ids.profile, + displayName: 'Hóa đơn đầu vào', + stabilizationSeconds: 10, + collisionPolicy: 'REVIEW', + confidenceThreshold: 0.9, + undoWindowHours: 24, + approvalRequired: true, + dataModeConstraint: 'Hybrid', + recipeHash: 'a'.repeat(64), + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ], + assignments: [ + { + assignmentId: ids.assignment, + profileId: ids.profile, + displayName: 'Kho chứng từ', + jraRecipeVersionId: ids.recipe, + deviceId: ids.device, + inputBindingId: ids.inputBinding, + outputBindingId: ids.outputBinding, + state: 'ACTIVE', + approvalRequired: true, + revision: 3, + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ], + previews: [ + { + previewId: ids.preview, + assignmentId: ids.assignment, + jraRecipeVersionId: ids.recipe, + planHash: 'b'.repeat(64), + status: 'NEEDS_APPROVAL', + affectedCount: 2, + blockedCount: 1, + actions: [ + { + stepId: 'step-1', + actionType: 'MOVE', + sourceArtifactVersionId: ids.artifact, + destinationBindingId: ids.outputBinding, + collision: 'REVIEW', + requiresApproval: true, + }, + ], + reasonCodes: ['DESTINATION_COLLISION'], + createdAt: '2026-08-04T00:00:00.000Z', + expiresAt: '2026-08-05T00:00:00.000Z', + }, + ], + approvals: [ + { + approvalId: ids.approval, + previewId: ids.preview, + planHash: 'b'.repeat(64), + decision: 'PENDING', + expiresAt: '2026-08-05T00:00:00.000Z', + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ], + executions: [ + { + executionId: ids.execution, + assignmentId: ids.assignment, + jraJobId: ids.job, + resultManifestId: ids.manifest, + outcome: 'UNDO_AVAILABLE', + affectedCount: 2, + handledCount: 2, + exceptionCount: 0, + reasonCodes: [], + undoState: 'AVAILABLE', + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ], + exceptions: [ + { + exceptionId: ids.exception, + assignmentId: ids.assignment, + executionId: ids.execution, + severity: 'WARNING', + reasonCode: 'DESTINATION_COLLISION', + status: 'OPEN', + createdAt: '2026-08-04T00:00:00.000Z', + }, + ], + health: [ + { + assignmentId: ids.assignment, + watcherState: 'HEALTHY', + lastHeartbeatAt: '2026-08-04T00:00:00.000Z', + queueAgeSeconds: 2, + queuedCount: 1, + syncLagSeconds: 0, + }, + ], +}; + +describe('Folder Autopilot API boundary', () => { + it('parses content-free dashboard projections and rejects source fields', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(dashboard)))); + + const parsed = await getFolderAutopilotDashboard(); + expect(parsed.assignments[0]?.assignmentId).toBe(ids.assignment); + expect(parsed.previews[0]?.actions[0]?.sourceArtifactVersionId).toBe(ids.artifact); + expect(JSON.stringify(parsed)).not.toMatch(/sourcePath|rawBytes|localHandle/iu); + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ...dashboard, sourcePath: 'C:\\private\\invoices' })), + ), + ); + await expect(getFolderAutopilotDashboard()).rejects.toThrow('AUTOPILOT_RESPONSE_INVALID'); + }); + + it('sends only bounded identifiers and policy values for mutations', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ accepted: true, value: dashboard.assignments[0] }), { + status: 200, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + await createFolderAutopilotProfile({ + displayName: 'New profile', + stabilizationSeconds: 10, + collisionPolicy: 'REVIEW', + confidenceThreshold: 0.9, + undoWindowHours: 24, + approvalRequired: true, + dataModeConstraint: 'Hybrid', + }); + await pauseFolderAutopilotAssignment(ids.assignment, 3); + await decideFolderAutopilotApproval(ids.approval, 'APPROVED', 'b'.repeat(64)); + await requestFolderAutopilotUndo(ids.execution); + + for (const [, request] of fetchMock.mock.calls) { + const init = request as RequestInit; + const body = String(init.body ?? ''); + expect(body).not.toMatch(/path|bytes|formula|sourceValue|localHandle/iu); + } + }); +}); From ec28838b45032e352aa5a7225e9d84a08336ee5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:16:36 +0700 Subject: [PATCH 05/62] feat(web): add content-free Folder Autopilot API client --- .../folder-autopilot/folder-autopilot-api.ts | 557 ++++++++++++++++++ apps/web/test/folder-autopilot-api.test.ts | 18 +- 2 files changed, 568 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/features/folder-autopilot/folder-autopilot-api.ts diff --git a/apps/web/src/features/folder-autopilot/folder-autopilot-api.ts b/apps/web/src/features/folder-autopilot/folder-autopilot-api.ts new file mode 100644 index 00000000..4ffcd63a --- /dev/null +++ b/apps/web/src/features/folder-autopilot/folder-autopilot-api.ts @@ -0,0 +1,557 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, +} from '@databreeze/domain/tenant-scope/v1'; + +const UUID_ERROR = 'AUTOPILOT_RESPONSE_INVALID'; +const SAFE_TOKEN = /^[A-Z][A-Z0-9_.-]{1,63}$/u; +const SAFE_TEXT = /^[^\u0000-\u001f\u007f]{1,128}$/u; + +export type FolderAutopilotAssignmentState = 'ACTIVE' | 'PAUSED' | 'RETIRED' | 'INVALID'; +export type FolderAutopilotCollisionPolicy = 'REVIEW' | 'SKIP' | 'UNIQUE_NAME'; +export type FolderAutopilotDataMode = 'Local' | 'Hybrid' | 'Cloud'; +export type FolderAutopilotPreviewStatus = 'READY' | 'NEEDS_APPROVAL' | 'BLOCKED' | 'EXPIRED'; +export type FolderAutopilotDecision = 'PENDING' | 'APPROVED' | 'REJECTED' | 'EXPIRED'; +export type FolderAutopilotActionType = + | 'INSPECT' + | 'RENAME' + | 'COPY' + | 'MOVE' + | 'CONVERT' + | 'ROUTE'; +export type FolderAutopilotCollision = 'NONE' | 'REVIEW' | 'SKIP' | 'UNIQUE_NAME'; +export type FolderAutopilotOutcome = + | 'QUEUED' + | 'WAITING_FOR_APPROVAL' + | 'RUNNING' + | 'HANDLED' + | 'EXCEPTION' + | 'UNDO_AVAILABLE' + | 'UNDO_EXPIRED'; +export type FolderAutopilotUndoState = + | 'AVAILABLE' + | 'REQUESTED' + | 'COMPLETED' + | 'CONFLICT' + | 'EXPIRED' + | 'NOT_ELIGIBLE'; + +export interface FolderAutopilotProfile { + readonly profileId: string; + readonly displayName: string; + readonly stabilizationSeconds: number; + readonly collisionPolicy: FolderAutopilotCollisionPolicy; + readonly confidenceThreshold: number; + readonly undoWindowHours: number; + readonly approvalRequired: boolean; + readonly dataModeConstraint: FolderAutopilotDataMode; + readonly recipeHash: string; + readonly updatedAt: string; +} + +export interface FolderAutopilotProfileInput { + readonly displayName: string; + readonly stabilizationSeconds: number; + readonly collisionPolicy: FolderAutopilotCollisionPolicy; + readonly confidenceThreshold: number; + readonly undoWindowHours: number; + readonly approvalRequired: boolean; + readonly dataModeConstraint: FolderAutopilotDataMode; +} + +export interface FolderAutopilotAssignment { + readonly assignmentId: string; + readonly profileId: string; + readonly displayName: string; + readonly jraRecipeVersionId: string; + readonly deviceId: string; + readonly inputBindingId: string; + readonly outputBindingId: string; + readonly state: FolderAutopilotAssignmentState; + readonly approvalRequired: boolean; + readonly revision: number; + readonly updatedAt: string; +} + +export interface FolderAutopilotActionPlan { + readonly stepId: string; + readonly actionType: FolderAutopilotActionType; + readonly sourceArtifactVersionId: string; + readonly destinationBindingId?: string; + readonly collision: FolderAutopilotCollision; + readonly requiresApproval: boolean; +} + +export interface FolderAutopilotPreview { + readonly previewId: string; + readonly assignmentId: string; + readonly jraRecipeVersionId: string; + readonly planHash: string; + readonly status: FolderAutopilotPreviewStatus; + readonly affectedCount: number; + readonly blockedCount: number; + readonly actions: readonly FolderAutopilotActionPlan[]; + readonly reasonCodes: readonly string[]; + readonly createdAt: string; + readonly expiresAt: string; +} + +export interface FolderAutopilotApproval { + readonly approvalId: string; + readonly previewId: string; + readonly planHash: string; + readonly decision: FolderAutopilotDecision; + readonly expiresAt: string; + readonly updatedAt: string; +} + +export interface FolderAutopilotExecution { + readonly executionId: string; + readonly assignmentId: string; + readonly jraJobId: string; + readonly resultManifestId: string; + readonly outcome: FolderAutopilotOutcome; + readonly affectedCount: number; + readonly handledCount: number; + readonly exceptionCount: number; + readonly reasonCodes: readonly string[]; + readonly undoState: FolderAutopilotUndoState; + readonly updatedAt: string; +} + +export interface FolderAutopilotException { + readonly exceptionId: string; + readonly assignmentId: string; + readonly executionId?: string; + readonly severity: 'INFO' | 'WARNING' | 'ERROR'; + readonly reasonCode: string; + readonly status: 'OPEN' | 'RESOLVED' | 'IGNORED'; + readonly createdAt: string; +} + +export interface FolderAutopilotHealth { + readonly assignmentId: string; + readonly watcherState: 'HEALTHY' | 'PAUSED' | 'OVERFLOWED' | 'OFFLINE'; + readonly lastHeartbeatAt: string; + readonly queueAgeSeconds: number; + readonly queuedCount: number; + readonly syncLagSeconds: number; +} + +export interface FolderAutopilotDashboard { + readonly schemaVersion: 1; + readonly profiles: readonly FolderAutopilotProfile[]; + readonly assignments: readonly FolderAutopilotAssignment[]; + readonly previews: readonly FolderAutopilotPreview[]; + readonly approvals: readonly FolderAutopilotApproval[]; + readonly executions: readonly FolderAutopilotExecution[]; + readonly exceptions: readonly FolderAutopilotException[]; + readonly health: readonly FolderAutopilotHealth[]; +} + +function apiBaseUrl(): string { + const configured: unknown = import.meta.env['VITE_DATABREEZE_API_BASE_URL']; + return typeof configured === 'string' && configured.trim() !== '' + ? configured.replace(/\/$/u, '') + : ''; +} + +function object(input: unknown): Record { + if (typeof input !== 'object' || input === null || Array.isArray(input)) + throw new Error(UUID_ERROR); + return input as Record; +} + +function only(input: Record, keys: readonly string[]): void { + const allowed = new Set(keys); + if (Object.keys(input).some((key) => !allowed.has(key))) throw new Error(UUID_ERROR); +} + +function id(input: unknown): string { + const parsed = parseStableIdentifierV1(input); + if (!parsed.accepted) throw new Error(UUID_ERROR); + return parsed.value; +} + +function timestamp(input: unknown): string { + const parsed = parseStrictUtcTimestampV1(input); + if (!parsed.accepted) throw new Error(UUID_ERROR); + return parsed.value; +} + +function text(input: unknown): string { + if (typeof input !== 'string' || !SAFE_TEXT.test(input) || input.trim() !== input) + throw new Error(UUID_ERROR); + return input; +} + +function token(input: unknown): string { + if (typeof input !== 'string' || !SAFE_TOKEN.test(input)) throw new Error(UUID_ERROR); + return input; +} + +function hash(input: unknown): string { + if (typeof input !== 'string' || !/^[0-9a-f]{64}$/u.test(input)) throw new Error(UUID_ERROR); + return input; +} + +function count(input: unknown): number { + if (typeof input !== 'number' || !Number.isSafeInteger(input) || input < 0) + throw new Error(UUID_ERROR); + return input; +} + +function decimal(input: unknown): number { + if (typeof input !== 'number' || !Number.isFinite(input) || input < 0 || input > 1) + throw new Error(UUID_ERROR); + return input; +} + +function boundedSeconds(input: unknown, maximum: number): number { + const value = count(input); + if (value > maximum) throw new Error(UUID_ERROR); + return value; +} + +function oneOf(input: unknown, values: readonly TValue[]): TValue { + if (typeof input !== 'string' || !values.includes(input as TValue)) throw new Error(UUID_ERROR); + return input as TValue; +} + +function list(input: unknown): readonly unknown[] { + if (!Array.isArray(input) || input.length > 512) throw new Error(UUID_ERROR); + return input; +} + +function parseProfile(input: unknown): FolderAutopilotProfile { + const value = object(input); + only(value, [ + 'profileId', + 'displayName', + 'stabilizationSeconds', + 'collisionPolicy', + 'confidenceThreshold', + 'undoWindowHours', + 'approvalRequired', + 'dataModeConstraint', + 'recipeHash', + 'updatedAt', + ]); + if (typeof value['approvalRequired'] !== 'boolean') throw new Error(UUID_ERROR); + return Object.freeze({ + profileId: id(value['profileId']), + displayName: text(value['displayName']), + stabilizationSeconds: boundedSeconds(value['stabilizationSeconds'], 86_400), + collisionPolicy: oneOf(value['collisionPolicy'], ['REVIEW', 'SKIP', 'UNIQUE_NAME']), + confidenceThreshold: decimal(value['confidenceThreshold']), + undoWindowHours: boundedSeconds(value['undoWindowHours'], 8_760), + approvalRequired: value['approvalRequired'], + dataModeConstraint: oneOf(value['dataModeConstraint'], ['Local', 'Hybrid', 'Cloud']), + recipeHash: hash(value['recipeHash']), + updatedAt: timestamp(value['updatedAt']), + }); +} + +function parseAssignment(input: unknown): FolderAutopilotAssignment { + const value = object(input); + only(value, [ + 'assignmentId', + 'profileId', + 'displayName', + 'jraRecipeVersionId', + 'deviceId', + 'inputBindingId', + 'outputBindingId', + 'state', + 'approvalRequired', + 'revision', + 'updatedAt', + ]); + if (typeof value['approvalRequired'] !== 'boolean') throw new Error(UUID_ERROR); + return Object.freeze({ + assignmentId: id(value['assignmentId']), + profileId: id(value['profileId']), + displayName: text(value['displayName']), + jraRecipeVersionId: id(value['jraRecipeVersionId']), + deviceId: id(value['deviceId']), + inputBindingId: id(value['inputBindingId']), + outputBindingId: id(value['outputBindingId']), + state: oneOf(value['state'], ['ACTIVE', 'PAUSED', 'RETIRED', 'INVALID']), + approvalRequired: value['approvalRequired'], + revision: Math.max(1, count(value['revision'])), + updatedAt: timestamp(value['updatedAt']), + }); +} + +function parseAction(input: unknown): FolderAutopilotActionPlan { + const value = object(input); + only(value, [ + 'stepId', + 'actionType', + 'sourceArtifactVersionId', + 'destinationBindingId', + 'collision', + 'requiresApproval', + ]); + if (typeof value['requiresApproval'] !== 'boolean') throw new Error(UUID_ERROR); + const destinationBindingId = value['destinationBindingId']; + return Object.freeze({ + stepId: text(value['stepId']), + actionType: oneOf(value['actionType'], [ + 'INSPECT', + 'RENAME', + 'COPY', + 'MOVE', + 'CONVERT', + 'ROUTE', + ]), + sourceArtifactVersionId: id(value['sourceArtifactVersionId']), + ...(destinationBindingId === undefined + ? {} + : { destinationBindingId: id(destinationBindingId) }), + collision: oneOf(value['collision'], ['NONE', 'REVIEW', 'SKIP', 'UNIQUE_NAME']), + requiresApproval: value['requiresApproval'], + }); +} + +function reasonCodes(input: unknown): readonly string[] { + return Object.freeze(list(input).map(token)); +} + +function parsePreview(input: unknown): FolderAutopilotPreview { + const value = object(input); + only(value, [ + 'previewId', + 'assignmentId', + 'jraRecipeVersionId', + 'planHash', + 'status', + 'affectedCount', + 'blockedCount', + 'actions', + 'reasonCodes', + 'createdAt', + 'expiresAt', + ]); + return Object.freeze({ + previewId: id(value['previewId']), + assignmentId: id(value['assignmentId']), + jraRecipeVersionId: id(value['jraRecipeVersionId']), + planHash: hash(value['planHash']), + status: oneOf(value['status'], ['READY', 'NEEDS_APPROVAL', 'BLOCKED', 'EXPIRED']), + affectedCount: count(value['affectedCount']), + blockedCount: count(value['blockedCount']), + actions: Object.freeze(list(value['actions']).map(parseAction)), + reasonCodes: reasonCodes(value['reasonCodes']), + createdAt: timestamp(value['createdAt']), + expiresAt: timestamp(value['expiresAt']), + }); +} + +function parseApproval(input: unknown): FolderAutopilotApproval { + const value = object(input); + only(value, ['approvalId', 'previewId', 'planHash', 'decision', 'expiresAt', 'updatedAt']); + return Object.freeze({ + approvalId: id(value['approvalId']), + previewId: id(value['previewId']), + planHash: hash(value['planHash']), + decision: oneOf(value['decision'], ['PENDING', 'APPROVED', 'REJECTED', 'EXPIRED']), + expiresAt: timestamp(value['expiresAt']), + updatedAt: timestamp(value['updatedAt']), + }); +} + +function parseExecution(input: unknown): FolderAutopilotExecution { + const value = object(input); + only(value, [ + 'executionId', + 'assignmentId', + 'jraJobId', + 'resultManifestId', + 'outcome', + 'affectedCount', + 'handledCount', + 'exceptionCount', + 'reasonCodes', + 'undoState', + 'updatedAt', + ]); + return Object.freeze({ + executionId: id(value['executionId']), + assignmentId: id(value['assignmentId']), + jraJobId: id(value['jraJobId']), + resultManifestId: id(value['resultManifestId']), + outcome: oneOf(value['outcome'], [ + 'QUEUED', + 'WAITING_FOR_APPROVAL', + 'RUNNING', + 'HANDLED', + 'EXCEPTION', + 'UNDO_AVAILABLE', + 'UNDO_EXPIRED', + ]), + affectedCount: count(value['affectedCount']), + handledCount: count(value['handledCount']), + exceptionCount: count(value['exceptionCount']), + reasonCodes: reasonCodes(value['reasonCodes']), + undoState: oneOf(value['undoState'], [ + 'AVAILABLE', + 'REQUESTED', + 'COMPLETED', + 'CONFLICT', + 'EXPIRED', + 'NOT_ELIGIBLE', + ]), + updatedAt: timestamp(value['updatedAt']), + }); +} + +function parseException(input: unknown): FolderAutopilotException { + const value = object(input); + only(value, [ + 'exceptionId', + 'assignmentId', + 'executionId', + 'severity', + 'reasonCode', + 'status', + 'createdAt', + ]); + const executionId = value['executionId']; + return Object.freeze({ + exceptionId: id(value['exceptionId']), + assignmentId: id(value['assignmentId']), + ...(executionId === undefined ? {} : { executionId: id(executionId) }), + severity: oneOf(value['severity'], ['INFO', 'WARNING', 'ERROR']), + reasonCode: token(value['reasonCode']), + status: oneOf(value['status'], ['OPEN', 'RESOLVED', 'IGNORED']), + createdAt: timestamp(value['createdAt']), + }); +} + +function parseHealth(input: unknown): FolderAutopilotHealth { + const value = object(input); + only(value, [ + 'assignmentId', + 'watcherState', + 'lastHeartbeatAt', + 'queueAgeSeconds', + 'queuedCount', + 'syncLagSeconds', + ]); + return Object.freeze({ + assignmentId: id(value['assignmentId']), + watcherState: oneOf(value['watcherState'], ['HEALTHY', 'PAUSED', 'OVERFLOWED', 'OFFLINE']), + lastHeartbeatAt: timestamp(value['lastHeartbeatAt']), + queueAgeSeconds: boundedSeconds(value['queueAgeSeconds'], 31_536_000), + queuedCount: count(value['queuedCount']), + syncLagSeconds: boundedSeconds(value['syncLagSeconds'], 31_536_000), + }); +} + +function parseDashboard(input: unknown): FolderAutopilotDashboard { + const value = object(input); + only(value, [ + 'schemaVersion', + 'profiles', + 'assignments', + 'previews', + 'approvals', + 'executions', + 'exceptions', + 'health', + ]); + if (value['schemaVersion'] !== 1) throw new Error(UUID_ERROR); + return Object.freeze({ + schemaVersion: 1, + profiles: Object.freeze(list(value['profiles']).map(parseProfile)), + assignments: Object.freeze(list(value['assignments']).map(parseAssignment)), + previews: Object.freeze(list(value['previews']).map(parsePreview)), + approvals: Object.freeze(list(value['approvals']).map(parseApproval)), + executions: Object.freeze(list(value['executions']).map(parseExecution)), + exceptions: Object.freeze(list(value['exceptions']).map(parseException)), + health: Object.freeze(list(value['health']).map(parseHealth)), + }); +} + +async function responsePayload(response: Response): Promise { + if (!response.ok) throw new Error('AUTOPILOT_REQUEST_FAILED'); + const payload: unknown = await response.json(); + const value = object(payload); + if (value['accepted'] === true && value['value'] !== undefined) return value['value']; + return payload; +} + +function idempotencyKey(prefix: string): string { + const random = globalThis.crypto?.randomUUID?.(); + return `${prefix}-${random ?? 'client-generated'}`; +} + +async function mutate( + path: string, + body: Record, + signal?: AbortSignal, +): Promise { + const response = await fetch(`${apiBaseUrl()}${path}`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey('autopilot'), + }, + credentials: 'include', + body: JSON.stringify(body), + ...(signal === undefined ? {} : { signal }), + }); + return responsePayload(response); +} + +export async function getFolderAutopilotDashboard( + signal?: AbortSignal, +): Promise { + const response = await fetch(`${apiBaseUrl()}/v1/autopilot-dashboard`, { + headers: { Accept: 'application/json' }, + credentials: 'include', + ...(signal === undefined ? {} : { signal }), + }); + return parseDashboard(await responsePayload(response)); +} + +export async function createFolderAutopilotProfile( + input: FolderAutopilotProfileInput, + signal?: AbortSignal, +): Promise { + return mutate('/v1/autopilot-profiles', input as unknown as Record, signal); +} + +export async function pauseFolderAutopilotAssignment( + assignmentId: string, + expectedRevision: number, + signal?: AbortSignal, +): Promise { + return mutate( + `/v1/autopilot-assignments/${encodeURIComponent(assignmentId)}/pause`, + { expectedRevision }, + signal, + ); +} + +export async function decideFolderAutopilotApproval( + approvalId: string, + decision: Exclude, + planHash: string, + signal?: AbortSignal, +): Promise { + return mutate( + `/v1/autopilot-approvals/${encodeURIComponent(approvalId)}/decision`, + { decision, planHash }, + signal, + ); +} + +export async function requestFolderAutopilotUndo( + executionId: string, + signal?: AbortSignal, +): Promise { + return mutate(`/v1/autopilot-executions/${encodeURIComponent(executionId)}/undo`, {}, signal); +} diff --git a/apps/web/test/folder-autopilot-api.test.ts b/apps/web/test/folder-autopilot-api.test.ts index e81a964e..3d380af6 100644 --- a/apps/web/test/folder-autopilot-api.test.ts +++ b/apps/web/test/folder-autopilot-api.test.ts @@ -137,18 +137,22 @@ describe('Folder Autopilot API boundary', () => { vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ...dashboard, sourcePath: 'C:\\private\\invoices' })), - ), + vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ ...dashboard, sourcePath: 'C:\\private\\invoices' })), + ), ); await expect(getFolderAutopilotDashboard()).rejects.toThrow('AUTOPILOT_RESPONSE_INVALID'); }); it('sends only bounded identifiers and policy values for mutations', async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ accepted: true, value: dashboard.assignments[0] }), { - status: 200, - }), + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ accepted: true, value: dashboard.assignments[0] }), { + status: 200, + }), + ), ); vi.stubGlobal('fetch', fetchMock); From 67fd769d42223dc10a364ec11733daedb1aa9cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:21:22 +0700 Subject: [PATCH 06/62] feat(engine): register typed autopilot plan action --- .../folder_autopilot_contracts.py | 183 ++++++++++++++++++ .../engine/src/databreeze_engine/models.py | 10 +- .../databreeze_engine/processors/__init__.py | 12 ++ .../processors/folder_autopilot.py | 68 ++----- .../processors/folder_autopilot_action.py | 29 +++ .../processors/folder_autopilot_plan.py | 146 +++----------- .../engine/src/databreeze_engine/registry.py | 80 +++++++- .../tests/test_folder_autopilot_action.py | 89 +++++++++ 8 files changed, 436 insertions(+), 181 deletions(-) create mode 100644 services/engine/src/databreeze_engine/folder_autopilot_contracts.py create mode 100644 services/engine/src/databreeze_engine/processors/folder_autopilot_action.py create mode 100644 services/engine/tests/test_folder_autopilot_action.py diff --git a/services/engine/src/databreeze_engine/folder_autopilot_contracts.py b/services/engine/src/databreeze_engine/folder_autopilot_contracts.py new file mode 100644 index 00000000..df48f989 --- /dev/null +++ b/services/engine/src/databreeze_engine/folder_autopilot_contracts.py @@ -0,0 +1,183 @@ +"""Closed, content-free Folder Autopilot contracts shared by the engine boundary.""" + +from __future__ import annotations + +import re +from typing import Annotated, Any, Literal + +from pydantic import ( + BaseModel, + BeforeValidator, + ConfigDict, + Field, + StrictBool, + StrictInt, + StrictStr, + field_validator, + model_validator, +) + +MAX_AUTOPILOT_FILE_BYTES = 10 * 1024 * 1024 * 1024 +MAX_PLAN_STEPS = 100 +MAX_DESTINATIONS = 10_000 +_SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$") +_DIGEST = re.compile(r"^[0-9a-f]{64}$") + + +def _invalid() -> ValueError: + return ValueError("INVALID_OBSERVATION") + + +def _valid_name(value: str) -> bool: + return ( + bool(value) + and value not in {".", ".."} + and "/" not in value + and "\\" not in value + and all(ord(character) >= 32 and ord(character) != 127 for character in value) + ) + + +def _tuple_from_json(value: Any) -> Any: + return tuple(value) if isinstance(value, list) else value + + +class FileObservation(BaseModel): + """A bounded, value-free identity for one locally observed file.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + observationId: StrictStr + displayName: StrictStr = Field(min_length=1, max_length=255) + sizeBytes: StrictInt = Field(ge=0, le=MAX_AUTOPILOT_FILE_BYTES) + modifiedAtNs: StrictInt = Field(ge=0) + contentSha256: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") + stableExecutionKey: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") + + @field_validator("observationId") + @classmethod + def validate_observation_id(cls, value: str) -> str: + if _SAFE_ID.fullmatch(value) is None: + raise _invalid() + return value + + @field_validator("displayName") + @classmethod + def validate_display_name(cls, value: str) -> str: + if not _valid_name(value): + raise _invalid() + return value + + @field_validator("contentSha256", "stableExecutionKey") + @classmethod + def validate_digest(cls, value: str) -> str: + if _DIGEST.fullmatch(value) is None: + raise _invalid() + return value + + +ActionType = Literal["INSPECT", "VALIDATE", "RENAME", "COPY", "MOVE"] +CollisionPolicy = Literal["REVIEW", "SKIP", "UNIQUE_NAME"] + + +class DestinationState(BaseModel): + """Content-free occupancy state keyed by a Desktop-local output binding.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + bindingId: StrictStr = Field(min_length=1, max_length=128) + displayName: StrictStr = Field(min_length=1, max_length=255) + occupied: StrictBool + + @model_validator(mode="after") + def validate_destination(self) -> DestinationState: + if _SAFE_ID.fullmatch(self.bindingId) is None or not _valid_name(self.displayName): + raise ValueError("INVALID_DESTINATION") + return self + + +class PlanStep(BaseModel): + """A single action from the closed Folder Autopilot action catalog.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + stepId: StrictStr = Field(min_length=1, max_length=128) + action: ActionType + destinationBindingId: StrictStr | None = Field(default=None, max_length=128) + destinationName: StrictStr | None = Field(default=None, max_length=255) + collisionPolicy: CollisionPolicy = "REVIEW" + requiresApproval: StrictBool = False + + @model_validator(mode="after") + def validate_shape(self) -> PlanStep: + if _SAFE_ID.fullmatch(self.stepId) is None: + raise ValueError("INVALID_STEP") + writes_destination = self.action in {"RENAME", "COPY", "MOVE"} + if writes_destination: + if self.destinationBindingId is None or self.destinationName is None: + raise ValueError("DESTINATION_REQUIRED") + if _SAFE_ID.fullmatch(self.destinationBindingId) is None: + raise ValueError("INVALID_DESTINATION_BINDING") + if not _valid_name(self.destinationName): + raise ValueError("INVALID_DESTINATION") + elif self.destinationBindingId is not None or self.destinationName is not None: + raise ValueError("DESTINATION_FORBIDDEN") + return self + + +class AutopilotPlanRequest(BaseModel): + """Local evaluator input; it carries IDs and names, never bytes or OS paths.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + recipeVersionId: StrictStr = Field(min_length=1, max_length=128) + assignmentId: StrictStr = Field(min_length=1, max_length=128) + observation: FileObservation + allowedOutputBindingIds: Annotated[tuple[StrictStr, ...], BeforeValidator(_tuple_from_json)] = ( + Field(min_length=1, max_length=20) + ) + existingDestinations: Annotated[ + tuple[DestinationState, ...], BeforeValidator(_tuple_from_json) + ] = Field(max_length=MAX_DESTINATIONS) + steps: Annotated[tuple[PlanStep, ...], BeforeValidator(_tuple_from_json)] = Field( + min_length=1, max_length=MAX_PLAN_STEPS + ) + + @model_validator(mode="after") + def validate_bindings_and_steps(self) -> AutopilotPlanRequest: + if any(_SAFE_ID.fullmatch(binding) is None for binding in self.allowedOutputBindingIds): + raise ValueError("INVALID_DESTINATION_BINDING") + if len(set(self.allowedOutputBindingIds)) != len(self.allowedOutputBindingIds): + raise ValueError("DUPLICATE_DESTINATION_BINDING") + if len({step.stepId for step in self.steps}) != len(self.steps): + raise ValueError("DUPLICATE_STEP") + return self + + +class PlanOperation(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + sequence: int = Field(ge=0, le=MAX_PLAN_STEPS) + stepId: StrictStr + action: ActionType + sourceObservationId: StrictStr + destinationBindingId: StrictStr | None = None + destinationName: StrictStr | None = None + requiresApproval: StrictBool + + +PlanStatus = Literal["READY", "REVIEW", "SKIPPED"] +PlanReason = Literal[ + "DESTINATION_COLLISION", + "DESTINATION_COLLISION_SKIPPED", + "MOVE_REQUIRES_APPROVAL", +] + + +class AutopilotPlan(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, strict=True) + + status: PlanStatus + operations: tuple[PlanOperation, ...] = Field(max_length=MAX_PLAN_STEPS) + reasonCodes: tuple[PlanReason, ...] = Field(max_length=MAX_PLAN_STEPS) + planHash: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") diff --git a/services/engine/src/databreeze_engine/models.py b/services/engine/src/databreeze_engine/models.py index 3ad71a1f..e8908b80 100644 --- a/services/engine/src/databreeze_engine/models.py +++ b/services/engine/src/databreeze_engine/models.py @@ -17,6 +17,8 @@ model_validator, ) +from .folder_autopilot_contracts import AutopilotPlan, AutopilotPlanRequest + MAX_HANDLES = 32 @@ -91,7 +93,7 @@ class SpreadsheetAuditParameters(ClosedModel): resultManifestId: Identifier -ActionParameters = FoundationMetadataParameters | SpreadsheetAuditParameters +ActionParameters = FoundationMetadataParameters | SpreadsheetAuditParameters | AutopilotPlanRequest class EngineExecutionRequest(ClosedModel): @@ -102,7 +104,7 @@ class EngineExecutionRequest(ClosedModel): action: ActionReference inputHandles: Annotated[list[OpaqueHandle], Field(max_length=MAX_HANDLES)] outputHandle: OpaqueHandle - parameters: FoundationMetadataParameters | SpreadsheetAuditParameters + parameters: ActionParameters deadline: UtcTimestamp locale: Literal["vi-VN", "en"] @@ -169,13 +171,13 @@ class SpreadsheetAuditProcessorResult(ClosedModel): processorVersion: Annotated[StrictStr, StringConstraints(min_length=1, max_length=128)] -ActionOutput = FoundationDigestResult | SpreadsheetAuditProcessorResult +ActionOutput = FoundationDigestResult | SpreadsheetAuditProcessorResult | AutopilotPlan class EngineResult(ClosedModel): attemptId: Identifier status: Literal["SUCCEEDED"] - output: FoundationDigestResult | SpreadsheetAuditProcessorResult + output: FoundationDigestResult | SpreadsheetAuditProcessorResult | AutopilotPlan EngineErrorCode = Literal[ diff --git a/services/engine/src/databreeze_engine/processors/__init__.py b/services/engine/src/databreeze_engine/processors/__init__.py index 773cf4d6..cb16c787 100644 --- a/services/engine/src/databreeze_engine/processors/__init__.py +++ b/services/engine/src/databreeze_engine/processors/__init__.py @@ -5,6 +5,15 @@ build_file_observation, fingerprint_bytes, ) +from .folder_autopilot_action import ( + ACTION_TYPE as FOLDER_AUTOPILOT_ACTION_TYPE, +) +from .folder_autopilot_action import ( + ACTION_VERSION as FOLDER_AUTOPILOT_ACTION_VERSION, +) +from .folder_autopilot_action import ( + handle as handle_folder_autopilot, +) from .folder_autopilot_plan import ( AutopilotPlan, AutopilotPlanRequest, @@ -36,6 +45,8 @@ ) __all__ = [ + "FOLDER_AUTOPILOT_ACTION_TYPE", + "FOLDER_AUTOPILOT_ACTION_VERSION", "SPREADSHEET_AUDITOR_ACTION_TYPE", "SPREADSHEET_AUDITOR_ACTION_VERSION", "AutopilotPlan", @@ -55,5 +66,6 @@ "build_spreadsheet_audit_manifest", "evaluate_autopilot_plan", "fingerprint_bytes", + "handle_folder_autopilot", "handle_spreadsheet_auditor", ] diff --git a/services/engine/src/databreeze_engine/processors/folder_autopilot.py b/services/engine/src/databreeze_engine/processors/folder_autopilot.py index dd2affa3..48015b33 100644 --- a/services/engine/src/databreeze_engine/processors/folder_autopilot.py +++ b/services/engine/src/databreeze_engine/processors/folder_autopilot.py @@ -4,57 +4,13 @@ import hashlib import json -import re -from typing import Literal -from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr, field_validator - -MAX_AUTOPILOT_FILE_BYTES = 10 * 1024 * 1024 * 1024 -_SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$") -_DIGEST = re.compile(r"^[0-9a-f]{64}$") - - -def _invalid() -> ValueError: - return ValueError("INVALID_OBSERVATION") - - -class FileObservation(BaseModel): - """A bounded, value-free identity for one locally observed file.""" - - model_config = ConfigDict(extra="forbid", frozen=True, strict=True) - - observationId: StrictStr - displayName: StrictStr = Field(min_length=1, max_length=255) - sizeBytes: StrictInt = Field(ge=0, le=MAX_AUTOPILOT_FILE_BYTES) - modifiedAtNs: StrictInt = Field(ge=0) - contentSha256: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") - stableExecutionKey: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") - - @field_validator("observationId") - @classmethod - def validate_observation_id(cls, value: str) -> str: - if _SAFE_ID.fullmatch(value) is None: - raise _invalid() - return value - - @field_validator("displayName") - @classmethod - def validate_display_name(cls, value: str) -> str: - if ( - value in {".", ".."} - or "/" in value - or "\\" in value - or any(ord(character) < 32 or ord(character) == 127 for character in value) - ): - raise _invalid() - return value - - @field_validator("contentSha256", "stableExecutionKey") - @classmethod - def validate_digest(cls, value: str) -> str: - if _DIGEST.fullmatch(value) is None: - raise _invalid() - return value +from ..folder_autopilot_contracts import ( + MAX_AUTOPILOT_FILE_BYTES, + ActionType, + CollisionPolicy, + FileObservation, +) def fingerprint_bytes(content: bytes) -> str: @@ -114,8 +70,14 @@ def build_file_observation( stableExecutionKey=stable_key, ) except Exception as error: - raise _invalid() from error + raise ValueError("INVALID_OBSERVATION") from error -ActionType = Literal["INSPECT", "VALIDATE", "RENAME", "COPY", "MOVE"] -CollisionPolicy = Literal["REVIEW", "SKIP", "UNIQUE_NAME"] +__all__ = [ + "MAX_AUTOPILOT_FILE_BYTES", + "ActionType", + "CollisionPolicy", + "FileObservation", + "build_file_observation", + "fingerprint_bytes", +] diff --git a/services/engine/src/databreeze_engine/processors/folder_autopilot_action.py b/services/engine/src/databreeze_engine/processors/folder_autopilot_action.py new file mode 100644 index 00000000..88d679e5 --- /dev/null +++ b/services/engine/src/databreeze_engine/processors/folder_autopilot_action.py @@ -0,0 +1,29 @@ +"""Reviewed read-only Folder Autopilot plan evaluator action.""" + +from __future__ import annotations + +from typing import Any + +from databreeze_engine.handler import ActionExecutionError, HandlerContext + +from .folder_autopilot_plan import ( + AutopilotPlan, + AutopilotPlanRequest, + PlanEvaluationError, + evaluate_autopilot_plan, +) + +ACTION_TYPE = "folder-autopilot.plan-evaluate" +ACTION_VERSION = "1.0.0" +INPUT_SCHEMA_ID = "folder-autopilot.plan-request.v1" +OUTPUT_SCHEMA_ID = "folder-autopilot.plan-result.v1" + + +def handle(context: HandlerContext, parameters: Any) -> AutopilotPlan: + """Evaluate only typed metadata; local file effects remain Desktop-owned.""" + if context.input_handles or not isinstance(parameters, AutopilotPlanRequest): + raise ActionExecutionError("VALIDATION_FAILED") + try: + return evaluate_autopilot_plan(parameters) + except PlanEvaluationError as error: + raise ActionExecutionError(error.code) from None diff --git a/services/engine/src/databreeze_engine/processors/folder_autopilot_plan.py b/services/engine/src/databreeze_engine/processors/folder_autopilot_plan.py index 6af5be9c..146c844c 100644 --- a/services/engine/src/databreeze_engine/processors/folder_autopilot_plan.py +++ b/services/engine/src/databreeze_engine/processors/folder_autopilot_plan.py @@ -4,27 +4,18 @@ import hashlib import json -import re -from typing import Literal -from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictStr, model_validator +from ..folder_autopilot_contracts import ( + AutopilotPlan, + AutopilotPlanRequest, + CollisionPolicy, + DestinationState, + PlanOperation, + PlanReason, + PlanStep, +) -from .folder_autopilot import ActionType, CollisionPolicy, FileObservation - -MAX_PLAN_STEPS = 100 -MAX_DESTINATIONS = 10_000 MAX_UNIQUE_NAME_ATTEMPTS = 1_000 -_SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$") - - -def _valid_name(value: str) -> bool: - return ( - bool(value) - and value not in {".", ".."} - and "/" not in value - and "\\" not in value - and all(ord(character) >= 32 and ord(character) != 127 for character in value) - ) class PlanEvaluationError(ValueError): @@ -35,103 +26,6 @@ def __init__(self, code: str) -> None: self.code = code -class DestinationState(BaseModel): - """Content-free occupancy state keyed by a Desktop-local output binding.""" - - model_config = ConfigDict(extra="forbid", frozen=True, strict=True) - - bindingId: StrictStr = Field(min_length=1, max_length=128) - displayName: StrictStr = Field(min_length=1, max_length=255) - occupied: StrictBool - - @model_validator(mode="after") - def validate_destination(self) -> DestinationState: - if _SAFE_ID.fullmatch(self.bindingId) is None or not _valid_name(self.displayName): - raise ValueError("INVALID_DESTINATION") - return self - - -class PlanStep(BaseModel): - """A single action from the closed Folder Autopilot action catalog.""" - - model_config = ConfigDict(extra="forbid", frozen=True, strict=True) - - stepId: StrictStr = Field(min_length=1, max_length=128) - action: ActionType - destinationBindingId: StrictStr | None = Field(default=None, max_length=128) - destinationName: StrictStr | None = Field(default=None, max_length=255) - collisionPolicy: CollisionPolicy = "REVIEW" - requiresApproval: StrictBool = False - - @model_validator(mode="after") - def validate_shape(self) -> PlanStep: - if _SAFE_ID.fullmatch(self.stepId) is None: - raise ValueError("INVALID_STEP") - writes_destination = self.action in {"RENAME", "COPY", "MOVE"} - if writes_destination: - if self.destinationBindingId is None or self.destinationName is None: - raise ValueError("DESTINATION_REQUIRED") - if _SAFE_ID.fullmatch(self.destinationBindingId) is None: - raise ValueError("INVALID_DESTINATION_BINDING") - if not _valid_name(self.destinationName): - raise ValueError("INVALID_DESTINATION") - elif self.destinationBindingId is not None or self.destinationName is not None: - raise ValueError("DESTINATION_FORBIDDEN") - return self - - -class AutopilotPlanRequest(BaseModel): - """Local evaluator input; it carries IDs and names, never bytes or OS paths.""" - - model_config = ConfigDict(extra="forbid", frozen=True, strict=True) - - recipeVersionId: StrictStr = Field(min_length=1, max_length=128) - assignmentId: StrictStr = Field(min_length=1, max_length=128) - observation: FileObservation - allowedOutputBindingIds: tuple[StrictStr, ...] = Field(min_length=1, max_length=20) - existingDestinations: tuple[DestinationState, ...] = Field(max_length=MAX_DESTINATIONS) - steps: tuple[PlanStep, ...] = Field(min_length=1, max_length=MAX_PLAN_STEPS) - - @model_validator(mode="after") - def validate_bindings_and_steps(self) -> AutopilotPlanRequest: - if any(_SAFE_ID.fullmatch(binding) is None for binding in self.allowedOutputBindingIds): - raise ValueError("INVALID_DESTINATION_BINDING") - if len(set(self.allowedOutputBindingIds)) != len(self.allowedOutputBindingIds): - raise ValueError("DUPLICATE_DESTINATION_BINDING") - if len({step.stepId for step in self.steps}) != len(self.steps): - raise ValueError("DUPLICATE_STEP") - return self - - -class PlanOperation(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True, strict=True) - - sequence: int = Field(ge=0, le=MAX_PLAN_STEPS) - stepId: StrictStr - action: ActionType - sourceObservationId: StrictStr - destinationBindingId: StrictStr | None = None - destinationName: StrictStr | None = None - requiresApproval: StrictBool - - -PlanStatus = Literal["READY", "REVIEW", "SKIPPED"] -PlanReason = Literal[ - "DESTINATION_COLLISION", - "DESTINATION_COLLISION_SKIPPED", - "MOVE_REQUIRES_APPROVAL", -] - - -class AutopilotPlan(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True, strict=True) - - status: PlanStatus - operations: tuple[PlanOperation, ...] = Field(max_length=MAX_PLAN_STEPS) - reasonCodes: tuple[PlanReason, ...] = Field(max_length=MAX_PLAN_STEPS) - planHash: StrictStr = Field(pattern=r"^[0-9a-f]{64}$") - - def _unique_name(name: str, occupied: set[tuple[str, str]], binding_id: str) -> str | None: stem, separator, extension = name.rpartition(".") if not separator or not stem: @@ -146,7 +40,7 @@ def _unique_name(name: str, occupied: set[tuple[str, str]], binding_id: str) -> def _plan_hash( request: AutopilotPlanRequest, - status: PlanStatus, + status: str, operations: tuple[PlanOperation, ...], reason_codes: tuple[PlanReason, ...], ) -> str: @@ -232,13 +126,7 @@ def evaluate_autopilot_plan(request: AutopilotPlanRequest) -> AutopilotPlan: operations.append(operation) occupied.add((binding_id, destination_name)) - status: PlanStatus - if review_required: - status = "REVIEW" - elif not operations and skipped: - status = "SKIPPED" - else: - status = "READY" + status = "REVIEW" if review_required else "SKIPPED" if not operations and skipped else "READY" reason_tuple = tuple(dict.fromkeys(reason_codes)) operation_tuple = tuple(operations) return AutopilotPlan( @@ -247,3 +135,15 @@ def evaluate_autopilot_plan(request: AutopilotPlanRequest) -> AutopilotPlan: reasonCodes=reason_tuple, planHash=_plan_hash(request, status, operation_tuple, reason_tuple), ) + + +__all__ = [ + "AutopilotPlan", + "AutopilotPlanRequest", + "CollisionPolicy", + "DestinationState", + "PlanEvaluationError", + "PlanOperation", + "PlanStep", + "evaluate_autopilot_plan", +] diff --git a/services/engine/src/databreeze_engine/registry.py b/services/engine/src/databreeze_engine/registry.py index 4b436f0e..c49dc001 100644 --- a/services/engine/src/databreeze_engine/registry.py +++ b/services/engine/src/databreeze_engine/registry.py @@ -10,6 +10,7 @@ from importlib.resources import files from types import MappingProxyType +from .folder_autopilot_contracts import AutopilotPlan, AutopilotPlanRequest from .handler import ActionHandler from .models import ( ActionManifest, @@ -20,6 +21,19 @@ SpreadsheetAuditProcessorResult, ) from .processors import handle_spreadsheet_auditor, metadata_digest +from .processors.folder_autopilot_action import ( + ACTION_TYPE as FOLDER_AUTOPILOT_ACTION_TYPE, +) +from .processors.folder_autopilot_action import ( + ACTION_VERSION as FOLDER_AUTOPILOT_ACTION_VERSION, +) +from .processors.folder_autopilot_action import ( + INPUT_SCHEMA_ID as FOLDER_AUTOPILOT_INPUT_SCHEMA_ID, +) +from .processors.folder_autopilot_action import ( + OUTPUT_SCHEMA_ID as FOLDER_AUTOPILOT_OUTPUT_SCHEMA_ID, +) +from .processors.folder_autopilot_action import handle as handle_folder_autopilot from .processors.spreadsheet_auditor_action import ( ACTION_TYPE as SPREADSHEET_AUDITOR_ACTION_TYPE, ) @@ -39,6 +53,9 @@ REVIEWED_SPREADSHEET_AUDITOR_HANDLER_DIGEST = ( "sha256:9f2f92194aa2e08e79afaeb791f67a481e35c183b2b359f83348eea67389b079" ) +REVIEWED_FOLDER_AUTOPILOT_HANDLER_DIGEST = ( + "sha256:9507d317afda56244aed2fd675333cd940c7e2fd180ddac20a7acff05239dbba" +) class RegistryError(Exception): @@ -103,6 +120,26 @@ def _verify_reviewed_spreadsheet_auditor_artifact(content: bytes | None = None) raise RegistryError("HANDLER_ARTIFACT_DIGEST_MISMATCH") +def _verify_reviewed_folder_autopilot_artifact(content: bytes | None = None) -> None: + artifact = content + if artifact is None: + try: + processors = files("databreeze_engine.processors") + artifact = b"\0".join( + processors.joinpath(name).read_bytes() + for name in ( + "folder_autopilot_action.py", + "folder_autopilot.py", + "folder_autopilot_plan.py", + ) + ) + except OSError: + raise RegistryError("HANDLER_ARTIFACT_UNAVAILABLE") from None + actual = "sha256:" + hashlib.sha256(artifact).hexdigest() + if actual != REVIEWED_FOLDER_AUTOPILOT_HANDLER_DIGEST: + raise RegistryError("HANDLER_ARTIFACT_DIGEST_MISMATCH") + + def _validate_action_boundary(action_type: str) -> None: action_boundary = action_type.replace("_", "-").split(".") prohibited_tokens = { @@ -188,11 +225,46 @@ def _reviewed_spreadsheet_auditor_definition() -> _ActionDefinition: return _ActionDefinition(manifest=manifest, handler=handle_spreadsheet_auditor) +def _reviewed_folder_autopilot_definition() -> _ActionDefinition: + _verify_reviewed_folder_autopilot_artifact() + manifest = ActionManifest( + actionType=FOLDER_AUTOPILOT_ACTION_TYPE, + actionVersion=FOLDER_AUTOPILOT_ACTION_VERSION, + handlerDigest=REVIEWED_FOLDER_AUTOPILOT_HANDLER_DIGEST, + engineVersion="0.1.0", + protocolVersion="1.0", + inputSchemaId=FOLDER_AUTOPILOT_INPUT_SCHEMA_ID, + outputSchemaId=FOLDER_AUTOPILOT_OUTPUT_SCHEMA_ID, + executionModes=("LOCAL",), + executionTargets=("DESKTOP",), + dataModes=("LOCAL",), + requiredCapabilities=("metadata.read",), + sideEffectClass="NONE", + riskClass="READ_ONLY", + determinism="DETERMINISTIC", + seedPolicy="NONE", + resources=ResourceLimits( + maxInputBytes=16 * 1024 * 1024, + maxOutputBytes=1024 * 1024, + maxMemoryBytes=64 * 1024 * 1024, + maxTemporaryStorageBytes=0, + maxDurationMilliseconds=5_000, + progressCadenceMilliseconds=500, + ), + networkPermitted=False, + filesystemWritesPermitted=False, + externalProvidersPermitted=False, + ) + return _ActionDefinition(manifest=manifest, handler=handle_folder_autopilot) + + def validate_action_parameters(manifest: ActionManifest, parameters: object) -> bool: if manifest.inputSchemaId == "foundation.metadata-fixture.v1": return isinstance(parameters, FoundationMetadataParameters) if manifest.inputSchemaId == SPREADSHEET_AUDITOR_INPUT_SCHEMA_ID: return isinstance(parameters, SpreadsheetAuditParameters) + if manifest.inputSchemaId == FOLDER_AUTOPILOT_INPUT_SCHEMA_ID: + return isinstance(parameters, AutopilotPlanRequest) return True @@ -201,6 +273,8 @@ def validate_action_output(manifest: ActionManifest, output: object) -> bool: return isinstance(output, FoundationDigestResult) if manifest.outputSchemaId == SPREADSHEET_AUDITOR_OUTPUT_SCHEMA_ID: return isinstance(output, SpreadsheetAuditProcessorResult) + if manifest.outputSchemaId == FOLDER_AUTOPILOT_OUTPUT_SCHEMA_ID: + return isinstance(output, AutopilotPlan) return True @@ -213,7 +287,11 @@ class ActionRegistry: _manifests: tuple[ActionManifest, ...] = field(init=False, repr=False) def __init__(self) -> None: - definitions = (_reviewed_definition(), _reviewed_spreadsheet_auditor_definition()) + definitions = ( + _reviewed_definition(), + _reviewed_spreadsheet_auditor_definition(), + _reviewed_folder_autopilot_definition(), + ) for definition in definitions: _validate_action_boundary(definition.manifest.actionType) actions = { diff --git a/services/engine/tests/test_folder_autopilot_action.py b/services/engine/tests/test_folder_autopilot_action.py new file mode 100644 index 00000000..0dc473a8 --- /dev/null +++ b/services/engine/tests/test_folder_autopilot_action.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from databreeze_engine.dispatcher import dispatch_execution +from databreeze_engine.models import EngineExecutionRequest +from databreeze_engine.registry import default_registry + + +def _payload(action: dict[str, str]) -> dict[str, Any]: + return { + "protocolVersion": "1.0", + "requestId": "00000000-0000-4000-8000-000000000101", + "attemptId": "00000000-0000-4000-8000-000000000102", + "correlation": {"correlationId": "00000000-0000-4000-8000-000000000103"}, + "action": action, + "inputHandles": [], + "outputHandle": { + "handleId": "output-folder-plan", + "byteLength": 1_048_576, + "sha256": "b" * 64, + "schemaId": "folder-autopilot.plan-result.v1", + }, + "parameters": { + "recipeVersionId": "recipe-001", + "assignmentId": "assignment-001", + "observation": { + "observationId": "obs-001", + "displayName": "invoice.csv", + "sizeBytes": 12, + "modifiedAtNs": 10, + "contentSha256": "a" * 64, + "stableExecutionKey": "c" * 64, + }, + "allowedOutputBindingIds": ["binding-out"], + "existingDestinations": [], + "steps": [ + { + "stepId": "inspect", + "action": "INSPECT", + "collisionPolicy": "REVIEW", + "requiresApproval": False, + } + ], + }, + "deadline": "2099-01-01T00:00:00Z", + "locale": "vi-VN", + } + + +def test_registry_exposes_content_free_folder_plan_action() -> None: + manifest = next( + manifest + for manifest in default_registry().manifests + if manifest.actionType == "folder-autopilot.plan-evaluate" + ) + assert manifest.inputSchemaId == "folder-autopilot.plan-request.v1" + assert manifest.outputSchemaId == "folder-autopilot.plan-result.v1" + assert manifest.filesystemWritesPermitted is False + assert manifest.networkPermitted is False + assert manifest.externalProvidersPermitted is False + + +def test_dispatch_evaluates_typed_folder_plan_without_input_bytes() -> None: + manifest = next( + manifest + for manifest in default_registry().manifests + if manifest.actionType == "folder-autopilot.plan-evaluate" + ) + request = EngineExecutionRequest.model_validate( + _payload( + { + "type": manifest.actionType, + "version": manifest.actionVersion, + "handlerDigest": manifest.handlerDigest, + } + ) + ) + + result = dispatch_execution( + request, + wall_clock=lambda: datetime(2026, 1, 1, tzinfo=UTC), + monotonic_clock=lambda: 1.0, + ) + + assert result.status == "SUCCEEDED" + assert result.output.status == "READY" + assert result.output.operations[0].action == "INSPECT" From c5494db20d5226dcf27c1b372dcf112bc2516c57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:14:33 +0700 Subject: [PATCH 07/62] test(fa): specify profile binding and assignment invariants --- .../domain/test/folder-autopilot-v1.test.mjs | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 packages/domain/test/folder-autopilot-v1.test.mjs diff --git a/packages/domain/test/folder-autopilot-v1.test.mjs b/packages/domain/test/folder-autopilot-v1.test.mjs new file mode 100644 index 00000000..8290a614 --- /dev/null +++ b/packages/domain/test/folder-autopilot-v1.test.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createAutopilotFolderBindingV1, + createFolderAutopilotProfileV1, + createRecipeAssignmentV1, + isFolderAutopilotDataModeNarrowingV1, +} from '../dist/folder-autopilot/v1.js'; + +const ids = { + organizationId: '11111111-1111-4111-8111-111111111111', + workspaceId: '22222222-2222-4222-8222-222222222222', + profileId: '33333333-3333-4333-8333-333333333333', + inputBindingId: '44444444-4444-4444-8444-444444444444', + outputBindingId: '55555555-5555-4555-8555-555555555555', + deviceGrantId: '66666666-6666-4666-8666-666666666666', + deviceId: '77777777-7777-4777-8777-777777777777', + recipeId: '88888888-8888-4888-8888-888888888888', + policyVersionId: '99999999-9999-4999-8999-999999999999', +}; + +const scope = { + scopeType: 'workspace', + organizationId: ids.organizationId, + workspaceId: ids.workspaceId, +}; + +const base = { + tenantScope: scope, + createdAt: '2026-08-04T00:00:00.000Z', +}; + +test('[FA-001..FA-007] profile and binding contracts contain no local path or DSO authority', () => { + const profile = createFolderAutopilotProfileV1({ + ...base, + profileId: ids.profileId, + version: 1, + payloadHash: 'a'.repeat(64), + stabilizationDelayMs: 1_000, + maxFilesPerScan: 100, + collisionPolicy: 'REVIEW', + undoWindowSeconds: 3_600, + outputLineageEnabled: true, + }); + assert.equal(profile.accepted, true); + if (!profile.accepted) return; + assert.equal(profile.value.version, 1); + assert.equal(Object.isFrozen(profile.value), true); + assert.equal('path' in profile.value, false); + assert.equal('status' in profile.value, false); + + const binding = createAutopilotFolderBindingV1({ + ...base, + bindingId: ids.inputBindingId, + deviceGrantId: ids.deviceGrantId, + role: 'INPUT', + expectedCapabilityDigest: 'b'.repeat(64), + }); + assert.equal(binding.accepted, true); + if (!binding.accepted) return; + assert.deepEqual(Object.keys(binding.value).sort(), [ + 'bindingId', + 'createdAt', + 'deviceGrantId', + 'expectedCapabilityDigest', + 'revision', + 'role', + 'schemaVersion', + 'tenantScope', + ]); + assert.equal('path' in binding.value, false); + assert.equal('revokedAt' in binding.value, false); +}); + +test('[FA-014..FA-015] assignment validates bindings, collision-safe settings, and immutable hashes', () => { + const assignment = createRecipeAssignmentV1({ + ...base, + assignmentId: ids.recipeId, + profileId: ids.profileId, + profileVersion: 1, + profileHash: 'a'.repeat(64), + jraRecipeVersionId: ids.recipeId, + jraRecipeVersionHash: 'c'.repeat(64), + deviceId: ids.deviceId, + inputBindingIds: [ids.inputBindingId], + outputBindingIds: [ids.outputBindingId], + dataModeConstraint: 'LOCAL', + effectiveDataModePolicyRef: ids.policyVersionId, + idempotencyKey: 'assignment-create-1', + }); + assert.equal(assignment.accepted, true); + if (!assignment.accepted) return; + assert.equal(assignment.value.state, 'DRAFT'); + assert.equal(assignment.value.revision, 1); + assert.equal(Object.isFrozen(assignment.value.inputBindingIds), true); + + const invalidCollision = createFolderAutopilotProfileV1({ + ...base, + profileId: ids.profileId, + version: 2, + payloadHash: 'd'.repeat(64), + stabilizationDelayMs: 1_000, + maxFilesPerScan: 100, + collisionPolicy: 'OVERWRITE', + undoWindowSeconds: 3_600, + outputLineageEnabled: true, + }); + assert.deepEqual(invalidCollision, { accepted: false, code: 'INVALID_COLLISION_POLICY' }); +}); + +test('[FA-031] assignment data mode constraints can only narrow the DSO maximum', () => { + assert.equal(isFolderAutopilotDataModeNarrowingV1('CLOUD', 'HYBRID'), true); + assert.equal(isFolderAutopilotDataModeNarrowingV1('HYBRID', 'LOCAL'), true); + assert.equal(isFolderAutopilotDataModeNarrowingV1('LOCAL', 'HYBRID'), false); +}); From deda24ad6671eaeb52941ee14e8be5f5ec8834d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:16:21 +0700 Subject: [PATCH 08/62] feat(domain): add folder autopilot contracts --- packages/domain/package.json | 4 + packages/domain/src/folder-autopilot/v1.ts | 328 +++++++++++++++++++++ packages/domain/src/v1.ts | 1 + 3 files changed, 333 insertions(+) create mode 100644 packages/domain/src/folder-autopilot/v1.ts diff --git a/packages/domain/package.json b/packages/domain/package.json index 0c7930ab..403fa15e 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -64,6 +64,10 @@ "types": "./src/data-mode/v1.ts", "import": "./dist/data-mode/v1.js" }, + "./folder-autopilot/v1": { + "types": "./src/folder-autopilot/v1.ts", + "import": "./dist/folder-autopilot/v1.js" + }, "./pkce/v1": { "types": "./src/pkce/v1.ts", "import": "./dist/pkce/v1.js" diff --git a/packages/domain/src/folder-autopilot/v1.ts b/packages/domain/src/folder-autopilot/v1.ts new file mode 100644 index 00000000..d2e9eada --- /dev/null +++ b/packages/domain/src/folder-autopilot/v1.ts @@ -0,0 +1,328 @@ +import { + parseStableIdentifierV1, + parseStrictUtcTimestampV1, + parseTenantScopeV1, + type StableIdentifierV1, + type StrictUtcTimestampV1, + type TenantScopeV1, +} from '../tenant-scope/v1.js'; +import type { DataModeV1 } from '../data-mode/v1.js'; + +/** FA-001..FA-007, FA-014, FA-015 and FA-031: content-free automation records. */ +export const FOLDER_AUTOPILOT_SCHEMA_VERSION_V1 = 1 as const; + +export type AutopilotFolderBindingRoleV1 = 'INPUT' | 'OUTPUT'; +export type FolderAutopilotCollisionPolicyV1 = 'REVIEW' | 'SKIP' | 'UNIQUE_NAME'; +export type RecipeAssignmentStateV1 = 'DRAFT' | 'ACTIVE' | 'PAUSED' | 'RETIRED'; + +export interface FolderAutopilotProfileV1 { + readonly schemaVersion: typeof FOLDER_AUTOPILOT_SCHEMA_VERSION_V1; + readonly profileId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly version: number; + /** SHA-256 of the canonical typed profile payload. */ + readonly payloadHash: string; + readonly stabilizationDelayMs: number; + readonly maxFilesPerScan: number; + readonly collisionPolicy: FolderAutopilotCollisionPolicyV1; + readonly undoWindowSeconds: number; + readonly outputLineageEnabled: boolean; + readonly createdAt: StrictUtcTimestampV1; + readonly revision: 1; +} + +/** A binding is deliberately only an opaque DSO reference plus a digest. */ +export interface AutopilotFolderBindingV1 { + readonly schemaVersion: typeof FOLDER_AUTOPILOT_SCHEMA_VERSION_V1; + readonly bindingId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly deviceGrantId: StableIdentifierV1; + readonly role: AutopilotFolderBindingRoleV1; + readonly expectedCapabilityDigest: string; + readonly createdAt: StrictUtcTimestampV1; + readonly revision: 1; +} + +/** + * Assignment state is a feature projection. JRA remains authoritative for the + * recipe/version and DSO remains authoritative for grant status and revocation. + */ +export interface RecipeAssignmentV1 { + readonly schemaVersion: typeof FOLDER_AUTOPILOT_SCHEMA_VERSION_V1; + readonly assignmentId: StableIdentifierV1; + readonly tenantScope: TenantScopeV1; + readonly profileId: StableIdentifierV1; + readonly profileVersion: number; + readonly profileHash: string; + readonly jraRecipeVersionId: StableIdentifierV1; + readonly jraRecipeVersionHash: string; + readonly deviceId: StableIdentifierV1; + readonly inputBindingIds: readonly StableIdentifierV1[]; + readonly outputBindingIds: readonly StableIdentifierV1[]; + readonly dataModeConstraint?: DataModeV1; + readonly effectiveDataModePolicyRef?: StableIdentifierV1; + readonly idempotencyKey: string; + readonly state: RecipeAssignmentStateV1; + readonly revision: number; + readonly createdAt: StrictUtcTimestampV1; +} + +export type FolderAutopilotErrorCodeV1 = + | 'INVALID_IDENTIFIER' + | 'INVALID_SCOPE' + | 'INVALID_HASH' + | 'INVALID_TIMESTAMP' + | 'INVALID_VERSION' + | 'INVALID_REVISION' + | 'INVALID_ROLE' + | 'INVALID_COLLISION_POLICY' + | 'INVALID_SETTINGS' + | 'INVALID_BINDINGS' + | 'INVALID_DATA_MODE' + | 'INVALID_POLICY_REFERENCE' + | 'INVALID_IDEMPOTENCY_KEY' + | 'INVALID_STATE'; + +export type FolderAutopilotResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: FolderAutopilotErrorCodeV1 }; + +function rejected(code: FolderAutopilotErrorCodeV1): FolderAutopilotResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function stable(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function scope(input: unknown): TenantScopeV1 | undefined { + const parsed = parseTenantScopeV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function timestamp(input: unknown): StrictUtcTimestampV1 | undefined { + const parsed = parseStrictUtcTimestampV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +function hash(input: unknown): string | undefined { + return typeof input === 'string' && /^[0-9a-f]{64}$/u.test(input) ? input : undefined; +} + +function boundedInteger(input: unknown, minimum: number, maximum: number): number | undefined { + return typeof input === 'number' && Number.isSafeInteger(input) && input >= minimum && input <= maximum + ? input + : undefined; +} + +function text(input: unknown, maximum: number): string | undefined { + if (typeof input !== 'string' || input.length === 0 || input.length > maximum) return undefined; + if (/\p{Cc}/u.test(input)) return undefined; + const normalized = input.normalize('NFC').trim(); + return normalized.length > 0 && normalized.length <= maximum ? normalized : undefined; +} + +function identifiers(input: unknown): readonly StableIdentifierV1[] | undefined { + if (!Array.isArray(input) || input.length < 1 || input.length > 32) return undefined; + const values = input.map(stable); + if (values.some((value) => value === undefined)) return undefined; + const result = values as StableIdentifierV1[]; + if (new Set(result).size !== result.length) return undefined; + return Object.freeze([...result]); +} + +function dataMode(input: unknown): DataModeV1 | undefined { + return input === 'LOCAL' || input === 'HYBRID' || input === 'CLOUD' + ? (input as DataModeV1) + : undefined; +} + +function revision(input: unknown, defaultValue = 1): number | undefined { + return input === undefined + ? defaultValue + : boundedInteger(input, 1, Number.MAX_SAFE_INTEGER); +} + +function freezeScope(value: TenantScopeV1): TenantScopeV1 { + return Object.freeze({ ...value }); +} + +export function createFolderAutopilotProfileV1(input: { + readonly profileId: unknown; + readonly tenantScope: unknown; + readonly version: unknown; + readonly payloadHash: unknown; + readonly stabilizationDelayMs: unknown; + readonly maxFilesPerScan: unknown; + readonly collisionPolicy: unknown; + readonly undoWindowSeconds: unknown; + readonly outputLineageEnabled: unknown; + readonly createdAt: unknown; +}): FolderAutopilotResultV1 { + const profileId = stable(input.profileId); + const tenantScope = scope(input.tenantScope); + const version = boundedInteger(input.version, 1, 10_000); + const payloadHash = hash(input.payloadHash); + const stabilizationDelayMs = boundedInteger(input.stabilizationDelayMs, 0, 86_400_000); + const maxFilesPerScan = boundedInteger(input.maxFilesPerScan, 1, 100_000); + const undoWindowSeconds = boundedInteger(input.undoWindowSeconds, 0, 604_800); + const createdAt = timestamp(input.createdAt); + if (!profileId) return rejected('INVALID_IDENTIFIER'); + if (!tenantScope) return rejected('INVALID_SCOPE'); + if (version === undefined) return rejected('INVALID_VERSION'); + if (!payloadHash) return rejected('INVALID_HASH'); + if ( + stabilizationDelayMs === undefined || + maxFilesPerScan === undefined || + undoWindowSeconds === undefined || + typeof input.outputLineageEnabled !== 'boolean' + ) + return rejected('INVALID_SETTINGS'); + if ( + input.collisionPolicy !== 'REVIEW' && + input.collisionPolicy !== 'SKIP' && + input.collisionPolicy !== 'UNIQUE_NAME' + ) + return rejected('INVALID_COLLISION_POLICY'); + if (!createdAt) return rejected('INVALID_TIMESTAMP'); + return Object.freeze({ + accepted: true, + value: Object.freeze({ + schemaVersion: FOLDER_AUTOPILOT_SCHEMA_VERSION_V1, + profileId, + tenantScope: freezeScope(tenantScope), + version, + payloadHash, + stabilizationDelayMs, + maxFilesPerScan, + collisionPolicy: input.collisionPolicy as FolderAutopilotCollisionPolicyV1, + undoWindowSeconds, + outputLineageEnabled: input.outputLineageEnabled, + createdAt, + revision: 1 as const, + }), + }); +} + +export function createAutopilotFolderBindingV1(input: { + readonly bindingId: unknown; + readonly tenantScope: unknown; + readonly deviceGrantId: unknown; + readonly role: unknown; + readonly expectedCapabilityDigest: unknown; + readonly createdAt: unknown; +}): FolderAutopilotResultV1 { + const bindingId = stable(input.bindingId); + const tenantScope = scope(input.tenantScope); + const deviceGrantId = stable(input.deviceGrantId); + const expectedCapabilityDigest = hash(input.expectedCapabilityDigest); + const createdAt = timestamp(input.createdAt); + if (!bindingId || !deviceGrantId) return rejected('INVALID_IDENTIFIER'); + if (!tenantScope) return rejected('INVALID_SCOPE'); + if (input.role !== 'INPUT' && input.role !== 'OUTPUT') return rejected('INVALID_ROLE'); + if (!expectedCapabilityDigest) return rejected('INVALID_HASH'); + if (!createdAt) return rejected('INVALID_TIMESTAMP'); + return Object.freeze({ + accepted: true, + value: Object.freeze({ + schemaVersion: FOLDER_AUTOPILOT_SCHEMA_VERSION_V1, + bindingId, + tenantScope: freezeScope(tenantScope), + deviceGrantId, + role: input.role as AutopilotFolderBindingRoleV1, + expectedCapabilityDigest, + createdAt, + revision: 1 as const, + }), + }); +} + +export function createRecipeAssignmentV1(input: { + readonly assignmentId: unknown; + readonly tenantScope: unknown; + readonly profileId: unknown; + readonly profileVersion: unknown; + readonly profileHash: unknown; + readonly jraRecipeVersionId: unknown; + readonly jraRecipeVersionHash: unknown; + readonly deviceId: unknown; + readonly inputBindingIds: unknown; + readonly outputBindingIds: unknown; + readonly dataModeConstraint?: unknown; + readonly effectiveDataModePolicyRef?: unknown; + readonly idempotencyKey: unknown; + readonly state?: unknown; + readonly revision?: unknown; + readonly createdAt: unknown; +}): FolderAutopilotResultV1 { + const assignmentId = stable(input.assignmentId); + const tenantScope = scope(input.tenantScope); + const profileId = stable(input.profileId); + const profileVersion = boundedInteger(input.profileVersion, 1, 10_000); + const profileHash = hash(input.profileHash); + const jraRecipeVersionId = stable(input.jraRecipeVersionId); + const jraRecipeVersionHash = hash(input.jraRecipeVersionHash); + const deviceId = stable(input.deviceId); + const inputBindingIds = identifiers(input.inputBindingIds); + const outputBindingIds = identifiers(input.outputBindingIds); + const constraint = input.dataModeConstraint === undefined ? undefined : dataMode(input.dataModeConstraint); + const effectiveDataModePolicyRef = + input.effectiveDataModePolicyRef === undefined + ? undefined + : stable(input.effectiveDataModePolicyRef); + const idempotencyKey = text(input.idempotencyKey, 200); + const assignmentRevision = revision(input.revision); + const createdAt = timestamp(input.createdAt); + if (!assignmentId || !profileId || !jraRecipeVersionId || !deviceId) + return rejected('INVALID_IDENTIFIER'); + if (!tenantScope) return rejected('INVALID_SCOPE'); + if (profileVersion === undefined) return rejected('INVALID_VERSION'); + if (!profileHash || !jraRecipeVersionHash) return rejected('INVALID_HASH'); + if (!inputBindingIds || !outputBindingIds) return rejected('INVALID_BINDINGS'); + if (inputBindingIds.some((id) => outputBindingIds.includes(id))) + return rejected('INVALID_BINDINGS'); + if (input.dataModeConstraint !== undefined && !constraint) return rejected('INVALID_DATA_MODE'); + if (input.effectiveDataModePolicyRef !== undefined && !effectiveDataModePolicyRef) + return rejected('INVALID_POLICY_REFERENCE'); + if (constraint === undefined && effectiveDataModePolicyRef !== undefined) + return rejected('INVALID_POLICY_REFERENCE'); + if (!idempotencyKey) return rejected('INVALID_IDEMPOTENCY_KEY'); + if (assignmentRevision === undefined) return rejected('INVALID_REVISION'); + if (!createdAt) return rejected('INVALID_TIMESTAMP'); + const state = input.state ?? 'DRAFT'; + if (state !== 'DRAFT' && state !== 'ACTIVE' && state !== 'PAUSED' && state !== 'RETIRED') + return rejected('INVALID_STATE'); + return Object.freeze({ + accepted: true, + value: Object.freeze({ + schemaVersion: FOLDER_AUTOPILOT_SCHEMA_VERSION_V1, + assignmentId, + tenantScope: freezeScope(tenantScope), + profileId, + profileVersion, + profileHash, + jraRecipeVersionId, + jraRecipeVersionHash, + deviceId, + inputBindingIds, + outputBindingIds, + ...(constraint === undefined ? {} : { dataModeConstraint: constraint }), + ...(effectiveDataModePolicyRef === undefined ? {} : { effectiveDataModePolicyRef }), + idempotencyKey, + state: state as RecipeAssignmentStateV1, + revision: assignmentRevision, + createdAt, + }), + }); +} + +/** Returns true only when a requested assignment mode is no broader than DSO's maximum. */ +export function isFolderAutopilotDataModeNarrowingV1( + maximum: DataModeV1, + requested: DataModeV1, +): boolean { + const rank = (mode: DataModeV1): number => (mode === 'LOCAL' ? 0 : mode === 'HYBRID' ? 1 : 2); + return rank(requested) <= rank(maximum); +} + diff --git a/packages/domain/src/v1.ts b/packages/domain/src/v1.ts index c3d755f8..8f701d2e 100644 --- a/packages/domain/src/v1.ts +++ b/packages/domain/src/v1.ts @@ -34,6 +34,7 @@ export * from './device-authorization/v1.js'; export * from './device-sync/v1.js'; export * from './device-capability/v1.js'; export * from './data-mode/v1.js'; +export * from './folder-autopilot/v1.js'; export * from './pkce/v1.js'; export * from './csrf/v1.js'; export * from './permissions/v1.js'; From b448a51c276cb4ba1c117db2f5c41138318abc27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:16:21 +0700 Subject: [PATCH 09/62] test(domain): register folder autopilot public entry point --- packages/domain/test/built-public-api-smoke.mjs | 5 ++++- packages/domain/test/public-api-v1.test.mjs | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/domain/test/built-public-api-smoke.mjs b/packages/domain/test/built-public-api-smoke.mjs index 007640de..a22250e2 100644 --- a/packages/domain/test/built-public-api-smoke.mjs +++ b/packages/domain/test/built-public-api-smoke.mjs @@ -20,6 +20,7 @@ const [ datasetExport, spreadsheetAudit, dataMode, + folderAutopilot, jobs, approval, executionAttempt, @@ -52,6 +53,7 @@ const [ import('@databreeze/domain/dataset-export/v1'), import('@databreeze/domain/spreadsheet-audit/v1'), import('@databreeze/domain/data-mode/v1'), + import('@databreeze/domain/folder-autopilot/v1'), import('@databreeze/domain/jobs/v1'), import('@databreeze/domain/approval/v1'), import('@databreeze/domain/execution-attempt/v1'), @@ -86,7 +88,8 @@ assert.equal(datasetQuality.DATASET_QUALITY_SCHEMA_VERSION_V1, 1); assert.equal(datasetProfile.DATASET_PROFILE_SCHEMA_VERSION_V1, 1); assert.equal(datasetExport.DATASET_EXPORT_SCHEMA_VERSION_V1, 1); assert.equal(spreadsheetAudit.SPREADSHEET_AUDIT_SCHEMA_VERSION_V1, 1); -assert.equal(dataMode.DATA_MODE_POLICY_SCHEMA_VERSION_V1, 1); + assert.equal(dataMode.DATA_MODE_POLICY_SCHEMA_VERSION_V1, 1); +assert.equal(folderAutopilot.FOLDER_AUTOPILOT_SCHEMA_VERSION_V1, 1); assert.equal(jobs.JOB_SCHEMA_VERSION_V1, 1); assert.equal(approval.APPROVAL_SCHEMA_VERSION_V1, 1); assert.equal(executionAttempt.EXECUTION_ATTEMPT_SCHEMA_VERSION_V1, 1); diff --git a/packages/domain/test/public-api-v1.test.mjs b/packages/domain/test/public-api-v1.test.mjs index 1417773d..6f507d09 100644 --- a/packages/domain/test/public-api-v1.test.mjs +++ b/packages/domain/test/public-api-v1.test.mjs @@ -24,6 +24,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o './device-sync/v1', './device-capability/v1', './data-mode/v1', + './folder-autopilot/v1', './pkce/v1', './csrf/v1', './artifact/v1', @@ -80,6 +81,7 @@ test('[IAM-001, IAM-002, IAM-003, IAM-004, IAM-009, IAM-019 partial] publishes o assert.equal(aggregate.DATASET_QUALITY_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DATASET_PROFILE_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.DATASET_EXPORT_SCHEMA_VERSION_V1, 1); + assert.equal(aggregate.FOLDER_AUTOPILOT_SCHEMA_VERSION_V1, 1); assert.equal(aggregate.SPREADSHEET_AUDIT_SCHEMA_VERSION_V1, 1); assert.equal(typeof aggregate.parseTenantScopeV1, 'function'); assert.equal(aggregate.ARTIFACT_UPLOAD_SCHEMA_VERSION_V1, 1); From eb6cffd8e305aec1af79d662982ea16da8c317af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:16:56 +0700 Subject: [PATCH 10/62] test(fa): specify tenant-scoped service behavior --- .../fa/folder-autopilot.service.test.ts | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 services/api/test/features/fa/folder-autopilot.service.test.ts diff --git a/services/api/test/features/fa/folder-autopilot.service.test.ts b/services/api/test/features/fa/folder-autopilot.service.test.ts new file mode 100644 index 00000000..4a29896d --- /dev/null +++ b/services/api/test/features/fa/folder-autopilot.service.test.ts @@ -0,0 +1,124 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { InMemoryFolderAutopilotRepositoryAdapter } from '../../../src/features/fa/adapter/in-memory-folder-autopilot-repository.adapter.js'; +import { + FolderAutopilotService, + type FolderAutopilotDataModePolicyPortV1, +} from '../../../src/features/fa/application/folder-autopilot.service.js'; + +const ids = { + organizationId: '11111111-1111-4111-8111-111111111111', + workspaceId: '22222222-2222-4222-8222-222222222222', + profileId: '33333333-3333-4333-8333-333333333333', + inputBindingId: '44444444-4444-4444-8444-444444444444', + outputBindingId: '55555555-5555-4555-8555-555555555555', + deviceGrantId: '66666666-6666-4666-8666-666666666666', + deviceId: '77777777-7777-4777-8777-777777777777', + recipeId: '88888888-8888-4888-8888-888888888888', + policyVersionId: '99999999-9999-4999-8999-999999999999', +}; + +function context(scope = { scopeType: 'workspace' as const, organizationId: ids.organizationId, workspaceId: ids.workspaceId }, idempotencyKey = 'fa-service') { + const result = createIamTenantContextV1({ + actorId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + correlationId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + tenantScope: scope, + authorizationEpoch: 1, + idempotencyKey, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context fixture'); + return result.value; +} + +const policy: FolderAutopilotDataModePolicyPortV1 = { + resolveNarrowed: async (_context, requested) => + requested === 'LOCAL' + ? { accepted: true, value: { effectiveDataModePolicyRef: ids.policyVersionId } } + : { accepted: false, code: 'DATA_MODE_BROADENS_WORKSPACE' }, +}; + +const profileInput = { + profileId: ids.profileId, + version: 1, + payloadHash: 'a'.repeat(64), + stabilizationDelayMs: 1_000, + maxFilesPerScan: 100, + collisionPolicy: 'REVIEW' as const, + undoWindowSeconds: 3_600, + outputLineageEnabled: true, + createdAt: '2026-08-04T00:00:00.000Z', +}; + +const bindingInput = (bindingId: string, role: 'INPUT' | 'OUTPUT') => ({ + bindingId, + deviceGrantId: ids.deviceGrantId, + role, + expectedCapabilityDigest: 'b'.repeat(64), + createdAt: '2026-08-04T00:00:00.000Z', +}); + +const assignmentInput = { + assignmentId: ids.recipeId, + profileId: ids.profileId, + profileVersion: 1, + profileHash: 'a'.repeat(64), + jraRecipeVersionId: ids.recipeId, + jraRecipeVersionHash: 'c'.repeat(64), + deviceId: ids.deviceId, + inputBindingIds: [ids.inputBindingId], + outputBindingIds: [ids.outputBindingId], + dataModeConstraint: 'LOCAL' as const, + createdAt: '2026-08-04T00:00:00.000Z', +}; + +void test('[FA-001..FA-007] service stores profile and binding idempotently without local path data', async () => { + const service = new FolderAutopilotService(new InMemoryFolderAutopilotRepositoryAdapter(), policy); + const tenant = context(); + const profile = await service.createProfile(tenant, profileInput); + assert.equal(profile.accepted, true); + const duplicate = await service.createProfile(tenant, profileInput); + assert.deepEqual(duplicate, profile); + const binding = await service.createBinding(tenant, bindingInput(ids.inputBindingId, 'INPUT')); + assert.equal(binding.accepted, true); + if (binding.accepted) { + assert.equal('path' in binding.value, false); + assert.equal('status' in binding.value, false); + } +}); + +void test('[FA-014, FA-015, FA-031] assignment validates owned references and rejects a broader mode', async () => { + const service = new FolderAutopilotService(new InMemoryFolderAutopilotRepositoryAdapter(), policy); + const tenant = context(); + await service.createProfile(tenant, profileInput); + await service.createBinding(tenant, bindingInput(ids.inputBindingId, 'INPUT')); + await service.createBinding(tenant, bindingInput(ids.outputBindingId, 'OUTPUT')); + const assignment = await service.createAssignment(tenant, assignmentInput); + assert.equal(assignment.accepted, true); + if (assignment.accepted) + assert.equal(assignment.value.effectiveDataModePolicyRef, ids.policyVersionId); + + const broader = await service.createAssignment(tenant, { + ...assignmentInput, + assignmentId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + dataModeConstraint: 'HYBRID', + }); + assert.deepEqual(broader, { accepted: false, code: 'DATA_MODE_BROADENS_WORKSPACE' }); +}); + +void test('[IAM-019, FA-003] sibling tenant cannot read a profile or assignment', async () => { + const repository = new InMemoryFolderAutopilotRepositoryAdapter(); + const service = new FolderAutopilotService(repository, policy); + const tenant = context(); + await service.createProfile(tenant, profileInput); + const sibling = context( + { scopeType: 'workspace', organizationId: ids.organizationId, workspaceId: 'ffffffff-ffff-4fff-8fff-ffffffffffff' }, + 'fa-sibling', + ); + assert.deepEqual(await service.findProfile(sibling, ids.profileId), { + accepted: false, + code: 'FA_PROFILE_NOT_FOUND', + }); +}); From 2976f9afe71e91b5a2375d8cd58b3182d738c464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:21:34 +0700 Subject: [PATCH 11/62] feat(fa): add tenant-scoped profile binding assignment service --- ...ory-folder-autopilot-repository.adapter.ts | 259 +++++++++++++++ .../folder-autopilot-repository.port.ts | 52 +++ .../application/folder-autopilot.service.ts | 299 ++++++++++++++++++ .../fa/folder-autopilot.service.test.ts | 1 + 4 files changed, 611 insertions(+) create mode 100644 services/api/src/features/fa/adapter/in-memory-folder-autopilot-repository.adapter.ts create mode 100644 services/api/src/features/fa/application/folder-autopilot-repository.port.ts create mode 100644 services/api/src/features/fa/application/folder-autopilot.service.ts diff --git a/services/api/src/features/fa/adapter/in-memory-folder-autopilot-repository.adapter.ts b/services/api/src/features/fa/adapter/in-memory-folder-autopilot-repository.adapter.ts new file mode 100644 index 00000000..df5a953a --- /dev/null +++ b/services/api/src/features/fa/adapter/in-memory-folder-autopilot-repository.adapter.ts @@ -0,0 +1,259 @@ +import { + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; +import type { + AutopilotFolderBindingV1, + FolderAutopilotProfileV1, + RecipeAssignmentStateV1, + RecipeAssignmentV1, +} from '@databreeze/domain/folder-autopilot/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + FolderAutopilotRepositoryPortV1, + FolderAutopilotTransactionPortV1, +} from '../application/folder-autopilot-repository.port.js'; + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +function cloneProfile(profile: FolderAutopilotProfileV1): FolderAutopilotProfileV1 { + return Object.freeze({ + ...profile, + tenantScope: Object.freeze({ ...profile.tenantScope }), + }); +} + +function cloneBinding(binding: AutopilotFolderBindingV1): AutopilotFolderBindingV1 { + return Object.freeze({ + ...binding, + tenantScope: Object.freeze({ ...binding.tenantScope }), + }); +} + +function cloneAssignment(assignment: RecipeAssignmentV1): RecipeAssignmentV1 { + return Object.freeze({ + ...assignment, + tenantScope: Object.freeze({ ...assignment.tenantScope }), + inputBindingIds: Object.freeze([...assignment.inputBindingIds]), + outputBindingIds: Object.freeze([...assignment.outputBindingIds]), + }); +} + +function profileKey(profile: Pick): string { + return `${profile.profileId}:${profile.version}`; +} + +/** Test/local adapter. Durable deployments use the Prisma adapter with the same port. */ +export class InMemoryFolderAutopilotRepositoryAdapter implements FolderAutopilotRepositoryPortV1 { + private profiles = new Map(); + private bindings = new Map(); + private assignments = new Map(); + private transactionTail: Promise = Promise.resolve(); + + private async saveProfileUnlocked( + context: IamTenantContextV1, + profile: FolderAutopilotProfileV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, profile.tenantScope)) + throw new Error('FA_SCOPE_NARROWING_REQUIRED'); + const key = profileKey(profile); + const existing = this.profiles.get(key); + if (existing && JSON.stringify(existing) !== JSON.stringify(profile)) + throw new Error('FA_IMMUTABLE_PROFILE'); + this.profiles.set(key, cloneProfile(profile)); + } + + private async findProfileUnlocked( + context: IamTenantContextV1, + profileId: StableIdentifierV1, + version?: number, + ): Promise { + const values = [...this.profiles.values()].filter( + (profile) => + profile.profileId === profileId && + (version === undefined || profile.version === version) && + visible(context.tenantScope, profile.tenantScope), + ); + values.sort((left, right) => right.version - left.version); + return values[0] ? cloneProfile(values[0]) : undefined; + } + + private async listProfilesUnlocked( + context: IamTenantContextV1, + ): Promise { + return [...this.profiles.values()] + .filter((profile) => visible(context.tenantScope, profile.tenantScope)) + .sort((left, right) => + `${left.profileId}:${left.version}`.localeCompare(`${right.profileId}:${right.version}`), + ) + .map(cloneProfile); + } + + private async saveBindingUnlocked( + context: IamTenantContextV1, + binding: AutopilotFolderBindingV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, binding.tenantScope)) + throw new Error('FA_SCOPE_NARROWING_REQUIRED'); + const existing = this.bindings.get(binding.bindingId); + if (existing && JSON.stringify(existing) !== JSON.stringify(binding)) + throw new Error('FA_IMMUTABLE_BINDING'); + this.bindings.set(binding.bindingId, cloneBinding(binding)); + } + + private async findBindingUnlocked( + context: IamTenantContextV1, + bindingId: StableIdentifierV1, + ): Promise { + const binding = this.bindings.get(bindingId); + return binding && visible(context.tenantScope, binding.tenantScope) + ? cloneBinding(binding) + : undefined; + } + + private async listBindingsUnlocked( + context: IamTenantContextV1, + ): Promise { + return [...this.bindings.values()] + .filter((binding) => visible(context.tenantScope, binding.tenantScope)) + .sort((left, right) => left.bindingId.localeCompare(right.bindingId)) + .map(cloneBinding); + } + + private async saveAssignmentUnlocked( + context: IamTenantContextV1, + assignment: RecipeAssignmentV1, + ): Promise { + if (!tenantScopeContainsV1(context.tenantScope, assignment.tenantScope)) + throw new Error('FA_SCOPE_NARROWING_REQUIRED'); + const existing = this.assignments.get(assignment.assignmentId); + if (existing && JSON.stringify(existing) !== JSON.stringify(assignment)) + throw new Error('FA_IMMUTABLE_ASSIGNMENT'); + this.assignments.set(assignment.assignmentId, cloneAssignment(assignment)); + } + + private async findAssignmentUnlocked( + context: IamTenantContextV1, + assignmentId: StableIdentifierV1, + ): Promise { + const assignment = this.assignments.get(assignmentId); + return assignment && visible(context.tenantScope, assignment.tenantScope) + ? cloneAssignment(assignment) + : undefined; + } + + private async listAssignmentsUnlocked( + context: IamTenantContextV1, + ): Promise { + return [...this.assignments.values()] + .filter((assignment) => visible(context.tenantScope, assignment.tenantScope)) + .sort((left, right) => left.assignmentId.localeCompare(right.assignmentId)) + .map(cloneAssignment); + } + + private async updateAssignmentStateUnlocked( + context: IamTenantContextV1, + assignmentId: StableIdentifierV1, + expectedRevision: number, + state: RecipeAssignmentStateV1, + ): Promise { + const existing = this.assignments.get(assignmentId); + if (!existing || !visible(context.tenantScope, existing.tenantScope)) + throw new Error('FA_ASSIGNMENT_NOT_FOUND'); + if (!tenantScopeContainsV1(context.tenantScope, existing.tenantScope)) + throw new Error('FA_SCOPE_NARROWING_REQUIRED'); + if (existing.revision !== expectedRevision) throw new Error('FA_ASSIGNMENT_REVISION_CONFLICT'); + const next = cloneAssignment({ ...existing, state, revision: existing.revision + 1 }); + this.assignments.set(assignmentId, next); + return cloneAssignment(next); + } + + public async saveProfile(context: IamTenantContextV1, profile: FolderAutopilotProfileV1): Promise { + await this.withTransaction(context, (transaction) => transaction.saveProfile(context, profile)); + } + + public findProfile(context: IamTenantContextV1, profileId: StableIdentifierV1, version?: number) { + return this.findProfileUnlocked(context, profileId, version); + } + + public listProfiles(context: IamTenantContextV1) { + return this.listProfilesUnlocked(context); + } + + public async saveBinding(context: IamTenantContextV1, binding: AutopilotFolderBindingV1): Promise { + await this.withTransaction(context, (transaction) => transaction.saveBinding(context, binding)); + } + + public findBinding(context: IamTenantContextV1, bindingId: StableIdentifierV1) { + return this.findBindingUnlocked(context, bindingId); + } + + public listBindings(context: IamTenantContextV1) { + return this.listBindingsUnlocked(context); + } + + public async saveAssignment(context: IamTenantContextV1, assignment: RecipeAssignmentV1): Promise { + await this.withTransaction(context, (transaction) => transaction.saveAssignment(context, assignment)); + } + + public findAssignment(context: IamTenantContextV1, assignmentId: StableIdentifierV1) { + return this.findAssignmentUnlocked(context, assignmentId); + } + + public listAssignments(context: IamTenantContextV1) { + return this.listAssignmentsUnlocked(context); + } + + public updateAssignmentState( + context: IamTenantContextV1, + assignmentId: StableIdentifierV1, + expectedRevision: number, + state: RecipeAssignmentStateV1, + ) { + return this.withTransaction(context, (transaction) => + transaction.updateAssignmentState(context, assignmentId, expectedRevision, state), + ); + } + + public async withTransaction( + context: IamTenantContextV1, + work: (transaction: FolderAutopilotTransactionPortV1) => Promise, + ): Promise { + let release!: () => void; + const previous = this.transactionTail; + this.transactionTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + const before = { + profiles: new Map(this.profiles), + bindings: new Map(this.bindings), + assignments: new Map(this.assignments), + }; + try { + return await work({ + saveProfile: this.saveProfileUnlocked.bind(this), + findProfile: this.findProfileUnlocked.bind(this), + listProfiles: this.listProfilesUnlocked.bind(this), + saveBinding: this.saveBindingUnlocked.bind(this), + findBinding: this.findBindingUnlocked.bind(this), + listBindings: this.listBindingsUnlocked.bind(this), + saveAssignment: this.saveAssignmentUnlocked.bind(this), + findAssignment: this.findAssignmentUnlocked.bind(this), + listAssignments: this.listAssignmentsUnlocked.bind(this), + updateAssignmentState: this.updateAssignmentStateUnlocked.bind(this), + }); + } catch (error) { + this.profiles = before.profiles; + this.bindings = before.bindings; + this.assignments = before.assignments; + throw error; + } finally { + release(); + } + } +} diff --git a/services/api/src/features/fa/application/folder-autopilot-repository.port.ts b/services/api/src/features/fa/application/folder-autopilot-repository.port.ts new file mode 100644 index 00000000..e6118620 --- /dev/null +++ b/services/api/src/features/fa/application/folder-autopilot-repository.port.ts @@ -0,0 +1,52 @@ +import type { + AutopilotFolderBindingV1, + FolderAutopilotProfileV1, + RecipeAssignmentStateV1, + RecipeAssignmentV1, +} from '@databreeze/domain/folder-autopilot/v1'; +import type { StableIdentifierV1 } from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; + +export const FOLDER_AUTOPILOT_REPOSITORY_PORT = Symbol('FOLDER_AUTOPILOT_REPOSITORY_PORT'); + +export interface FolderAutopilotTransactionPortV1 { + saveProfile(context: IamTenantContextV1, profile: FolderAutopilotProfileV1): Promise; + findProfile( + context: IamTenantContextV1, + profileId: StableIdentifierV1, + version?: number, + ): Promise; + listProfiles( + context: IamTenantContextV1, + ): Promise; + saveBinding(context: IamTenantContextV1, binding: AutopilotFolderBindingV1): Promise; + findBinding( + context: IamTenantContextV1, + bindingId: StableIdentifierV1, + ): Promise; + listBindings( + context: IamTenantContextV1, + ): Promise; + saveAssignment(context: IamTenantContextV1, assignment: RecipeAssignmentV1): Promise; + findAssignment( + context: IamTenantContextV1, + assignmentId: StableIdentifierV1, + ): Promise; + listAssignments( + context: IamTenantContextV1, + ): Promise; + updateAssignmentState( + context: IamTenantContextV1, + assignmentId: StableIdentifierV1, + expectedRevision: number, + state: RecipeAssignmentStateV1, + ): Promise; +} + +export interface FolderAutopilotRepositoryPortV1 extends FolderAutopilotTransactionPortV1 { + withTransaction( + context: IamTenantContextV1, + work: (transaction: FolderAutopilotTransactionPortV1) => Promise, + ): Promise; +} diff --git a/services/api/src/features/fa/application/folder-autopilot.service.ts b/services/api/src/features/fa/application/folder-autopilot.service.ts new file mode 100644 index 00000000..54fe6cd4 --- /dev/null +++ b/services/api/src/features/fa/application/folder-autopilot.service.ts @@ -0,0 +1,299 @@ +import { + createAutopilotFolderBindingV1, + createFolderAutopilotProfileV1, + createRecipeAssignmentV1, + type AutopilotFolderBindingV1, + type FolderAutopilotErrorCodeV1, + type FolderAutopilotProfileV1, + type RecipeAssignmentStateV1, + type RecipeAssignmentV1, +} from '@databreeze/domain/folder-autopilot/v1'; +import { + parseStableIdentifierV1, + type StableIdentifierV1, +} from '@databreeze/domain/tenant-scope/v1'; +import type { DataModeV1 } from '@databreeze/domain/data-mode/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + FolderAutopilotRepositoryPortV1, + FolderAutopilotTransactionPortV1, +} from './folder-autopilot-repository.port.js'; + +export const FOLDER_AUTOPILOT_SERVICE = Symbol('FOLDER_AUTOPILOT_SERVICE'); +export const FOLDER_AUTOPILOT_DATA_MODE_POLICY_PORT = Symbol('FOLDER_AUTOPILOT_DATA_MODE_POLICY_PORT'); + +export type FolderAutopilotDataModePolicyResultV1 = + | { readonly accepted: true; readonly value: { readonly effectiveDataModePolicyRef: string } } + | { + readonly accepted: false; + readonly code: 'DATA_MODE_BROADENS_WORKSPACE' | 'DATA_MODE_POLICY_UNAVAILABLE'; + }; + +/** DSO owns policy records; FA only calls this narrow integration facade. */ +export interface FolderAutopilotDataModePolicyPortV1 { + resolveNarrowed( + context: IamTenantContextV1, + requested: DataModeV1, + ): Promise; +} + +export class UnavailableFolderAutopilotDataModePolicyAdapter + implements FolderAutopilotDataModePolicyPortV1 +{ + public resolveNarrowed( + _context: IamTenantContextV1, + _requested: DataModeV1, + ): Promise { + return Promise.resolve({ accepted: false, code: 'DATA_MODE_POLICY_UNAVAILABLE' as const }); + } +} + +export type FolderAutopilotServiceErrorV1 = + | FolderAutopilotErrorCodeV1 + | 'FA_PROFILE_NOT_FOUND' + | 'FA_BINDING_NOT_FOUND' + | 'FA_ASSIGNMENT_NOT_FOUND' + | 'FA_SCOPE_NARROWING_REQUIRED' + | 'FA_IMMUTABLE_PROFILE' + | 'FA_IMMUTABLE_BINDING' + | 'FA_IMMUTABLE_ASSIGNMENT' + | 'FA_PROFILE_HASH_MISMATCH' + | 'FA_BINDING_ROLE_MISMATCH' + | 'FA_ASSIGNMENT_REVISION_CONFLICT' + | 'FA_PERSISTENCE_UNAVAILABLE' + | 'DATA_MODE_BROADENS_WORKSPACE' + | 'DATA_MODE_POLICY_UNAVAILABLE'; + +export type FolderAutopilotServiceResultV1 = + | { readonly accepted: true; readonly value: TValue } + | { readonly accepted: false; readonly code: FolderAutopilotServiceErrorV1 }; + +type ProfileInputV1 = Omit[0], 'tenantScope'>; +type BindingInputV1 = Omit[0], 'tenantScope'>; +type AssignmentInputV1 = Omit[0], 'tenantScope'>; + +function rejected(code: FolderAutopilotServiceErrorV1): FolderAutopilotServiceResultV1 { + return Object.freeze({ accepted: false, code }); +} + +function mapPersistenceError(error: unknown): FolderAutopilotServiceErrorV1 { + const code = error instanceof Error ? error.message : ''; + if ( + code === 'FA_SCOPE_NARROWING_REQUIRED' || + code === 'FA_IMMUTABLE_PROFILE' || + code === 'FA_IMMUTABLE_BINDING' || + code === 'FA_IMMUTABLE_ASSIGNMENT' || + code === 'FA_PROFILE_NOT_FOUND' || + code === 'FA_BINDING_NOT_FOUND' || + code === 'FA_ASSIGNMENT_NOT_FOUND' || + code === 'FA_ASSIGNMENT_REVISION_CONFLICT' + ) + return code; + return 'FA_PERSISTENCE_UNAVAILABLE' as FolderAutopilotServiceErrorV1; +} + +function parseId(input: unknown): StableIdentifierV1 | undefined { + const parsed = parseStableIdentifierV1(input); + return parsed.accepted ? parsed.value : undefined; +} + +/** Coordinates FA-owned records without copying JRA recipe or DSO grant authority. */ +export class FolderAutopilotService { + public constructor( + private readonly repository: FolderAutopilotRepositoryPortV1, + private readonly dataModePolicy: FolderAutopilotDataModePolicyPortV1 = + new UnavailableFolderAutopilotDataModePolicyAdapter(), + ) {} + + public async createProfile( + context: IamTenantContextV1, + input: ProfileInputV1, + ): Promise> { + const created = createFolderAutopilotProfileV1({ + ...input, + tenantScope: context.tenantScope, + }); + if (!created.accepted) return created; + return this.repository + .withTransaction( + context, + async ( + transaction, + ): Promise> => { + const existing = await transaction.findProfile( + context, + created.value.profileId, + created.value.version, + ); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) + return Object.freeze({ accepted: true, value: existing }); + return rejected('FA_IMMUTABLE_PROFILE'); + } + await transaction.saveProfile(context, created.value); + return created; + }, + ) + .catch((error: unknown) => rejected(mapPersistenceError(error))); + } + + public async createBinding( + context: IamTenantContextV1, + input: BindingInputV1, + ): Promise> { + const created = createAutopilotFolderBindingV1({ + ...input, + tenantScope: context.tenantScope, + }); + if (!created.accepted) return created; + return this.repository + .withTransaction( + context, + async ( + transaction, + ): Promise> => { + const existing = await transaction.findBinding(context, created.value.bindingId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) + return Object.freeze({ accepted: true, value: existing }); + return rejected('FA_IMMUTABLE_BINDING'); + } + await transaction.saveBinding(context, created.value); + return created; + }, + ) + .catch((error: unknown) => rejected(mapPersistenceError(error))); + } + + public async createAssignment( + context: IamTenantContextV1, + input: AssignmentInputV1, + ): Promise> { + let effectiveDataModePolicyRef: string | undefined; + if (input.dataModeConstraint !== undefined) { + const requested = input.dataModeConstraint; + const resolution = await this.dataModePolicy.resolveNarrowed( + context, + requested as DataModeV1, + ); + if (!resolution.accepted) return rejected(resolution.code); + effectiveDataModePolicyRef = resolution.value.effectiveDataModePolicyRef; + } + const created = createRecipeAssignmentV1({ + ...input, + tenantScope: context.tenantScope, + ...(effectiveDataModePolicyRef === undefined ? {} : { effectiveDataModePolicyRef }), + }); + if (!created.accepted) return created; + return this.repository + .withTransaction( + context, + async ( + transaction, + ): Promise> => { + const profile = await transaction.findProfile( + context, + created.value.profileId, + created.value.profileVersion, + ); + if (!profile) return rejected('FA_PROFILE_NOT_FOUND'); + if (profile.payloadHash !== created.value.profileHash) + return rejected('FA_PROFILE_HASH_MISMATCH'); + for (const bindingId of created.value.inputBindingIds) { + const binding = await transaction.findBinding(context, bindingId); + if (!binding) return rejected('FA_BINDING_NOT_FOUND'); + if (binding.role !== 'INPUT') return rejected('FA_BINDING_ROLE_MISMATCH'); + } + for (const bindingId of created.value.outputBindingIds) { + const binding = await transaction.findBinding(context, bindingId); + if (!binding) return rejected('FA_BINDING_NOT_FOUND'); + if (binding.role !== 'OUTPUT') return rejected('FA_BINDING_ROLE_MISMATCH'); + } + const existing = await transaction.findAssignment(context, created.value.assignmentId); + if (existing) { + if (JSON.stringify(existing) === JSON.stringify(created.value)) + return Object.freeze({ accepted: true, value: existing }); + return rejected('FA_IMMUTABLE_ASSIGNMENT'); + } + await transaction.saveAssignment(context, created.value); + return created; + }, + ) + .catch((error: unknown) => rejected(mapPersistenceError(error))); + } + + public async updateAssignmentState( + context: IamTenantContextV1, + assignmentIdInput: unknown, + expectedRevision: number, + state: RecipeAssignmentStateV1, + ): Promise> { + const assignmentId = parseId(assignmentIdInput); + if (!assignmentId) return rejected('INVALID_IDENTIFIER'); + if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) + return rejected('INVALID_REVISION'); + if (!['DRAFT', 'ACTIVE', 'PAUSED', 'RETIRED'].includes(state)) + return rejected('INVALID_STATE'); + try { + const value = await this.repository.updateAssignmentState( + context, + assignmentId, + expectedRevision, + state, + ); + return Object.freeze({ accepted: true as const, value }); + } catch (error) { + return rejected(mapPersistenceError(error)); + } + } + + public async findProfile( + context: IamTenantContextV1, + profileIdInput: unknown, + version?: number, + ): Promise> { + const profileId = parseId(profileIdInput); + if (!profileId) return rejected('INVALID_IDENTIFIER'); + const value = await this.repository.findProfile(context, profileId, version); + return value ? Object.freeze({ accepted: true, value }) : rejected('FA_PROFILE_NOT_FOUND'); + } + + public async findBinding( + context: IamTenantContextV1, + bindingIdInput: unknown, + ): Promise> { + const bindingId = parseId(bindingIdInput); + if (!bindingId) return rejected('INVALID_IDENTIFIER'); + const value = await this.repository.findBinding(context, bindingId); + return value ? Object.freeze({ accepted: true, value }) : rejected('FA_BINDING_NOT_FOUND'); + } + + public async findAssignment( + context: IamTenantContextV1, + assignmentIdInput: unknown, + ): Promise> { + const assignmentId = parseId(assignmentIdInput); + if (!assignmentId) return rejected('INVALID_IDENTIFIER'); + const value = await this.repository.findAssignment(context, assignmentId); + return value ? Object.freeze({ accepted: true, value }) : rejected('FA_ASSIGNMENT_NOT_FOUND'); + } + + public async listProfiles( + context: IamTenantContextV1, + ): Promise> { + return Object.freeze({ accepted: true, value: await this.repository.listProfiles(context) }); + } + + public async listBindings( + context: IamTenantContextV1, + ): Promise> { + return Object.freeze({ accepted: true, value: await this.repository.listBindings(context) }); + } + + public async listAssignments( + context: IamTenantContextV1, + ): Promise> { + return Object.freeze({ accepted: true, value: await this.repository.listAssignments(context) }); + } +} diff --git a/services/api/test/features/fa/folder-autopilot.service.test.ts b/services/api/test/features/fa/folder-autopilot.service.test.ts index 4a29896d..d2cfbcea 100644 --- a/services/api/test/features/fa/folder-autopilot.service.test.ts +++ b/services/api/test/features/fa/folder-autopilot.service.test.ts @@ -71,6 +71,7 @@ const assignmentInput = { inputBindingIds: [ids.inputBindingId], outputBindingIds: [ids.outputBindingId], dataModeConstraint: 'LOCAL' as const, + idempotencyKey: 'assignment-create-1', createdAt: '2026-08-04T00:00:00.000Z', }; From d698e85b1906824311bbcf212ea5a9eeb6f44593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:22:32 +0700 Subject: [PATCH 12/62] feat(desktop): enforce local folder path containment --- .../folder-autopilot/path-containment.ts | 99 +++++++++++++++++++ .../folder-autopilot-path-containment.test.ts | 71 +++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 apps/desktop/src/features/folder-autopilot/path-containment.ts create mode 100644 apps/desktop/test/folder-autopilot-path-containment.test.ts diff --git a/apps/desktop/src/features/folder-autopilot/path-containment.ts b/apps/desktop/src/features/folder-autopilot/path-containment.ts new file mode 100644 index 00000000..64b9deb5 --- /dev/null +++ b/apps/desktop/src/features/folder-autopilot/path-containment.ts @@ -0,0 +1,99 @@ +import path from 'node:path'; + +export type ReparsePointPolicy = 'REJECT' | 'ALLOW_WITHIN_ROOT'; + +export type PathContainmentCode = + | 'INVALID_LOCAL_PATH' + | 'PATH_OUTSIDE_AUTHORIZATION' + | 'PATH_REPARSE_POINT'; + +export class PathContainmentError extends Error { + readonly code: PathContainmentCode; + + constructor(code: PathContainmentCode) { + super(code); + this.name = 'PathContainmentError'; + this.code = code; + } +} + +export interface PathContainmentOptions { + readonly canonicalRoot: string; + readonly realpath: (value: string) => string; + readonly isReparsePoint?: (value: string) => boolean; + readonly reparsePointPolicy?: ReparsePointPolicy; +} + +export interface PathContainmentGuard { + readonly canonicalRoot: string; + assertContained(candidate: string): string; + relativeName(candidate: string): string; +} + +function reject(code: PathContainmentCode): never { + throw new PathContainmentError(code); +} + +export function canonicalizeWindowsPath(value: string): string { + if (typeof value !== 'string' || value.length === 0 || value.includes('\0')) { + return reject('INVALID_LOCAL_PATH'); + } + const normalized = path.win32.normalize(value.replaceAll('/', '\\')); + if (!path.win32.isAbsolute(normalized)) return reject('INVALID_LOCAL_PATH'); + const parsed = path.win32.parse(normalized); + if (normalized !== parsed.root) return normalized.replace(/[\\]+$/, ''); + return parsed.root; +} + +function caseFold(value: string): string { + return value.toLowerCase(); +} + +function isContained(root: string, candidate: string): boolean { + const relative = path.win32.relative(caseFold(root), caseFold(candidate)); + return ( + relative.length === 0 || + (!relative.startsWith('..\\') && relative !== '..' && !path.win32.isAbsolute(relative)) + ); +} + +export function createPathContainmentGuard({ + canonicalRoot, + realpath, + isReparsePoint = () => false, + reparsePointPolicy = 'REJECT', +}: PathContainmentOptions): PathContainmentGuard { + const root = canonicalizeWindowsPath(canonicalRoot); + let resolvedRoot: string; + try { + resolvedRoot = canonicalizeWindowsPath(realpath(root)); + } catch { + return reject('INVALID_LOCAL_PATH'); + } + + const assertContained = (candidate: string): string => { + const canonicalCandidate = canonicalizeWindowsPath(candidate); + if (reparsePointPolicy === 'REJECT' && isReparsePoint(canonicalCandidate)) { + return reject('PATH_REPARSE_POINT'); + } + let resolvedCandidate: string; + try { + resolvedCandidate = canonicalizeWindowsPath(realpath(canonicalCandidate)); + } catch { + return reject('INVALID_LOCAL_PATH'); + } + if (!isContained(resolvedRoot, resolvedCandidate)) { + return reject('PATH_OUTSIDE_AUTHORIZATION'); + } + return resolvedCandidate; + }; + + return Object.freeze({ + canonicalRoot: resolvedRoot, + assertContained, + relativeName: (candidate: string): string => { + const resolvedCandidate = assertContained(candidate); + return path.win32.relative(resolvedRoot, resolvedCandidate); + }, + }); +} diff --git a/apps/desktop/test/folder-autopilot-path-containment.test.ts b/apps/desktop/test/folder-autopilot-path-containment.test.ts new file mode 100644 index 00000000..f6409579 --- /dev/null +++ b/apps/desktop/test/folder-autopilot-path-containment.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { + PathContainmentError, + canonicalizeWindowsPath, + createPathContainmentGuard, +} from '../src/features/folder-autopilot/path-containment.ts'; + +describe('Folder Autopilot path containment', () => { + it('canonicalizes case and separators while preserving the root boundary', () => { + expect(canonicalizeWindowsPath('C:\\Approved\\')).toBe('C:\\Approved'); + expect(() => canonicalizeWindowsPath('Approved\\relative')).toThrow('INVALID_LOCAL_PATH'); + + const guard = createPathContainmentGuard({ + canonicalRoot: 'C:\\Approved', + realpath: (value) => value, + }); + + expect(guard.assertContained('c:\\APPROVED\\Invoices\\01.csv')).toBe( + 'c:\\APPROVED\\Invoices\\01.csv', + ); + expect(() => guard.assertContained('C:\\Approved-neighbor\\01.csv')).toThrow( + 'PATH_OUTSIDE_AUTHORIZATION', + ); + }); + + it('rejects dot traversal after canonicalization', () => { + const guard = createPathContainmentGuard({ + canonicalRoot: 'C:\\Approved', + realpath: (value) => value, + }); + + expect(() => guard.assertContained('C:\\Approved\\..\\Secrets\\payroll.csv')).toThrow( + 'PATH_OUTSIDE_AUTHORIZATION', + ); + }); + + it('rejects a symlink or junction that resolves outside the authorized root', () => { + const guard = createPathContainmentGuard({ + canonicalRoot: 'C:\\Approved', + realpath: (value) => + value.toLowerCase().includes('linked') ? 'C:\\Secrets\\payroll.csv' : value, + }); + + expect(() => guard.assertContained('C:\\Approved\\linked\\payroll.csv')).toThrow( + 'PATH_OUTSIDE_AUTHORIZATION', + ); + }); + + it('rejects reparse points before local access under the strict policy', () => { + const guard = createPathContainmentGuard({ + canonicalRoot: 'C:\\Approved', + realpath: (value) => value, + isReparsePoint: (value) => value.toLowerCase().includes('junction'), + reparsePointPolicy: 'REJECT', + }); + + expect(() => guard.assertContained('C:\\Approved\\junction\\file.csv')).toThrow( + 'PATH_REPARSE_POINT', + ); + }); + + it('exposes only a content-free relative name after containment succeeds', () => { + const guard = createPathContainmentGuard({ + canonicalRoot: 'C:\\Approved', + realpath: (value) => value, + }); + + expect(guard.relativeName('C:\\Approved\\Invoices\\01.csv')).toBe('Invoices\\01.csv'); + expect(() => guard.relativeName('C:\\Other\\01.csv')).toThrow(PathContainmentError); + }); +}); From ab0e1a70826f7e916af5980fd66a25f255918353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:25:18 +0700 Subject: [PATCH 13/62] feat(desktop): stabilize and fingerprint local files --- .../folder-autopilot/file-observation.ts | 207 ++++++++++++++++++ .../test/folder-autopilot-observation.test.ts | 88 ++++++++ 2 files changed, 295 insertions(+) create mode 100644 apps/desktop/src/features/folder-autopilot/file-observation.ts create mode 100644 apps/desktop/test/folder-autopilot-observation.test.ts diff --git a/apps/desktop/src/features/folder-autopilot/file-observation.ts b/apps/desktop/src/features/folder-autopilot/file-observation.ts new file mode 100644 index 00000000..069dc4f9 --- /dev/null +++ b/apps/desktop/src/features/folder-autopilot/file-observation.ts @@ -0,0 +1,207 @@ +import { createHash } from 'node:crypto'; + +const MAX_FILE_BYTES = 10 * 1024 * 1024 * 1024; + +export type StableFileCode = + | 'FILE_CHANGED_DURING_READ' + | 'FILE_STILL_IN_USE' + | 'INVALID_OBSERVATION' + | 'NOT_REGULAR_FILE' + | 'PATH_REPARSE_POINT' + | 'RESOURCE_LIMIT'; + +export class StableFileError extends Error { + readonly code: StableFileCode; + + constructor(code: StableFileCode) { + super(code); + this.name = 'StableFileError'; + this.code = code; + } +} + +export interface StableFileStat { + readonly isFile: boolean; + readonly isSymbolicLink: boolean; + readonly sizeBytes: number; + readonly modifiedAtNs: number; +} + +export interface StableFileOptions { + readonly maxAttempts?: number; + readonly intervalMs?: number; + readonly sleep?: (milliseconds: number) => Promise; +} + +export interface LocalFileObservation { + readonly observationId: string; + readonly displayName: string; + readonly sizeBytes: number; + readonly modifiedAtNs: number; + readonly contentSha256: string; + readonly stableExecutionKey: string; +} + +interface CaptureStableObservationInput extends StableFileOptions { + readonly observationId: string; + readonly displayName: string; + readonly readStat: () => Promise; + readonly readBytes: () => Promise; +} + +function reject(code: StableFileCode): never { + throw new StableFileError(code); +} + +function defaultSleep(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function validateStat(stat: StableFileStat): StableFileStat { + if ( + typeof stat !== 'object' || + stat === null || + typeof stat.isFile !== 'boolean' || + typeof stat.isSymbolicLink !== 'boolean' || + !Number.isSafeInteger(stat.sizeBytes) || + stat.sizeBytes < 0 || + stat.sizeBytes > MAX_FILE_BYTES || + !Number.isSafeInteger(stat.modifiedAtNs) || + stat.modifiedAtNs < 0 + ) { + return reject('INVALID_OBSERVATION'); + } + if (stat.isSymbolicLink) return reject('PATH_REPARSE_POINT'); + if (!stat.isFile) return reject('NOT_REGULAR_FILE'); + return stat; +} + +function sameStat(first: StableFileStat, second: StableFileStat): boolean { + return ( + first.isFile === second.isFile && + first.isSymbolicLink === second.isSymbolicLink && + first.sizeBytes === second.sizeBytes && + first.modifiedAtNs === second.modifiedAtNs + ); +} + +export async function waitForStableFile( + readStat: () => Promise, + { maxAttempts = 5, intervalMs = 250, sleep = defaultSleep }: StableFileOptions = {}, +): Promise { + if ( + !Number.isSafeInteger(maxAttempts) || + maxAttempts < 2 || + maxAttempts > 20 || + !Number.isSafeInteger(intervalMs) || + intervalMs < 0 || + intervalMs > 5_000 + ) { + return reject('RESOURCE_LIMIT'); + } + + let previous: StableFileStat | undefined; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + let current: StableFileStat; + try { + current = validateStat(await readStat()); + } catch (error) { + if (error instanceof StableFileError && error.code !== 'FILE_STILL_IN_USE') throw error; + if (attempt === maxAttempts - 1) return reject('FILE_STILL_IN_USE'); + await sleep(intervalMs); + continue; + } + if (previous !== undefined && sameStat(previous, current)) return current; + previous = current; + if (attempt < maxAttempts - 1) await sleep(intervalMs); + } + return reject('FILE_STILL_IN_USE'); +} + +function isByteArray(value: unknown): value is Uint8Array { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { readonly byteLength?: unknown }).byteLength === 'number' && + Number.isSafeInteger((value as { readonly byteLength: number }).byteLength) && + (value as { readonly byteLength: number }).byteLength >= 0 + ); +} + +export function fingerprintBytes(bytes: Uint8Array): string { + if (!isByteArray(bytes)) return reject('INVALID_OBSERVATION'); + return createHash('sha256').update(bytes).digest('hex'); +} + +function stableExecutionKey(observation: Omit): string { + const canonical = JSON.stringify({ + contentSha256: observation.contentSha256, + displayName: observation.displayName, + modifiedAtNs: observation.modifiedAtNs, + observationId: observation.observationId, + sizeBytes: observation.sizeBytes, + }); + return createHash('sha256').update(canonical, 'utf8').digest('hex'); +} + +function validateDisplayName(displayName: string): string { + if ( + typeof displayName !== 'string' || + displayName.length === 0 || + displayName.length > 255 || + displayName === '.' || + displayName === '..' || + displayName.includes('/') || + displayName.includes('\\') || + [...displayName].some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127) + ) { + return reject('INVALID_OBSERVATION'); + } + return displayName; +} + +function validateObservationId(observationId: string): string { + if ( + typeof observationId !== 'string' || + !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(observationId) + ) { + return reject('INVALID_OBSERVATION'); + } + return observationId; +} + +export async function captureStableObservation({ + observationId, + displayName, + readStat, + readBytes, + maxAttempts, + intervalMs, + sleep, +}: CaptureStableObservationInput): Promise { + const first = await waitForStableFile(readStat, { maxAttempts, intervalMs, sleep }); + let bytes: Uint8Array; + try { + bytes = await readBytes(); + } catch { + return reject('FILE_STILL_IN_USE'); + } + if (!isByteArray(bytes) || bytes.byteLength !== first.sizeBytes) { + return reject('FILE_CHANGED_DURING_READ'); + } + let after: StableFileStat; + try { + after = validateStat(await readStat()); + } catch { + return reject('FILE_CHANGED_DURING_READ'); + } + if (!sameStat(first, after)) return reject('FILE_CHANGED_DURING_READ'); + const observation: Omit = { + observationId: validateObservationId(observationId), + displayName: validateDisplayName(displayName), + sizeBytes: first.sizeBytes, + modifiedAtNs: first.modifiedAtNs, + contentSha256: fingerprintBytes(bytes), + }; + return Object.freeze({ ...observation, stableExecutionKey: stableExecutionKey(observation) }); +} diff --git a/apps/desktop/test/folder-autopilot-observation.test.ts b/apps/desktop/test/folder-autopilot-observation.test.ts new file mode 100644 index 00000000..bcd1d2db --- /dev/null +++ b/apps/desktop/test/folder-autopilot-observation.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + StableFileError, + captureStableObservation, + fingerprintBytes, + waitForStableFile, + type StableFileStat, +} from '../src/features/folder-autopilot/file-observation.ts'; + +const stableStat: StableFileStat = { + isFile: true, + isSymbolicLink: false, + sizeBytes: 4, + modifiedAtNs: 10, +}; + +describe('Folder Autopilot stable local observations', () => { + it('waits for two identical metadata samples before hashing', async () => { + const readStat = vi + .fn<() => Promise>() + .mockResolvedValueOnce({ ...stableStat, sizeBytes: 3 }) + .mockResolvedValue(stableStat); + const sleep = vi.fn(() => Promise.resolve()); + + await expect(waitForStableFile(readStat, { maxAttempts: 4, sleep })).resolves.toEqual( + stableStat, + ); + expect(readStat).toHaveBeenCalledTimes(3); + expect(sleep).toHaveBeenCalledTimes(2); + }); + + it('retries transient lock failures and reports a bounded stable result', async () => { + const readStat = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('sharing violation')) + .mockResolvedValue(stableStat); + + await expect( + waitForStableFile(readStat, { maxAttempts: 4, sleep: () => Promise.resolve() }), + ).resolves.toEqual(stableStat); + }); + + it('rejects links and non-files before bytes are read', async () => { + const readStat = vi.fn<() => Promise>().mockResolvedValue({ + ...stableStat, + isSymbolicLink: true, + }); + await expect(waitForStableFile(readStat)).rejects.toMatchObject({ + code: 'PATH_REPARSE_POINT', + }); + }); + + it('fingerprints bytes and captures a content-free immutable observation', async () => { + const bytes = new TextEncoder().encode('data'); + expect(fingerprintBytes(bytes)).toBe( + '3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7', + ); + const observation = await captureStableObservation({ + observationId: 'obs-001', + displayName: 'Báo cáo.csv', + readStat: vi.fn<() => Promise>().mockResolvedValue(stableStat), + readBytes: vi.fn(() => Promise.resolve(bytes)), + sleep: () => Promise.resolve(), + }); + + expect(observation.sizeBytes).toBe(4); + expect(observation.contentSha256).toBe(fingerprintBytes(bytes)); + expect(observation.stableExecutionKey).toHaveLength(64); + expect('path' in observation).toBe(false); + }); + + it('refuses bytes when the file changes while it is being read', async () => { + const readStat = vi + .fn<() => Promise>() + .mockResolvedValueOnce(stableStat) + .mockResolvedValueOnce(stableStat) + .mockResolvedValue({ ...stableStat, modifiedAtNs: 11 }); + await expect( + captureStableObservation({ + observationId: 'obs-001', + displayName: 'report.csv', + readStat, + readBytes: () => Promise.resolve(new TextEncoder().encode('data')), + sleep: () => Promise.resolve(), + }), + ).rejects.toMatchObject({ code: 'FILE_CHANGED_DURING_READ' }); + }); +}); From 7a63380ea73ce5416bc780b303bdc6927e6d8f9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:25:37 +0700 Subject: [PATCH 14/62] feat(fa): add durable folder autopilot persistence boundary --- .../migration.sql | 73 ++++ services/api/prisma/schema/fa.prisma | 70 +++ services/api/prisma/schema/platform.prisma | 2 +- ...sma-folder-autopilot-repository.adapter.ts | 403 ++++++++++++++++++ services/api/test/prisma-foundation.test.mjs | 20 + 5 files changed, 567 insertions(+), 1 deletion(-) create mode 100644 services/api/prisma/migrations/20260804050000_fa_folder_autopilot/migration.sql create mode 100644 services/api/prisma/schema/fa.prisma create mode 100644 services/api/src/features/fa/adapter/prisma-folder-autopilot-repository.adapter.ts diff --git a/services/api/prisma/migrations/20260804050000_fa_folder_autopilot/migration.sql b/services/api/prisma/migrations/20260804050000_fa_folder_autopilot/migration.sql new file mode 100644 index 00000000..aaa77d2a --- /dev/null +++ b/services/api/prisma/migrations/20260804050000_fa_folder_autopilot/migration.sql @@ -0,0 +1,73 @@ +-- FA-001..FA-007: Folder Autopilot stores typed settings and opaque DSO/JRA references only. +CREATE SCHEMA IF NOT EXISTS "fa"; + +INSERT INTO "platform"."schema_registry" ("schema_name", "owner_module") +VALUES ('fa', 'folder-autopilot') +ON CONFLICT ("schema_name") DO NOTHING; + +CREATE TABLE "fa"."folder_autopilot_profiles" ( + "id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "version" INTEGER NOT NULL, + "payload_hash" CHAR(64) NOT NULL, + "stabilization_delay_ms" INTEGER NOT NULL, + "max_files_per_scan" INTEGER NOT NULL, + "collision_policy" VARCHAR(16) NOT NULL, + "undo_window_seconds" INTEGER NOT NULL, + "output_lineage_enabled" BOOLEAN NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL, + "revision" INTEGER NOT NULL DEFAULT 1, + CONSTRAINT "folder_autopilot_profiles_pkey" PRIMARY KEY ("id", "version") +); +CREATE INDEX "folder_autopilot_profiles_scope_idx" + ON "fa"."folder_autopilot_profiles" ("organization_id", "workspace_id", "project_id", "id", "version"); + +CREATE TABLE "fa"."autopilot_folder_bindings" ( + "id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "device_grant_id" UUID NOT NULL, + "role" VARCHAR(8) NOT NULL, + "expected_capability_digest" CHAR(64) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL, + "revision" INTEGER NOT NULL DEFAULT 1, + CONSTRAINT "autopilot_folder_bindings_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "autopilot_folder_bindings_scope_role_idx" + ON "fa"."autopilot_folder_bindings" ("organization_id", "workspace_id", "project_id", "role"); +CREATE INDEX "autopilot_folder_bindings_device_grant_idx" + ON "fa"."autopilot_folder_bindings" ("device_grant_id"); + +CREATE TABLE "fa"."recipe_assignments" ( + "id" UUID NOT NULL, + "scope_type" VARCHAR(24) NOT NULL, + "organization_id" UUID NOT NULL, + "workspace_id" UUID, + "project_id" UUID, + "profile_id" UUID NOT NULL, + "profile_version" INTEGER NOT NULL, + "profile_hash" CHAR(64) NOT NULL, + "jra_recipe_version_id" UUID NOT NULL, + "jra_recipe_version_hash" CHAR(64) NOT NULL, + "device_id" UUID NOT NULL, + "input_binding_ids" JSONB NOT NULL, + "output_binding_ids" JSONB NOT NULL, + "data_mode_constraint" VARCHAR(16), + "effective_data_mode_policy_ref" UUID, + "idempotency_key" VARCHAR(200) NOT NULL, + "state" VARCHAR(16) NOT NULL, + "revision" INTEGER NOT NULL DEFAULT 1, + "created_at" TIMESTAMPTZ(6) NOT NULL, + CONSTRAINT "recipe_assignments_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "recipe_assignments_scope_idempotency_key" + ON "fa"."recipe_assignments" ("organization_id", "workspace_id", "project_id", "idempotency_key"); +CREATE INDEX "recipe_assignments_scope_state_idx" + ON "fa"."recipe_assignments" ("organization_id", "workspace_id", "project_id", "state"); +CREATE INDEX "recipe_assignments_device_state_idx" + ON "fa"."recipe_assignments" ("device_id", "state"); diff --git a/services/api/prisma/schema/fa.prisma b/services/api/prisma/schema/fa.prisma new file mode 100644 index 00000000..6818c687 --- /dev/null +++ b/services/api/prisma/schema/fa.prisma @@ -0,0 +1,70 @@ +/// FA-001..FA-007: Folder Autopilot owns only typed profiles and opaque references. +model FolderAutopilotProfileRecord { + id String @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + version Int + payloadHash String @map("payload_hash") @db.Char(64) + stabilizationDelayMs Int @map("stabilization_delay_ms") + maxFilesPerScan Int @map("max_files_per_scan") + collisionPolicy String @map("collision_policy") @db.VarChar(16) + undoWindowSeconds Int @map("undo_window_seconds") + outputLineageEnabled Boolean @map("output_lineage_enabled") + createdAt DateTime @map("created_at") @db.Timestamptz(6) + revision Int @default(1) + + @@id([id, version], map: "folder_autopilot_profiles_pkey") + @@index([organizationId, workspaceId, projectId, id, version], map: "folder_autopilot_profiles_scope_idx") + @@map("folder_autopilot_profiles") + @@schema("fa") +} + +/// FA-001..FA-003: only DSO grant identifiers and expected digests are persisted. +model AutopilotFolderBindingRecord { + id String @id @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + deviceGrantId String @map("device_grant_id") @db.Uuid + role String @db.VarChar(8) + expectedCapabilityDigest String @map("expected_capability_digest") @db.Char(64) + createdAt DateTime @map("created_at") @db.Timestamptz(6) + revision Int @default(1) + + @@index([organizationId, workspaceId, projectId, role], map: "autopilot_folder_bindings_scope_role_idx") + @@index([deviceGrantId], map: "autopilot_folder_bindings_device_grant_idx") + @@map("autopilot_folder_bindings") + @@schema("fa") +} + +/// FA-005..FA-007, FA-014, FA-015, FA-031: references JRA/DSO authority by ID/hash. +model RecipeAssignmentRecord { + id String @id @db.Uuid + scopeType String @map("scope_type") @db.VarChar(24) + organizationId String @map("organization_id") @db.Uuid + workspaceId String? @map("workspace_id") @db.Uuid + projectId String? @map("project_id") @db.Uuid + profileId String @map("profile_id") @db.Uuid + profileVersion Int @map("profile_version") + profileHash String @map("profile_hash") @db.Char(64) + jraRecipeVersionId String @map("jra_recipe_version_id") @db.Uuid + jraRecipeVersionHash String @map("jra_recipe_version_hash") @db.Char(64) + deviceId String @map("device_id") @db.Uuid + inputBindingIds Json @map("input_binding_ids") + outputBindingIds Json @map("output_binding_ids") + dataModeConstraint String? @map("data_mode_constraint") @db.VarChar(16) + effectiveDataModePolicyRef String? @map("effective_data_mode_policy_ref") @db.Uuid + idempotencyKey String @map("idempotency_key") @db.VarChar(200) + state String @db.VarChar(16) + revision Int @default(1) + createdAt DateTime @map("created_at") @db.Timestamptz(6) + + @@unique([organizationId, workspaceId, projectId, idempotencyKey], map: "recipe_assignments_scope_idempotency_key") + @@index([organizationId, workspaceId, projectId, state], map: "recipe_assignments_scope_state_idx") + @@index([deviceId, state], map: "recipe_assignments_device_state_idx") + @@map("recipe_assignments") + @@schema("fa") +} diff --git a/services/api/prisma/schema/platform.prisma b/services/api/prisma/schema/platform.prisma index c4f3d74f..8fdfb4de 100644 --- a/services/api/prisma/schema/platform.prisma +++ b/services/api/prisma/schema/platform.prisma @@ -7,7 +7,7 @@ generator client { datasource db { provider = "postgresql" - schemas = ["platform", "system", "iam", "iae", "aud", "bua", "dsm", "jra", "dso", "sa"] + schemas = ["platform", "system", "iam", "iae", "aud", "bua", "dsm", "jra", "dso", "sa", "fa"] } /// Platform-owned registry documenting database-schema ownership boundaries. diff --git a/services/api/src/features/fa/adapter/prisma-folder-autopilot-repository.adapter.ts b/services/api/src/features/fa/adapter/prisma-folder-autopilot-repository.adapter.ts new file mode 100644 index 00000000..e96e37d3 --- /dev/null +++ b/services/api/src/features/fa/adapter/prisma-folder-autopilot-repository.adapter.ts @@ -0,0 +1,403 @@ +import { + createAutopilotFolderBindingV1, + createFolderAutopilotProfileV1, + createRecipeAssignmentV1, + type AutopilotFolderBindingV1, + type FolderAutopilotProfileV1, + type RecipeAssignmentV1, +} from '@databreeze/domain/folder-autopilot/v1'; +import { + parseTenantScopeV1, + tenantScopeContainsV1, + type TenantScopeV1, +} from '@databreeze/domain/tenant-scope/v1'; + +import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; +import type { + FolderAutopilotRepositoryPortV1, + FolderAutopilotTransactionPortV1, +} from '../application/folder-autopilot-repository.port.js'; + +export interface FolderAutopilotProfileDatabaseRowV1 { + readonly id: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly version: number; + readonly payloadHash: string; + readonly stabilizationDelayMs: number; + readonly maxFilesPerScan: number; + readonly collisionPolicy: string; + readonly undoWindowSeconds: number; + readonly outputLineageEnabled: boolean; + readonly createdAt: Date; + readonly revision: number; +} + +export interface FolderAutopilotBindingDatabaseRowV1 { + readonly id: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly deviceGrantId: string; + readonly role: string; + readonly expectedCapabilityDigest: string; + readonly createdAt: Date; + readonly revision: number; +} + +export interface FolderAutopilotAssignmentDatabaseRowV1 { + readonly id: string; + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; + readonly profileId: string; + readonly profileVersion: number; + readonly profileHash: string; + readonly jraRecipeVersionId: string; + readonly jraRecipeVersionHash: string; + readonly deviceId: string; + readonly inputBindingIds: unknown; + readonly outputBindingIds: unknown; + readonly dataModeConstraint: string | null; + readonly effectiveDataModePolicyRef: string | null; + readonly idempotencyKey: string; + readonly state: string; + readonly revision: number; + readonly createdAt: Date; +} + +export interface FolderAutopilotDatabaseClientV1 { + readonly folderAutopilotProfileRecord: { + create(input: { readonly data: FolderAutopilotProfileDatabaseRowV1 }): Promise; + findFirst(input: { + readonly where: Readonly>; + readonly orderBy?: Readonly>; + }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy: Readonly>; + }): Promise; + }; + readonly autopilotFolderBindingRecord: { + create(input: { readonly data: FolderAutopilotBindingDatabaseRowV1 }): Promise; + findUnique(input: { readonly where: { readonly id: string } }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy: Readonly>; + }): Promise; + }; + readonly recipeAssignmentRecord: { + create(input: { readonly data: FolderAutopilotAssignmentDatabaseRowV1 }): Promise; + findUnique(input: { readonly where: { readonly id: string } }): Promise; + findMany(input: { + readonly where: Readonly>; + readonly orderBy: Readonly>; + }): Promise; + update(input: { + readonly where: { readonly id: string }; + readonly data: Readonly>; + }): Promise; + }; + $transaction( + work: (transaction: FolderAutopilotDatabaseClientV1) => Promise, + ): Promise; +} + +function databaseScope(scope: TenantScopeV1) { + return { + scopeType: scope.scopeType, + organizationId: scope.organizationId, + workspaceId: scope.scopeType === 'organization' ? null : scope.workspaceId, + projectId: scope.scopeType === 'project' ? scope.projectId : null, + } as const; +} + +function rowScope(row: { + readonly scopeType: string; + readonly organizationId: string; + readonly workspaceId: string | null; + readonly projectId: string | null; +}): TenantScopeV1 { + const parsed = parseTenantScopeV1({ + scopeType: row.scopeType, + organizationId: row.organizationId, + ...(row.workspaceId === null ? {} : { workspaceId: row.workspaceId }), + ...(row.projectId === null ? {} : { projectId: row.projectId }), + }); + if (!parsed.accepted) throw new Error('FA_PERSISTED_SCOPE_INVALID'); + return parsed.value; +} + +function profileFromRow(row: FolderAutopilotProfileDatabaseRowV1): FolderAutopilotProfileV1 { + const parsed = createFolderAutopilotProfileV1({ + profileId: row.id, + tenantScope: rowScope(row), + version: row.version, + payloadHash: row.payloadHash, + stabilizationDelayMs: row.stabilizationDelayMs, + maxFilesPerScan: row.maxFilesPerScan, + collisionPolicy: row.collisionPolicy, + undoWindowSeconds: row.undoWindowSeconds, + outputLineageEnabled: row.outputLineageEnabled, + createdAt: row.createdAt.toISOString(), + }); + if (!parsed.accepted) throw new Error('FA_PERSISTED_PROFILE_INVALID'); + return parsed.value; +} + +function bindingFromRow(row: FolderAutopilotBindingDatabaseRowV1): AutopilotFolderBindingV1 { + const parsed = createAutopilotFolderBindingV1({ + bindingId: row.id, + tenantScope: rowScope(row), + deviceGrantId: row.deviceGrantId, + role: row.role, + expectedCapabilityDigest: row.expectedCapabilityDigest, + createdAt: row.createdAt.toISOString(), + }); + if (!parsed.accepted) throw new Error('FA_PERSISTED_BINDING_INVALID'); + return parsed.value; +} + +function assignmentFromRow(row: FolderAutopilotAssignmentDatabaseRowV1): RecipeAssignmentV1 { + const parsed = createRecipeAssignmentV1({ + assignmentId: row.id, + tenantScope: rowScope(row), + profileId: row.profileId, + profileVersion: row.profileVersion, + profileHash: row.profileHash, + jraRecipeVersionId: row.jraRecipeVersionId, + jraRecipeVersionHash: row.jraRecipeVersionHash, + deviceId: row.deviceId, + inputBindingIds: row.inputBindingIds, + outputBindingIds: row.outputBindingIds, + ...(row.dataModeConstraint === null ? {} : { dataModeConstraint: row.dataModeConstraint }), + ...(row.effectiveDataModePolicyRef === null + ? {} + : { effectiveDataModePolicyRef: row.effectiveDataModePolicyRef }), + idempotencyKey: row.idempotencyKey, + state: row.state, + revision: row.revision, + createdAt: row.createdAt.toISOString(), + }); + if (!parsed.accepted) throw new Error('FA_PERSISTED_ASSIGNMENT_INVALID'); + return parsed.value; +} + +function visible(context: TenantScopeV1, candidate: TenantScopeV1): boolean { + return tenantScopeContainsV1(context, candidate) || tenantScopeContainsV1(candidate, context); +} + +class PrismaFolderAutopilotTransactionAdapter implements FolderAutopilotTransactionPortV1 { + public constructor(private readonly client: FolderAutopilotDatabaseClientV1) {} + + public async saveProfile(context: IamTenantContextV1, profile: FolderAutopilotProfileV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, profile.tenantScope)) + throw new Error('FA_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.folderAutopilotProfileRecord.findFirst({ + where: { id: profile.profileId, version: profile.version }, + }); + if (existing) { + if (JSON.stringify(profileFromRow(existing)) !== JSON.stringify(profile)) + throw new Error('FA_IMMUTABLE_PROFILE'); + return; + } + await this.client.folderAutopilotProfileRecord.create({ + data: { + ...databaseScope(profile.tenantScope), + id: profile.profileId, + version: profile.version, + payloadHash: profile.payloadHash, + stabilizationDelayMs: profile.stabilizationDelayMs, + maxFilesPerScan: profile.maxFilesPerScan, + collisionPolicy: profile.collisionPolicy, + undoWindowSeconds: profile.undoWindowSeconds, + outputLineageEnabled: profile.outputLineageEnabled, + createdAt: new Date(profile.createdAt), + revision: profile.revision, + }, + }); + } + + public async findProfile(context: IamTenantContextV1, profileId: FolderAutopilotProfileV1['profileId'], version?: number) { + const row = await this.client.folderAutopilotProfileRecord.findFirst({ + where: { id: profileId, ...(version === undefined ? {} : { version }) }, + orderBy: { version: 'desc' }, + }); + return row !== null && visible(context.tenantScope, rowScope(row)) ? profileFromRow(row) : undefined; + } + + public async listProfiles(context: IamTenantContextV1) { + const rows = await this.client.folderAutopilotProfileRecord.findMany({ + where: { organizationId: context.tenantScope.organizationId }, + orderBy: { id: 'asc' }, + }); + return rows.filter((row) => visible(context.tenantScope, rowScope(row))).map(profileFromRow); + } + + public async saveBinding(context: IamTenantContextV1, binding: AutopilotFolderBindingV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, binding.tenantScope)) + throw new Error('FA_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.autopilotFolderBindingRecord.findUnique({ + where: { id: binding.bindingId }, + }); + if (existing) { + if (JSON.stringify(bindingFromRow(existing)) !== JSON.stringify(binding)) + throw new Error('FA_IMMUTABLE_BINDING'); + return; + } + await this.client.autopilotFolderBindingRecord.create({ + data: { + ...databaseScope(binding.tenantScope), + id: binding.bindingId, + deviceGrantId: binding.deviceGrantId, + role: binding.role, + expectedCapabilityDigest: binding.expectedCapabilityDigest, + createdAt: new Date(binding.createdAt), + revision: binding.revision, + }, + }); + } + + public async findBinding(context: IamTenantContextV1, bindingId: AutopilotFolderBindingV1['bindingId']) { + const row = await this.client.autopilotFolderBindingRecord.findUnique({ where: { id: bindingId } }); + return row !== null && visible(context.tenantScope, rowScope(row)) ? bindingFromRow(row) : undefined; + } + + public async listBindings(context: IamTenantContextV1) { + const rows = await this.client.autopilotFolderBindingRecord.findMany({ + where: { organizationId: context.tenantScope.organizationId }, + orderBy: { id: 'asc' }, + }); + return rows.filter((row) => visible(context.tenantScope, rowScope(row))).map(bindingFromRow); + } + + public async saveAssignment(context: IamTenantContextV1, assignment: RecipeAssignmentV1): Promise { + if (!tenantScopeContainsV1(context.tenantScope, assignment.tenantScope)) + throw new Error('FA_SCOPE_NARROWING_REQUIRED'); + const existing = await this.client.recipeAssignmentRecord.findUnique({ where: { id: assignment.assignmentId } }); + if (existing) { + if (JSON.stringify(assignmentFromRow(existing)) !== JSON.stringify(assignment)) + throw new Error('FA_IMMUTABLE_ASSIGNMENT'); + return; + } + await this.client.recipeAssignmentRecord.create({ + data: { + ...databaseScope(assignment.tenantScope), + id: assignment.assignmentId, + profileId: assignment.profileId, + profileVersion: assignment.profileVersion, + profileHash: assignment.profileHash, + jraRecipeVersionId: assignment.jraRecipeVersionId, + jraRecipeVersionHash: assignment.jraRecipeVersionHash, + deviceId: assignment.deviceId, + inputBindingIds: assignment.inputBindingIds, + outputBindingIds: assignment.outputBindingIds, + dataModeConstraint: assignment.dataModeConstraint ?? null, + effectiveDataModePolicyRef: assignment.effectiveDataModePolicyRef ?? null, + idempotencyKey: assignment.idempotencyKey, + state: assignment.state, + revision: assignment.revision, + createdAt: new Date(assignment.createdAt), + }, + }); + } + + public async findAssignment(context: IamTenantContextV1, assignmentId: RecipeAssignmentV1['assignmentId']) { + const row = await this.client.recipeAssignmentRecord.findUnique({ where: { id: assignmentId } }); + return row !== null && visible(context.tenantScope, rowScope(row)) ? assignmentFromRow(row) : undefined; + } + + public async listAssignments(context: IamTenantContextV1) { + const rows = await this.client.recipeAssignmentRecord.findMany({ + where: { organizationId: context.tenantScope.organizationId }, + orderBy: { id: 'asc' }, + }); + return rows.filter((row) => visible(context.tenantScope, rowScope(row))).map(assignmentFromRow); + } + + public async updateAssignmentState( + context: IamTenantContextV1, + assignmentId: RecipeAssignmentV1['assignmentId'], + expectedRevision: number, + state: RecipeAssignmentV1['state'], + ): Promise { + const existing = await this.client.recipeAssignmentRecord.findUnique({ where: { id: assignmentId } }); + if (!existing || !visible(context.tenantScope, rowScope(existing))) + throw new Error('FA_ASSIGNMENT_NOT_FOUND'); + if (!tenantScopeContainsV1(context.tenantScope, rowScope(existing))) + throw new Error('FA_SCOPE_NARROWING_REQUIRED'); + if (existing.revision !== expectedRevision) throw new Error('FA_ASSIGNMENT_REVISION_CONFLICT'); + const updated = await this.client.recipeAssignmentRecord.update({ + where: { id: assignmentId }, + data: { state, revision: expectedRevision + 1 }, + }); + return assignmentFromRow(updated); + } +} + +export class PrismaFolderAutopilotRepositoryAdapter implements FolderAutopilotRepositoryPortV1 { + public constructor(private readonly client: FolderAutopilotDatabaseClientV1) {} + + public withTransaction( + _context: IamTenantContextV1, + work: (transaction: FolderAutopilotTransactionPortV1) => Promise, + ): Promise { + return this.client.$transaction((transaction) => + work(new PrismaFolderAutopilotTransactionAdapter(transaction)), + ); + } + + public saveProfile(context: IamTenantContextV1, profile: FolderAutopilotProfileV1) { + return new PrismaFolderAutopilotTransactionAdapter(this.client).saveProfile(context, profile); + } + + public findProfile(context: IamTenantContextV1, profileId: FolderAutopilotProfileV1['profileId'], version?: number) { + return new PrismaFolderAutopilotTransactionAdapter(this.client).findProfile(context, profileId, version); + } + + public listProfiles(context: IamTenantContextV1) { + return new PrismaFolderAutopilotTransactionAdapter(this.client).listProfiles(context); + } + + public saveBinding(context: IamTenantContextV1, binding: AutopilotFolderBindingV1) { + return new PrismaFolderAutopilotTransactionAdapter(this.client).saveBinding(context, binding); + } + + public findBinding(context: IamTenantContextV1, bindingId: AutopilotFolderBindingV1['bindingId']) { + return new PrismaFolderAutopilotTransactionAdapter(this.client).findBinding(context, bindingId); + } + + public listBindings(context: IamTenantContextV1) { + return new PrismaFolderAutopilotTransactionAdapter(this.client).listBindings(context); + } + + public saveAssignment(context: IamTenantContextV1, assignment: RecipeAssignmentV1) { + return new PrismaFolderAutopilotTransactionAdapter(this.client).saveAssignment(context, assignment); + } + + public findAssignment(context: IamTenantContextV1, assignmentId: RecipeAssignmentV1['assignmentId']) { + return new PrismaFolderAutopilotTransactionAdapter(this.client).findAssignment(context, assignmentId); + } + + public listAssignments(context: IamTenantContextV1) { + return new PrismaFolderAutopilotTransactionAdapter(this.client).listAssignments(context); + } + + public updateAssignmentState( + context: IamTenantContextV1, + assignmentId: RecipeAssignmentV1['assignmentId'], + expectedRevision: number, + state: RecipeAssignmentV1['state'], + ) { + return new PrismaFolderAutopilotTransactionAdapter(this.client).updateAssignmentState( + context, + assignmentId, + expectedRevision, + state, + ); + } +} diff --git a/services/api/test/prisma-foundation.test.mjs b/services/api/test/prisma-foundation.test.mjs index fc9a0fab..7e426162 100644 --- a/services/api/test/prisma-foundation.test.mjs +++ b/services/api/test/prisma-foundation.test.mjs @@ -80,6 +80,9 @@ test('the schema diff and centrally ordered migration inventory establish platfo assert.match(diff.stdout, /CREATE TABLE "dso"\."device_sync_conflicts"/); assert.match(diff.stdout, /CREATE TABLE "dso"\."strict_local_package_manifests"/); assert.match(diff.stdout, /CREATE TABLE "sa"\."spreadsheet_audit_results"/); + assert.match(diff.stdout, /CREATE TABLE "fa"\."folder_autopilot_profiles"/); + assert.match(diff.stdout, /CREATE TABLE "fa"\."autopilot_folder_bindings"/); + assert.match(diff.stdout, /CREATE TABLE "fa"\."recipe_assignments"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."authorization_snapshots"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."mfa_recovery_codes"/); assert.match(diff.stdout, /CREATE TABLE "iam"\."invitation_tokens"/); @@ -142,6 +145,7 @@ test('the schema diff and centrally ordered migration inventory establish platfo '20260804020000_iam_service_account_replay_bounds', '20260804030000_iam_recovery_compensation_failures', '20260804040000_iam_invitation_delivery_failures', + '20260804050000_fa_folder_autopilot', 'migration_lock.toml', ]); const migration = await readFile( @@ -514,6 +518,22 @@ test('the schema diff and centrally ordered migration inventory establish platfo new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), ); } + const folderAutopilotMigration = await readFile( + path.join(migrationsDirectory, inventory[46], 'migration.sql'), + 'utf8', + ); + for (const statement of [ + 'CREATE SCHEMA IF NOT EXISTS "fa"', + 'CREATE TABLE "fa"."folder_autopilot_profiles"', + 'CREATE TABLE "fa"."autopilot_folder_bindings"', + 'CREATE TABLE "fa"."recipe_assignments"', + 'expected_capability_digest', + ]) { + assert.match( + folderAutopilotMigration, + new RegExp(statement.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ); + } const lineageUniquenessMigration = await readFile( path.join(migrationsDirectory, inventory[32], 'migration.sql'), 'utf8', From 66d29b92963aeff0ae0b338884da8ede5c0780e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:28:12 +0700 Subject: [PATCH 15/62] feat(web): add Folder Autopilot workspace surfaces --- apps/web/src/app/feature-registry.ts | 1 + apps/web/src/app/messages.ts | 98 +++ apps/web/src/app/navigation.ts | 2 + apps/web/src/app/router.tsx | 19 +- apps/web/src/components/shell-layout.tsx | 1 + .../folder-autopilot-page.tsx | 560 ++++++++++++++++++ apps/web/src/pages/shell-states.tsx | 1 + apps/web/test/folder-autopilot-page.test.tsx | 173 ++++++ 8 files changed, 854 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/features/folder-autopilot/folder-autopilot-page.tsx create mode 100644 apps/web/test/folder-autopilot-page.test.tsx diff --git a/apps/web/src/app/feature-registry.ts b/apps/web/src/app/feature-registry.ts index 7bb5babf..f7828bc1 100644 --- a/apps/web/src/app/feature-registry.ts +++ b/apps/web/src/app/feature-registry.ts @@ -13,6 +13,7 @@ export const WEB_FEATURE_REGISTRY = Object.freeze([ { key: 'inbox', messageKey: 'nav.inbox', path: 'inbox' }, { key: 'jobs', messageKey: 'nav.jobs', path: 'jobs' }, { key: 'reviews', messageKey: 'nav.reviews', path: 'reviews' }, + { key: 'autopilot', path: 'autopilot' }, { key: 'approvals', messageKey: 'nav.approvals', path: 'approvals' }, { key: 'reports', messageKey: 'nav.reports', path: 'reports' }, { key: 'devices', messageKey: 'nav.devices', path: 'devices' }, diff --git a/apps/web/src/app/messages.ts b/apps/web/src/app/messages.ts index eb02efc5..853e7417 100644 --- a/apps/web/src/app/messages.ts +++ b/apps/web/src/app/messages.ts @@ -84,6 +84,55 @@ const vietnameseMessages = { 'spreadsheetAudit.blocked.externalLink': 'Tệp có liên kết ngoài và không được làm mới.', 'spreadsheetAudit.blocked.unsupportedXml': 'Một phần XML không được hỗ trợ.', 'spreadsheetAudit.unknownSheet': 'Trang tính không xác định', + 'autopilot.heading': 'Folder Autopilot', + 'autopilot.caption': + 'Tạo và xem xét các quy trình thư mục được kiểm soát. Chỉ hiển thị mã, trạng thái và bằng chứng an toàn.', + 'autopilot.loading': 'Đang tải Folder Autopilot…', + 'autopilot.error': 'Không thể tải Folder Autopilot. Không có thay đổi nào được gửi.', + 'autopilot.retry': 'Tải lại an toàn', + 'autopilot.profile.heading': 'Hồ sơ', + 'autopilot.profile.name': 'Tên hiển thị', + 'autopilot.profile.stabilization': 'Thời gian ổn định (giây)', + 'autopilot.profile.collision': 'Xử lý xung đột', + 'autopilot.profile.confidence': 'Ngưỡng tin cậy', + 'autopilot.profile.undoWindow': 'Thời gian hoàn tác (giờ)', + 'autopilot.profile.approval': 'Yêu cầu phê duyệt', + 'autopilot.profile.dataMode': 'Chế độ dữ liệu', + 'autopilot.profile.save': 'Lưu hồ sơ', + 'autopilot.profile.saved': 'Hồ sơ đã được gửi để kiểm tra.', + 'autopilot.assignment.heading': 'Phân công', + 'autopilot.assignment.name': 'Phân công', + 'autopilot.assignment.state': 'Trạng thái', + 'autopilot.assignment.revision': 'Phiên bản', + 'autopilot.assignment.health': 'Sức khỏe watcher', + 'autopilot.assignment.pause': 'Tạm dừng assignment', + 'autopilot.assignment.paused': 'Đã tạm dừng', + 'autopilot.assignment.active': 'Đang hoạt động', + 'autopilot.approval.heading': 'Hàng đợi phê duyệt', + 'autopilot.approval.preview': 'Preview', + 'autopilot.approval.plan': 'Mã kế hoạch', + 'autopilot.approval.affected': 'Số mục ảnh hưởng', + 'autopilot.approval.blocked': 'Bị chặn', + 'autopilot.approval.approve': 'Phê duyệt preview', + 'autopilot.approval.reject': 'Từ chối preview', + 'autopilot.approval.pending': 'Đang chờ', + 'autopilot.approval.approved': 'Đã phê duyệt', + 'autopilot.approval.rejected': 'Đã từ chối', + 'autopilot.exceptions.heading': 'Ngoại lệ', + 'autopilot.exceptions.reason': 'Mã lý do', + 'autopilot.exceptions.severity': 'Mức độ', + 'autopilot.exceptions.status': 'Trạng thái', + 'autopilot.exceptions.open': 'Mở', + 'autopilot.outcomes.heading': 'Kết quả gần đây', + 'autopilot.outcomes.outcome': 'Kết quả', + 'autopilot.outcomes.affected': 'Ảnh hưởng', + 'autopilot.outcomes.undo': 'Hoàn tác', + 'autopilot.outcomes.undoRequested': 'Đã yêu cầu hoàn tác', + 'autopilot.outcomes.undoAvailable': 'Có thể hoàn tác', + 'autopilot.outcomes.handled': 'Đã xử lý', + 'autopilot.outcomes.exception': 'Ngoại lệ', + 'autopilot.reason.collision': 'Đích có xung đột', + 'autopilot.reason.none': 'Không có mã lý do', 'locale.english': 'English', 'locale.vietnamese': 'Tiếng Việt', 'nav.administration': 'Quản trị', @@ -191,6 +240,55 @@ const englishMessages: Readonly> = { 'spreadsheetAudit.blocked.externalLink': 'External links were detected and not refreshed.', 'spreadsheetAudit.blocked.unsupportedXml': 'Some XML content is unsupported.', 'spreadsheetAudit.unknownSheet': 'Unknown sheet', + 'autopilot.heading': 'Folder Autopilot', + 'autopilot.caption': + 'Author and review governed folder workflows. Only safe identifiers, statuses, and evidence are shown.', + 'autopilot.loading': 'Loading Folder Autopilot…', + 'autopilot.error': 'Folder Autopilot could not load. No changes were sent.', + 'autopilot.retry': 'Retry safely', + 'autopilot.profile.heading': 'Profiles', + 'autopilot.profile.name': 'Display name', + 'autopilot.profile.stabilization': 'Stabilization (seconds)', + 'autopilot.profile.collision': 'Collision policy', + 'autopilot.profile.confidence': 'Confidence threshold', + 'autopilot.profile.undoWindow': 'Undo window (hours)', + 'autopilot.profile.approval': 'Require approval', + 'autopilot.profile.dataMode': 'Data mode', + 'autopilot.profile.save': 'Save profile', + 'autopilot.profile.saved': 'Profile submitted for validation.', + 'autopilot.assignment.heading': 'Assignments', + 'autopilot.assignment.name': 'Assignment', + 'autopilot.assignment.state': 'State', + 'autopilot.assignment.revision': 'Revision', + 'autopilot.assignment.health': 'Watcher health', + 'autopilot.assignment.pause': 'Pause assignment', + 'autopilot.assignment.paused': 'Paused', + 'autopilot.assignment.active': 'Active', + 'autopilot.approval.heading': 'Approval queue', + 'autopilot.approval.preview': 'Preview', + 'autopilot.approval.plan': 'Plan hash', + 'autopilot.approval.affected': 'Affected', + 'autopilot.approval.blocked': 'Blocked', + 'autopilot.approval.approve': 'Approve preview', + 'autopilot.approval.reject': 'Reject preview', + 'autopilot.approval.pending': 'Pending', + 'autopilot.approval.approved': 'Approved', + 'autopilot.approval.rejected': 'Rejected', + 'autopilot.exceptions.heading': 'Exceptions', + 'autopilot.exceptions.reason': 'Reason code', + 'autopilot.exceptions.severity': 'Severity', + 'autopilot.exceptions.status': 'Status', + 'autopilot.exceptions.open': 'Open', + 'autopilot.outcomes.heading': 'Recent outcomes', + 'autopilot.outcomes.outcome': 'Outcome', + 'autopilot.outcomes.affected': 'Affected', + 'autopilot.outcomes.undo': 'Undo', + 'autopilot.outcomes.undoRequested': 'Undo requested', + 'autopilot.outcomes.undoAvailable': 'Undo available', + 'autopilot.outcomes.handled': 'Handled', + 'autopilot.outcomes.exception': 'Exception', + 'autopilot.reason.collision': 'Destination collision', + 'autopilot.reason.none': 'No reason codes', 'locale.english': 'English', 'locale.vietnamese': 'Tiếng Việt', 'nav.administration': 'Administration', diff --git a/apps/web/src/app/navigation.ts b/apps/web/src/app/navigation.ts index 92945369..df755372 100644 --- a/apps/web/src/app/navigation.ts +++ b/apps/web/src/app/navigation.ts @@ -20,6 +20,7 @@ export interface WebAccessContext { export type NavigationKey = | 'administration' | 'approvals' + | 'autopilot' | 'audit' | 'devices' | 'inbox' @@ -56,6 +57,7 @@ export const WEB_NAVIGATION_REGISTRY = Object.freeze([ navigationItem('inbox', 'inbox', [PERMISSIONS_V1.ARTIFACT_RECORD_READ]), navigationItem('jobs', 'jobs', [PERMISSIONS_V1.JOB_EXECUTION_READ], ['automation']), navigationItem('reviews', 'reviews', [PERMISSIONS_V1.JOB_EXECUTION_READ], ['automation']), + navigationItem('autopilot', 'autopilot', [PERMISSIONS_V1.JOB_EXECUTION_READ], ['automation']), navigationItem('approvals', 'approvals', [PERMISSIONS_V1.APPROVAL_REQUEST_READ], ['governance']), navigationItem('reports', 'reports', [PERMISSIONS_V1.ARTIFACT_RECORD_READ], ['reports']), navigationItem('devices', 'devices', [PERMISSIONS_V1.DEVICE_IDENTITY_READ], ['devices']), diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx index c25afccd..cb90f38b 100644 --- a/apps/web/src/app/router.tsx +++ b/apps/web/src/app/router.tsx @@ -1,4 +1,5 @@ import { DEFAULT_LOCALE_V1, SUPPORTED_LOCALES_V1 } from '@databreeze/i18n/v1'; +import { lazy, Suspense } from 'react'; import { Navigate, createBrowserRouter, @@ -20,6 +21,20 @@ import { SpreadsheetAuditPage } from '../features/spreadsheet-auditor/spreadshee import { WEB_FEATURE_REGISTRY } from './feature-registry.ts'; import { DEFAULT_ACCESS_CONTEXT, type WebAccessContext } from './navigation.ts'; +const LazyFolderAutopilotPage = lazy(() => + import('../features/folder-autopilot/folder-autopilot-page.tsx').then((module) => ({ + default: module.FolderAutopilotPage, + })), +); + +function FolderAutopilotRoute() { + return ( + }> + + + ); +} + const logicalRoots = new Set(WEB_FEATURE_REGISTRY.map((feature) => feature.path)); function canonicalPathname(pathname: string): string | undefined { @@ -58,7 +73,9 @@ function createRoutes(accessContext: WebAccessContext): RouteObject[] { ...WEB_FEATURE_REGISTRY.filter((feature) => feature.key !== 'workspace').map((feature) => ({ path: feature.path, element: - feature.key === 'inbox' ? ( + feature.key === 'autopilot' ? ( + + ) : feature.key === 'inbox' ? ( ) : feature.key === 'audit' ? ( diff --git a/apps/web/src/components/shell-layout.tsx b/apps/web/src/components/shell-layout.tsx index a16535af..fb8ef376 100644 --- a/apps/web/src/components/shell-layout.tsx +++ b/apps/web/src/components/shell-layout.tsx @@ -40,6 +40,7 @@ function navigationLabel(locale: 'en' | 'vi-VN', key: NavigationKey): string { return formatMessageV1(locale, registration.messageKey); if (key === 'usage') return appMessage(locale, 'nav.usage'); if (key === 'administration') return appMessage(locale, 'nav.administration'); + if (key === 'autopilot') return appMessage(locale, 'autopilot.heading'); return appMessage(locale, 'nav.audit'); } diff --git a/apps/web/src/features/folder-autopilot/folder-autopilot-page.tsx b/apps/web/src/features/folder-autopilot/folder-autopilot-page.tsx new file mode 100644 index 00000000..8cbab571 --- /dev/null +++ b/apps/web/src/features/folder-autopilot/folder-autopilot-page.tsx @@ -0,0 +1,560 @@ +import { Button, Status } from '@databreeze/ui/v1'; +import { useQuery } from '@tanstack/react-query'; +import { useState, type FormEvent } from 'react'; +import { appMessage } from '../../app/messages.ts'; +import { useLocale } from '../../app/locale-context.tsx'; +import { + createFolderAutopilotProfile, + decideFolderAutopilotApproval, + getFolderAutopilotDashboard, + pauseFolderAutopilotAssignment, + requestFolderAutopilotUndo, + type FolderAutopilotApproval, + type FolderAutopilotAssignment, + type FolderAutopilotDashboard, + type FolderAutopilotExecution, + type FolderAutopilotProfileInput, + type FolderAutopilotPreview, +} from './folder-autopilot-api.ts'; + +function dateLabel(locale: ReturnType, value: string): string { + return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format( + new Date(value), + ); +} + +function statusKind(value: string): 'danger' | 'info' | 'success' | 'warning' { + if (value === 'ACTIVE' || value === 'HEALTHY' || value === 'HANDLED' || value === 'APPROVED') + return 'success'; + if (value === 'INVALID' || value === 'EXCEPTION' || value === 'ERROR' || value === 'REJECTED') + return 'danger'; + if (value === 'RUNNING' || value === 'QUEUED') return 'info'; + return 'warning'; +} + +function reasonLabel(locale: ReturnType, value: string): string { + return value === 'DESTINATION_COLLISION' + ? appMessage(locale, 'autopilot.reason.collision') + : value; +} + +function assignmentHealth( + dashboard: FolderAutopilotDashboard, + assignmentId: string, +): string | undefined { + return dashboard.health.find((item) => item.assignmentId === assignmentId)?.watcherState; +} + +function ProfileAuthoring({ onSaved }: { readonly onSaved: () => void }) { + const locale = useLocale(); + const [input, setInput] = useState({ + displayName: '', + stabilizationSeconds: 10, + collisionPolicy: 'REVIEW', + confidenceThreshold: 0.9, + undoWindowHours: 24, + approvalRequired: true, + dataModeConstraint: 'Hybrid', + }); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + setSaving(true); + setSaved(false); + setError(false); + try { + await createFolderAutopilotProfile(input); + setSaved(true); + onSaved(); + } catch { + setError(true); + } finally { + setSaving(false); + } + } + + return ( +
+
+

{appMessage(locale, 'autopilot.profile.heading')}

+ JRA profile facade +
+
void submit(event)}> + + + + + + + + +
+ {saved ? ( +

+ {appMessage(locale, 'autopilot.profile.saved')} +

+ ) : null} + {error ? {appMessage(locale, 'autopilot.error')} : null} +
+ ); +} + +function AssignmentList({ + dashboard, + paused, + onPause, +}: { + readonly dashboard: FolderAutopilotDashboard; + readonly paused: Readonly>; + readonly onPause: (assignment: FolderAutopilotAssignment) => Promise; +}) { + const locale = useLocale(); + return ( +
+
+

+ {appMessage(locale, 'autopilot.assignment.heading')} +

+
+
+ + + + + + + + + + + + {dashboard.assignments.map((assignment) => { + const isPaused = paused[assignment.assignmentId] || assignment.state === 'PAUSED'; + const health = assignmentHealth(dashboard, assignment.assignmentId); + return ( + + + + + + + + ); + })} + +
{appMessage(locale, 'autopilot.assignment.name')}{appMessage(locale, 'autopilot.assignment.state')}{appMessage(locale, 'autopilot.assignment.revision')}{appMessage(locale, 'autopilot.assignment.health')} + Actions +
+ {assignment.displayName} + + {assignment.assignmentId} + + + + {isPaused + ? appMessage(locale, 'autopilot.assignment.paused') + : appMessage(locale, 'autopilot.assignment.active')} + + {assignment.revision} + {health ?? 'OFFLINE'} + + +
+
+
+ ); +} + +function ApprovalQueue({ + previews, + approvals, + decisions, + onDecision, +}: { + readonly previews: readonly FolderAutopilotPreview[]; + readonly approvals: readonly FolderAutopilotApproval[]; + readonly decisions: Readonly>; + readonly onDecision: ( + approval: FolderAutopilotApproval, + decision: 'APPROVED' | 'REJECTED', + planHash: string, + ) => Promise; +}) { + const locale = useLocale(); + return ( +
+
+

{appMessage(locale, 'autopilot.approval.heading')}

+
+ {approvals.length === 0 ?

{appMessage(locale, 'autopilot.reason.none')}

: null} +
+ {approvals.map((approval) => { + const preview = previews.find((candidate) => candidate.previewId === approval.previewId); + if (!preview) return null; + const decision = decisions[approval.approvalId] ?? approval.decision; + return ( +
+
+
+

+ {appMessage(locale, 'autopilot.approval.preview')}{' '} + {preview.previewId} +

+

+ {approval.approvalId} +

+
+ + {decision === 'PENDING' + ? appMessage(locale, 'autopilot.approval.pending') + : decision === 'APPROVED' + ? appMessage(locale, 'autopilot.approval.approved') + : appMessage(locale, 'autopilot.approval.rejected')} + +
+
+
+
{appMessage(locale, 'autopilot.approval.plan')}
+
+ {preview.planHash} +
+
+
+
{appMessage(locale, 'autopilot.approval.affected')}
+
{preview.affectedCount}
+
+
+
{appMessage(locale, 'autopilot.approval.blocked')}
+
{preview.blockedCount}
+
+
+
    + {preview.reasonCodes.map((reason) => ( +
  • {reasonLabel(locale, reason)}
  • + ))} +
+
+ + +
+
+ ); + })} +
+
+ ); +} + +function Exceptions({ dashboard }: { readonly dashboard: FolderAutopilotDashboard }) { + const locale = useLocale(); + return ( +
+
+

+ {appMessage(locale, 'autopilot.exceptions.heading')} +

+
+ {dashboard.exceptions.length === 0 ? ( +

{appMessage(locale, 'autopilot.reason.none')}

+ ) : ( +
+ + + + + + + + + + {dashboard.exceptions.map((item) => ( + + + + + + ))} + +
{appMessage(locale, 'autopilot.exceptions.reason')}{appMessage(locale, 'autopilot.exceptions.severity')}{appMessage(locale, 'autopilot.exceptions.status')}
+ {item.reasonCode} + + {item.severity} + + {item.status === 'OPEN' + ? appMessage(locale, 'autopilot.exceptions.open') + : item.status} +
+
+ )} +
+ ); +} + +function RecentOutcomes({ + executions, + requestedUndo, + onUndo, +}: { + readonly executions: readonly FolderAutopilotExecution[]; + readonly requestedUndo: Readonly>; + readonly onUndo: (execution: FolderAutopilotExecution) => Promise; +}) { + const locale = useLocale(); + return ( +
+
+

{appMessage(locale, 'autopilot.outcomes.heading')}

+
+
+ + + + + + + + + + + {executions.map((execution) => { + const undoAvailable = + execution.undoState === 'AVAILABLE' && !requestedUndo[execution.executionId]; + return ( + + + + + + + ); + })} + +
{appMessage(locale, 'autopilot.outcomes.outcome')}{appMessage(locale, 'autopilot.outcomes.affected')}{appMessage(locale, 'autopilot.outcomes.undo')} + Actions +
+ + {execution.outcome === 'HANDLED' + ? appMessage(locale, 'autopilot.outcomes.handled') + : execution.outcome === 'EXCEPTION' + ? appMessage(locale, 'autopilot.outcomes.exception') + : execution.outcome} + + + {execution.executionId} + + {execution.affectedCount} + {requestedUndo[execution.executionId] + ? appMessage(locale, 'autopilot.outcomes.undoRequested') + : execution.undoState === 'AVAILABLE' + ? appMessage(locale, 'autopilot.outcomes.undoAvailable') + : execution.undoState} + + +
+
+
+ ); +} + +export function FolderAutopilotPage() { + const locale = useLocale(); + const query = useQuery({ + queryKey: ['folder-autopilot', 'dashboard'], + queryFn: ({ signal }) => getFolderAutopilotDashboard(signal), + retry: false, + }); + const [paused, setPaused] = useState>>({}); + const [decisions, setDecisions] = useState>>({}); + const [requestedUndo, setRequestedUndo] = useState>>({}); + + if (query.isPending) + return ( +
+

{appMessage(locale, 'autopilot.heading')}

+ {appMessage(locale, 'autopilot.loading')} +
+ ); + if (query.isError) + return ( +
+

{appMessage(locale, 'autopilot.heading')}

+ {appMessage(locale, 'autopilot.error')} + +
+ ); + + const dashboard = query.data; + async function pause(assignment: FolderAutopilotAssignment) { + await pauseFolderAutopilotAssignment(assignment.assignmentId, assignment.revision); + setPaused((current) => ({ ...current, [assignment.assignmentId]: true })); + } + async function decide( + approval: FolderAutopilotApproval, + decision: 'APPROVED' | 'REJECTED', + planHash: string, + ) { + await decideFolderAutopilotApproval(approval.approvalId, decision, planHash); + setDecisions((current) => ({ ...current, [approval.approvalId]: decision })); + } + async function undo(execution: FolderAutopilotExecution) { + await requestFolderAutopilotUndo(execution.executionId); + setRequestedUndo((current) => ({ ...current, [execution.executionId]: true })); + } + + return ( +
+
+
+

{appMessage(locale, 'autopilot.heading')}

+

{appMessage(locale, 'autopilot.caption')}

+
+ Hybrid +
+
+ undefined} /> + + + + +
+

{appMessage(locale, 'access.clientHint')}

+

+ {dateLabel(locale, dashboard.profiles[0]?.updatedAt ?? new Date(0).toISOString())} +

+
+ ); +} diff --git a/apps/web/src/pages/shell-states.tsx b/apps/web/src/pages/shell-states.tsx index 67ec90a3..3fbfc58b 100644 --- a/apps/web/src/pages/shell-states.tsx +++ b/apps/web/src/pages/shell-states.tsx @@ -12,6 +12,7 @@ function featureLabel(locale: ReturnType, key: NavigationKey): return formatMessageV1(locale, registration.messageKey); } if (key === 'usage') return appMessage(locale, 'nav.usage'); + if (key === 'autopilot') return appMessage(locale, 'autopilot.heading'); return appMessage(locale, 'nav.administration'); } diff --git a/apps/web/test/folder-autopilot-page.test.tsx b/apps/web/test/folder-autopilot-page.test.tsx new file mode 100644 index 00000000..f9f5e81e --- /dev/null +++ b/apps/web/test/folder-autopilot-page.test.tsx @@ -0,0 +1,173 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ApplicationBoundary, createAppRouter } from '../src/app/app.tsx'; + +const dashboard = { + schemaVersion: 1, + profiles: [ + { + profileId: '00000000-0000-4000-8000-000000000001', + displayName: 'Invoice intake', + stabilizationSeconds: 10, + collisionPolicy: 'REVIEW', + confidenceThreshold: 0.9, + undoWindowHours: 24, + approvalRequired: true, + dataModeConstraint: 'Hybrid', + recipeHash: 'a'.repeat(64), + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ], + assignments: [ + { + assignmentId: '00000000-0000-4000-8000-000000000002', + profileId: '00000000-0000-4000-8000-000000000001', + displayName: 'Invoice intake assignment', + jraRecipeVersionId: '00000000-0000-4000-8000-000000000003', + deviceId: '00000000-0000-4000-8000-000000000004', + inputBindingId: '00000000-0000-4000-8000-000000000005', + outputBindingId: '00000000-0000-4000-8000-000000000006', + state: 'ACTIVE', + approvalRequired: true, + revision: 3, + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ], + previews: [ + { + previewId: '00000000-0000-4000-8000-000000000007', + assignmentId: '00000000-0000-4000-8000-000000000002', + jraRecipeVersionId: '00000000-0000-4000-8000-000000000003', + planHash: 'b'.repeat(64), + status: 'NEEDS_APPROVAL', + affectedCount: 2, + blockedCount: 1, + actions: [ + { + stepId: 'step-1', + actionType: 'MOVE', + sourceArtifactVersionId: '00000000-0000-4000-8000-000000000008', + destinationBindingId: '00000000-0000-4000-8000-000000000006', + collision: 'REVIEW', + requiresApproval: true, + }, + ], + reasonCodes: ['DESTINATION_COLLISION'], + createdAt: '2026-08-04T00:00:00.000Z', + expiresAt: '2026-08-05T00:00:00.000Z', + }, + ], + approvals: [ + { + approvalId: '00000000-0000-4000-8000-000000000009', + previewId: '00000000-0000-4000-8000-000000000007', + planHash: 'b'.repeat(64), + decision: 'PENDING', + expiresAt: '2026-08-05T00:00:00.000Z', + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ], + executions: [ + { + executionId: '00000000-0000-4000-8000-00000000000a', + assignmentId: '00000000-0000-4000-8000-000000000002', + jraJobId: '00000000-0000-4000-8000-00000000000b', + resultManifestId: '00000000-0000-4000-8000-00000000000c', + outcome: 'UNDO_AVAILABLE', + affectedCount: 2, + handledCount: 2, + exceptionCount: 0, + reasonCodes: [], + undoState: 'AVAILABLE', + updatedAt: '2026-08-04T00:00:00.000Z', + }, + ], + exceptions: [ + { + exceptionId: '00000000-0000-4000-8000-00000000000d', + assignmentId: '00000000-0000-4000-8000-000000000002', + executionId: '00000000-0000-4000-8000-00000000000a', + severity: 'WARNING', + reasonCode: 'DESTINATION_COLLISION', + status: 'OPEN', + createdAt: '2026-08-04T00:00:00.000Z', + }, + ], + health: [ + { + assignmentId: '00000000-0000-4000-8000-000000000002', + watcherState: 'HEALTHY', + lastHeartbeatAt: '2026-08-04T00:00:00.000Z', + queueAgeSeconds: 2, + queuedCount: 1, + syncLagSeconds: 0, + }, + ], +}; + +describe('Folder Autopilot workspace surface', () => { + it('renders authoring, preview, approval, exception, and undo projections without paths', async () => { + const fetchMock = vi + .fn() + .mockImplementation(() => + Promise.resolve(new Response(JSON.stringify(dashboard), { status: 200 })), + ); + vi.stubGlobal('fetch', fetchMock); + const router = createAppRouter({ initialEntries: ['/en/autopilot'] }); + render(); + + expect(await screen.findByRole('heading', { name: 'Folder Autopilot' })).toBeTruthy(); + const asyncQueryOptions = { timeout: 5_000 }; + expect( + await screen.findByRole('heading', { name: 'Profiles' }, asyncQueryOptions), + ).toBeTruthy(); + expect( + await screen.findByRole('heading', { name: 'Assignments' }, asyncQueryOptions), + ).toBeTruthy(); + expect( + await screen.findByRole('heading', { name: 'Approval queue' }, asyncQueryOptions), + ).toBeTruthy(); + expect( + await screen.findByRole('heading', { name: 'Exceptions' }, asyncQueryOptions), + ).toBeTruthy(); + expect( + await screen.findByRole('heading', { name: 'Recent outcomes' }, asyncQueryOptions), + ).toBeTruthy(); + expect( + await screen.findByText('Invoice intake assignment', {}, asyncQueryOptions), + ).toBeTruthy(); + expect(screen.queryByText(/sourceArtifactVersionId|sourcePath|localHandle/iu)).toBeNull(); + }); + + it('pauses an assignment and approves the exact preview plan through safe mutations', async () => { + const fetchMock = vi.fn().mockImplementation((input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/v1/autopilot-dashboard')) + return Promise.resolve(new Response(JSON.stringify(dashboard), { status: 200 })); + return Promise.resolve( + new Response(JSON.stringify({ accepted: true, value: {} }), { status: 200 }), + ); + }); + vi.stubGlobal('fetch', fetchMock); + const user = userEvent.setup(); + const router = createAppRouter({ initialEntries: ['/en/autopilot'] }); + render(); + + const asyncQueryOptions = { timeout: 5_000 }; + await user.click( + await screen.findByRole('button', { name: 'Pause assignment' }, asyncQueryOptions), + ); + expect(await screen.findByText('Paused', { selector: 'span' }, asyncQueryOptions)).toBeTruthy(); + await user.click(screen.getByRole('button', { name: 'Approve preview' })); + expect( + await screen.findByText('Approved', { selector: 'span' }, asyncQueryOptions), + ).toBeTruthy(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); + const mutationBodies = fetchMock.mock.calls + .slice(1) + .map(([, init]) => String((init as RequestInit).body ?? '')) + .join('\n'); + expect(mutationBodies).not.toMatch(/path|bytes|formula|sourceValue|localHandle/iu); + }, 20_000); +}); From cc5c8e0411b9712ea0874f82acdaf1ea39032bb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:28:12 +0700 Subject: [PATCH 16/62] feat(desktop): add guarded local action execution --- .../folder-autopilot/local-actions.ts | 147 ++++++++++++++++++ .../folder-autopilot-local-actions.test.ts | 117 ++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 apps/desktop/src/features/folder-autopilot/local-actions.ts create mode 100644 apps/desktop/test/folder-autopilot-local-actions.test.ts diff --git a/apps/desktop/src/features/folder-autopilot/local-actions.ts b/apps/desktop/src/features/folder-autopilot/local-actions.ts new file mode 100644 index 00000000..cb8f2945 --- /dev/null +++ b/apps/desktop/src/features/folder-autopilot/local-actions.ts @@ -0,0 +1,147 @@ +export type LocalAction = 'INSPECT' | 'VALIDATE' | 'RENAME' | 'COPY' | 'MOVE'; +export type LocalCollisionPolicy = 'REVIEW' | 'SKIP' | 'UNIQUE_NAME'; +export type LocalActionCode = + | 'APPROVAL_REQUIRED' + | 'DESTINATION_COLLISION' + | 'DESTINATION_RECURSION' + | 'INVALID_PLAN' + | 'LOCAL_IO_FAILED' + | 'STALE_PLAN'; + +export class LocalActionError extends Error { + readonly code: LocalActionCode; + + constructor(code: LocalActionCode) { + super(code); + this.name = 'LocalActionError'; + this.code = code; + } +} + +export interface LocalPathGuard { + assertContained(candidate: string): string; +} + +export interface LocalFileSystem { + exists(path: string): Promise; + readFingerprint(path: string): Promise; + copyExclusive(source: string, destination: string): Promise; + rename(source: string, destination: string): Promise; +} + +export interface LocalActionOperation { + readonly operationId: string; + readonly action: LocalAction; + readonly sourcePath: string; + readonly destinationPath?: string; + readonly sourceFingerprint: string; + readonly collisionPolicy?: LocalCollisionPolicy; + readonly approved?: boolean; +} + +export interface LocalActionPlan { + readonly operations: readonly LocalActionOperation[]; +} + +export interface LocalActionDependencies { + readonly sourceGuard: LocalPathGuard; + readonly destinationGuard: LocalPathGuard; + readonly fileSystem: LocalFileSystem; +} + +export interface LocalActionReceipt { + readonly operationId: string; + readonly action: LocalAction; + readonly status: 'APPLIED' | 'SKIPPED'; +} + +const MAX_OPERATIONS = 100; +const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; +const SHA256 = /^[0-9a-f]{64}$/; + +function reject(code: LocalActionCode): never { + throw new LocalActionError(code); +} + +function isWriteAction(action: LocalAction): boolean { + return action === 'RENAME' || action === 'COPY' || action === 'MOVE'; +} + +function validateOperation(operation: LocalActionOperation): void { + if ( + typeof operation !== 'object' || + operation === null || + !SAFE_ID.test(operation.operationId) || + !['INSPECT', 'VALIDATE', 'RENAME', 'COPY', 'MOVE'].includes(operation.action) || + typeof operation.sourcePath !== 'string' || + operation.sourcePath.length === 0 || + operation.sourcePath.includes('\0') || + !SHA256.test(operation.sourceFingerprint) + ) { + return reject('INVALID_PLAN'); + } + if (isWriteAction(operation.action)) { + if ( + typeof operation.destinationPath !== 'string' || + operation.destinationPath.length === 0 || + operation.destinationPath.includes('\0') + ) { + return reject('INVALID_PLAN'); + } + if ( + operation.collisionPolicy !== undefined && + !['REVIEW', 'SKIP', 'UNIQUE_NAME'].includes(operation.collisionPolicy) + ) { + return reject('INVALID_PLAN'); + } + if (operation.action === 'MOVE' && operation.approved !== true) { + return reject('APPROVAL_REQUIRED'); + } + } else if (operation.destinationPath !== undefined || operation.collisionPolicy !== undefined) { + return reject('INVALID_PLAN'); + } +} + +export async function executeLocalPlan( + plan: LocalActionPlan, + { sourceGuard, destinationGuard, fileSystem }: LocalActionDependencies, +): Promise { + const candidate: unknown = plan; + if (typeof candidate !== 'object' || candidate === null) return reject('INVALID_PLAN'); + const operationsValue: unknown = (candidate as { readonly operations?: unknown }).operations; + if (!Array.isArray(operationsValue) || operationsValue.length > MAX_OPERATIONS) { + return reject('INVALID_PLAN'); + } + const operations = operationsValue as readonly LocalActionOperation[]; + + const receipts: LocalActionReceipt[] = []; + for (const operation of operations) { + validateOperation(operation); + const source = sourceGuard.assertContained(operation.sourcePath); + const expectedFingerprint = await fileSystem.readFingerprint(source); + if (expectedFingerprint !== operation.sourceFingerprint) return reject('STALE_PLAN'); + + if (!isWriteAction(operation.action)) { + receipts.push({ operationId: operation.operationId, action: operation.action, status: 'APPLIED' }); + continue; + } + + const destination = destinationGuard.assertContained(operation.destinationPath as string); + if (source.toLowerCase() === destination.toLowerCase()) return reject('DESTINATION_RECURSION'); + if (await fileSystem.exists(destination)) { + if (operation.collisionPolicy === 'SKIP') { + receipts.push({ operationId: operation.operationId, action: operation.action, status: 'SKIPPED' }); + continue; + } + return reject('DESTINATION_COLLISION'); + } + try { + if (operation.action === 'COPY') await fileSystem.copyExclusive(source, destination); + else await fileSystem.rename(source, destination); + } catch { + return reject('LOCAL_IO_FAILED'); + } + receipts.push({ operationId: operation.operationId, action: operation.action, status: 'APPLIED' }); + } + return receipts; +} diff --git a/apps/desktop/test/folder-autopilot-local-actions.test.ts b/apps/desktop/test/folder-autopilot-local-actions.test.ts new file mode 100644 index 00000000..fb8a2f9b --- /dev/null +++ b/apps/desktop/test/folder-autopilot-local-actions.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + LocalActionError, + executeLocalPlan, + type LocalActionPlan, + type LocalFileSystem, +} from '../src/features/folder-autopilot/local-actions.ts'; + +const sourcePath = 'C:\\Approved\\invoice.csv'; +const destinationPath = 'C:\\Output\\invoice-reviewed.csv'; + +function dependencies(overrides: Partial = {}) { + const fileSystem: LocalFileSystem = { + exists: vi.fn(() => Promise.resolve(false)), + readFingerprint: vi.fn(() => Promise.resolve('a'.repeat(64))), + copyExclusive: vi.fn(() => Promise.resolve()), + rename: vi.fn(() => Promise.resolve()), + ...overrides, + }; + return { + fileSystem, + sourceGuard: { assertContained: vi.fn((value: string) => value) }, + destinationGuard: { assertContained: vi.fn((value: string) => value) }, + }; +} + +function plan(...operations: LocalActionPlan['operations']): LocalActionPlan { + return { operations }; +} + +describe('Folder Autopilot local typed actions', () => { + it('evaluates inspect and validate without a filesystem mutation', async () => { + const deps = dependencies(); + const copyExclusive = vi.spyOn(deps.fileSystem, 'copyExclusive'); + const rename = vi.spyOn(deps.fileSystem, 'rename'); + const result = await executeLocalPlan( + plan( + { operationId: 'inspect-1', action: 'INSPECT', sourcePath, sourceFingerprint: 'a'.repeat(64) }, + { operationId: 'validate-1', action: 'VALIDATE', sourcePath, sourceFingerprint: 'a'.repeat(64) }, + ), + deps, + ); + + expect(result.map((item) => item.status)).toEqual(['APPLIED', 'APPLIED']); + expect(copyExclusive).not.toHaveBeenCalled(); + expect(rename).not.toHaveBeenCalled(); + }); + + it('revalidates containment and source fingerprint before a rename', async () => { + const deps = dependencies(); + const rename = vi.spyOn(deps.fileSystem, 'rename'); + const result = await executeLocalPlan( + plan({ + operationId: 'rename-1', + action: 'RENAME', + sourcePath, + destinationPath, + sourceFingerprint: 'a'.repeat(64), + }), + deps, + ); + + expect(result[0].status).toBe('APPLIED'); + expect(deps.sourceGuard.assertContained).toHaveBeenCalledWith(sourcePath); + expect(deps.destinationGuard.assertContained).toHaveBeenCalledWith(destinationPath); + expect(rename).toHaveBeenCalledWith(sourcePath, destinationPath); + }); + + it('never overwrites a destination and handles SKIP explicitly', async () => { + const deps = dependencies({ exists: vi.fn(() => Promise.resolve(true)) }); + const copyExclusive = vi.spyOn(deps.fileSystem, 'copyExclusive'); + const result = await executeLocalPlan( + plan({ + operationId: 'copy-1', + action: 'COPY', + sourcePath, + destinationPath, + sourceFingerprint: 'a'.repeat(64), + collisionPolicy: 'SKIP', + }), + deps, + ); + + expect(result).toEqual([{ operationId: 'copy-1', action: 'COPY', status: 'SKIPPED' }]); + expect(copyExclusive).not.toHaveBeenCalled(); + }); + + it('fails closed for collisions, stale plans, and unknown local effects', async () => { + const collisionDeps = dependencies({ exists: vi.fn(() => Promise.resolve(true)) }); + await expect( + executeLocalPlan( + plan({ + operationId: 'copy-1', + action: 'COPY', + sourcePath, + destinationPath, + sourceFingerprint: 'a'.repeat(64), + }), + collisionDeps, + ), + ).rejects.toMatchObject({ code: 'DESTINATION_COLLISION' }); + + const staleDeps = dependencies({ readFingerprint: vi.fn(() => Promise.resolve('b'.repeat(64))) }); + await expect( + executeLocalPlan( + plan({ + operationId: 'rename-1', + action: 'RENAME', + sourcePath, + destinationPath, + sourceFingerprint: 'a'.repeat(64), + }), + staleDeps, + ), + ).rejects.toMatchObject({ code: 'STALE_PLAN' }); + }); +}); From 7678c2654f727f393cfef8799101a3c30855ca58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:29:03 +0700 Subject: [PATCH 17/62] feat(fa): expose tenant-scoped Nest API --- services/api/src/app.module.ts | 5 +- services/api/src/bootstrap.ts | 4 +- .../fa/api/folder-autopilot.controller.ts | 150 ++++++++++++++++ .../features/fa/api/folder-autopilot.dto.ts | 164 ++++++++++++++++++ .../application/folder-autopilot.service.ts | 5 + services/api/src/features/fa/fa.module.ts | 70 ++++++++ .../fa/folder-autopilot.controller.test.ts | 138 +++++++++++++++ 7 files changed, 534 insertions(+), 2 deletions(-) create mode 100644 services/api/src/features/fa/api/folder-autopilot.controller.ts create mode 100644 services/api/src/features/fa/api/folder-autopilot.dto.ts create mode 100644 services/api/src/features/fa/fa.module.ts create mode 100644 services/api/test/features/fa/folder-autopilot.controller.test.ts diff --git a/services/api/src/app.module.ts b/services/api/src/app.module.ts index b36c5134..05c12445 100644 --- a/services/api/src/app.module.ts +++ b/services/api/src/app.module.ts @@ -8,6 +8,7 @@ import { DsoModule, type DsoModuleOptions } from './features/dso/dso.module.js'; import { AudModule, type AudModuleOptions } from './features/aud/aud.module.js'; import { BuaModule, type BuaModuleOptions } from './features/bua/bua.module.js'; import { SaModule, type SaModuleOptions } from './features/sa/sa.module.js'; +import { FaModule, type FaModuleOptions } from './features/fa/fa.module.js'; import { SessionRequestTenantContextAdapter } from './platform/http/session-tenant-context.adapter.js'; import { PrismaSessionLifecycleAdapter } from './features/iam/adapter/prisma-session-lifecycle.adapter.js'; @@ -18,7 +19,8 @@ export type AppModuleOptions = SystemModuleOptions & DsoModuleOptions & AudModuleOptions & BuaModuleOptions & - SaModuleOptions; + SaModuleOptions & + FaModuleOptions; @Module({}) export class AppModule { @@ -57,6 +59,7 @@ export class AppModule { AudModule.register(composedOptions), BuaModule.register(composedOptions), SaModule.register(composedOptions), + FaModule.register(composedOptions), ], }; } diff --git a/services/api/src/bootstrap.ts b/services/api/src/bootstrap.ts index 630aa3fd..9fa650eb 100644 --- a/services/api/src/bootstrap.ts +++ b/services/api/src/bootstrap.ts @@ -12,6 +12,7 @@ import type { DsoModuleOptions } from './features/dso/dso.module.js'; import type { AudModuleOptions } from './features/aud/aud.module.js'; import type { BuaModuleOptions } from './features/bua/bua.module.js'; import type { SaModuleOptions } from './features/sa/sa.module.js'; +import type { FaModuleOptions } from './features/fa/fa.module.js'; import type { ClientCompatibilityPort } from './features/system/application/client-compatibility.port.js'; import type { ReadinessPort } from './features/system/application/readiness.port.js'; import { ProblemDetailsFilter } from './platform/http/problem-details.filter.js'; @@ -34,7 +35,8 @@ export interface ApiApplicationOptions DsoModuleOptions, AudModuleOptions, BuaModuleOptions, - SaModuleOptions { + SaModuleOptions, + FaModuleOptions { readonly compatibilityPort?: ClientCompatibilityPort; readonly readinessPort?: ReadinessPort; readonly requestContext?: RequestContextOptions; diff --git a/services/api/src/features/fa/api/folder-autopilot.controller.ts b/services/api/src/features/fa/api/folder-autopilot.controller.ts new file mode 100644 index 00000000..6bc2a860 --- /dev/null +++ b/services/api/src/features/fa/api/folder-autopilot.controller.ts @@ -0,0 +1,150 @@ +import { + Body, + Controller, + Get, + Inject, + Param, + Patch, + Post, + Query, + Req, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; + +import { + FOLDER_AUTOPILOT_SERVICE, + FolderAutopilotService, +} from '../application/folder-autopilot.service.js'; +import { + CreateAutopilotFolderBindingDto, + CreateFolderAutopilotProfileDto, + CreateRecipeAssignmentDto, + UpdateRecipeAssignmentDto, +} from './folder-autopilot.dto.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, +} from '../../../platform/http/request-tenant-context.port.js'; + +@ApiTags('folder-autopilot') +@ApiBearerAuth() +@Controller('v1') +export class FolderAutopilotController { + public constructor( + @Inject(FOLDER_AUTOPILOT_SERVICE) private readonly service: FolderAutopilotService, + @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, + ) {} + + @Post('autopilot-profiles') + @ApiOperation({ summary: 'Register an immutable, content-free Folder Autopilot profile' }) + @ApiBody({ type: CreateFolderAutopilotProfileDto }) + public async createProfile( + @Req() request: unknown, + @Body() input: CreateFolderAutopilotProfileDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.service.createProfile(context, input); + } + + @Get('autopilot-profiles') + @ApiOperation({ summary: 'List Folder Autopilot profile versions visible to the tenant' }) + public async listProfiles(@Req() request: unknown): Promise { + const context = await this.requestContext.resolve(request); + return this.service.listProfiles(context); + } + + @Get('autopilot-profiles/:profileId') + @ApiOperation({ summary: 'Read an exact immutable Folder Autopilot profile version' }) + @ApiQuery({ name: 'version', required: false, type: 'integer', minimum: 1 }) + public async findProfile( + @Req() request: unknown, + @Param('profileId') profileId: string, + @Query('version') version?: string, + ): Promise { + const context = await this.requestContext.resolve(request); + const parsedVersion = version === undefined ? undefined : Number(version); + return this.service.findProfile(context, profileId, parsedVersion); + } + + @Post('autopilot-folder-bindings') + @ApiOperation({ summary: 'Register an opaque DSO-backed Folder Autopilot binding' }) + @ApiBody({ type: CreateAutopilotFolderBindingDto }) + public async createBinding( + @Req() request: unknown, + @Body() input: CreateAutopilotFolderBindingDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.service.createBinding(context, input); + } + + @Get('autopilot-folder-bindings') + @ApiOperation({ summary: 'List opaque Folder Autopilot bindings visible to the tenant' }) + public async listBindings(@Req() request: unknown): Promise { + const context = await this.requestContext.resolve(request); + return this.service.listBindings(context); + } + + @Get('autopilot-folder-bindings/:bindingId') + @ApiOperation({ summary: 'Read an opaque Folder Autopilot binding' }) + public async findBinding( + @Req() request: unknown, + @Param('bindingId') bindingId: string, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.service.findBinding(context, bindingId); + } + + @Post('autopilot-assignments') + @ApiOperation({ summary: 'Create a tenant-scoped Folder Autopilot assignment projection' }) + @ApiBody({ type: CreateRecipeAssignmentDto }) + public async createAssignment( + @Req() request: unknown, + @Body() input: CreateRecipeAssignmentDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.service.createAssignment(context, { + ...input, + idempotencyKey: context.idempotencyKey, + }); + } + + @Get('autopilot-assignments') + @ApiOperation({ summary: 'List Folder Autopilot assignments visible to the tenant' }) + public async listAssignments(@Req() request: unknown): Promise { + const context = await this.requestContext.resolve(request); + return this.service.listAssignments(context); + } + + @Get('autopilot-assignments/:assignmentId') + @ApiOperation({ summary: 'Read a tenant-scoped Folder Autopilot assignment' }) + public async findAssignment( + @Req() request: unknown, + @Param('assignmentId') assignmentId: string, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.service.findAssignment(context, assignmentId); + } + + @Patch('autopilot-assignments/:assignmentId') + @ApiOperation({ summary: 'Advance an assignment projection with optimistic concurrency' }) + @ApiBody({ type: UpdateRecipeAssignmentDto }) + public async updateAssignment( + @Req() request: unknown, + @Param('assignmentId') assignmentId: string, + @Body() input: UpdateRecipeAssignmentDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.service.updateAssignmentState( + context, + assignmentId, + input.expectedRevision, + input.state, + ); + } +} diff --git a/services/api/src/features/fa/api/folder-autopilot.dto.ts b/services/api/src/features/fa/api/folder-autopilot.dto.ts new file mode 100644 index 00000000..bee16f9a --- /dev/null +++ b/services/api/src/features/fa/api/folder-autopilot.dto.ts @@ -0,0 +1,164 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayMaxSize, + ArrayMinSize, + ArrayUnique, + IsArray, + IsBoolean, + IsIn, + IsInt, + IsISO8601, + IsOptional, + IsString, + IsUUID, + Matches, + Max, + MaxLength, + Min, +} from 'class-validator'; + +const sha256Pattern = '^[0-9a-f]{64}$'; +const strictUtcTimestampPattern = '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$'; + +export class CreateFolderAutopilotProfileDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + profileId!: string; + + @ApiProperty({ type: 'integer', minimum: 1, maximum: 10000 }) + @IsInt() + @Min(1) + @Max(10_000) + version!: number; + + @ApiProperty({ pattern: sha256Pattern }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + payloadHash!: string; + + @ApiProperty({ type: 'integer', minimum: 0, maximum: 86400000 }) + @IsInt() + @Min(0) + @Max(86_400_000) + stabilizationDelayMs!: number; + + @ApiProperty({ type: 'integer', minimum: 1, maximum: 100000 }) + @IsInt() + @Min(1) + @Max(100_000) + maxFilesPerScan!: number; + + @ApiProperty({ enum: ['REVIEW', 'SKIP', 'UNIQUE_NAME'] }) + @IsIn(['REVIEW', 'SKIP', 'UNIQUE_NAME']) + collisionPolicy!: 'REVIEW' | 'SKIP' | 'UNIQUE_NAME'; + + @ApiProperty({ type: 'integer', minimum: 0, maximum: 604800 }) + @IsInt() + @Min(0) + @Max(604_800) + undoWindowSeconds!: number; + + @ApiProperty() + @IsBoolean() + outputLineageEnabled!: boolean; + + @ApiProperty({ format: 'date-time', pattern: strictUtcTimestampPattern }) + @IsISO8601({ strict: true, strictSeparator: true }) + @Matches(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u) + createdAt!: string; +} + +export class CreateAutopilotFolderBindingDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + bindingId!: string; + + @ApiProperty({ format: 'uuid', description: 'Opaque DSO DeviceGrant identifier.' }) + @IsUUID() + deviceGrantId!: string; + + @ApiProperty({ enum: ['INPUT', 'OUTPUT'] }) + @IsIn(['INPUT', 'OUTPUT']) + role!: 'INPUT' | 'OUTPUT'; + + @ApiProperty({ pattern: sha256Pattern }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + expectedCapabilityDigest!: string; + + @ApiProperty({ format: 'date-time', pattern: strictUtcTimestampPattern }) + @IsISO8601({ strict: true, strictSeparator: true }) + @Matches(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u) + createdAt!: string; +} + +export class CreateRecipeAssignmentDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + assignmentId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + profileId!: string; + + @ApiProperty({ type: 'integer', minimum: 1, maximum: 10000 }) + @IsInt() + @Min(1) + @Max(10_000) + profileVersion!: number; + + @ApiProperty({ pattern: sha256Pattern }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + profileHash!: string; + + @ApiProperty({ format: 'uuid', description: 'Opaque JRA RecipeVersion identifier.' }) + @IsUUID() + jraRecipeVersionId!: string; + + @ApiProperty({ pattern: sha256Pattern }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + jraRecipeVersionHash!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + deviceId!: string; + + @ApiProperty({ type: [String], minItems: 1, maxItems: 32, format: 'uuid' }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(32) + @ArrayUnique() + @IsUUID(undefined, { each: true }) + inputBindingIds!: string[]; + + @ApiProperty({ type: [String], minItems: 1, maxItems: 32, format: 'uuid' }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(32) + @ArrayUnique() + @IsUUID(undefined, { each: true }) + outputBindingIds!: string[]; + + @ApiPropertyOptional({ enum: ['LOCAL', 'HYBRID', 'CLOUD'] }) + @IsOptional() + @IsIn(['LOCAL', 'HYBRID', 'CLOUD']) + dataModeConstraint?: 'LOCAL' | 'HYBRID' | 'CLOUD'; + + @ApiProperty({ format: 'date-time', pattern: strictUtcTimestampPattern }) + @IsISO8601({ strict: true, strictSeparator: true }) + @Matches(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u) + createdAt!: string; +} + +export class UpdateRecipeAssignmentDto { + @ApiProperty({ type: 'integer', minimum: 1 }) + @IsInt() + @Min(1) + expectedRevision!: number; + + @ApiProperty({ enum: ['DRAFT', 'ACTIVE', 'PAUSED', 'RETIRED'] }) + @IsIn(['DRAFT', 'ACTIVE', 'PAUSED', 'RETIRED']) + state!: 'DRAFT' | 'ACTIVE' | 'PAUSED' | 'RETIRED'; +} diff --git a/services/api/src/features/fa/application/folder-autopilot.service.ts b/services/api/src/features/fa/application/folder-autopilot.service.ts index 54fe6cd4..3bcd0053 100644 --- a/services/api/src/features/fa/application/folder-autopilot.service.ts +++ b/services/api/src/features/fa/application/folder-autopilot.service.ts @@ -255,6 +255,11 @@ export class FolderAutopilotService { ): Promise> { const profileId = parseId(profileIdInput); if (!profileId) return rejected('INVALID_IDENTIFIER'); + if ( + version !== undefined && + (!Number.isSafeInteger(version) || version < 1 || version > 10_000) + ) + return rejected('INVALID_VERSION'); const value = await this.repository.findProfile(context, profileId, version); return value ? Object.freeze({ accepted: true, value }) : rejected('FA_PROFILE_NOT_FOUND'); } diff --git a/services/api/src/features/fa/fa.module.ts b/services/api/src/features/fa/fa.module.ts new file mode 100644 index 00000000..9807de1e --- /dev/null +++ b/services/api/src/features/fa/fa.module.ts @@ -0,0 +1,70 @@ +import { type DynamicModule, Module } from '@nestjs/common'; + +import { FolderAutopilotController } from './api/folder-autopilot.controller.js'; +import { InMemoryFolderAutopilotRepositoryAdapter } from './adapter/in-memory-folder-autopilot-repository.adapter.js'; +import { + PrismaFolderAutopilotRepositoryAdapter, + type FolderAutopilotDatabaseClientV1, +} from './adapter/prisma-folder-autopilot-repository.adapter.js'; +import { + FOLDER_AUTOPILOT_DATA_MODE_POLICY_PORT, + FOLDER_AUTOPILOT_SERVICE, + FolderAutopilotService, + type FolderAutopilotDataModePolicyPortV1, + UnavailableFolderAutopilotDataModePolicyAdapter, +} from './application/folder-autopilot.service.js'; +import { + FOLDER_AUTOPILOT_REPOSITORY_PORT, + type FolderAutopilotRepositoryPortV1, +} from './application/folder-autopilot-repository.port.js'; +import { + REQUEST_TENANT_CONTEXT, + type RequestTenantContextPortV1, + UnavailableRequestTenantContextAdapter, +} from '../../platform/http/request-tenant-context.port.js'; + +export interface FaModuleOptions { + /** Production composition passes the generated Prisma client; tests may use in-memory state. */ + readonly folderAutopilotDatabase?: FolderAutopilotDatabaseClientV1; + readonly folderAutopilotRepository?: FolderAutopilotRepositoryPortV1; + /** DSO owns policy authority; FA receives only this narrow facade. */ + readonly folderAutopilotDataModePolicy?: FolderAutopilotDataModePolicyPortV1; + readonly requestTenantContext?: RequestTenantContextPortV1; +} + +@Module({}) +export class FaModule { + public static register(options: FaModuleOptions = {}): DynamicModule { + const repository = + options.folderAutopilotRepository ?? + (options.folderAutopilotDatabase === undefined + ? new InMemoryFolderAutopilotRepositoryAdapter() + : new PrismaFolderAutopilotRepositoryAdapter(options.folderAutopilotDatabase)); + const dataModePolicy = + options.folderAutopilotDataModePolicy ?? new UnavailableFolderAutopilotDataModePolicyAdapter(); + return { + module: FaModule, + controllers: [FolderAutopilotController], + providers: [ + { provide: FOLDER_AUTOPILOT_REPOSITORY_PORT, useValue: repository }, + { + provide: FOLDER_AUTOPILOT_DATA_MODE_POLICY_PORT, + useValue: dataModePolicy, + }, + { + provide: FOLDER_AUTOPILOT_SERVICE, + useFactory: ( + folderRepository: FolderAutopilotRepositoryPortV1, + policy: FolderAutopilotDataModePolicyPortV1 | undefined, + ): FolderAutopilotService => new FolderAutopilotService(folderRepository, policy), + inject: [FOLDER_AUTOPILOT_REPOSITORY_PORT, FOLDER_AUTOPILOT_DATA_MODE_POLICY_PORT], + }, + { + provide: REQUEST_TENANT_CONTEXT, + useValue: options.requestTenantContext ?? new UnavailableRequestTenantContextAdapter(), + }, + ], + exports: [FOLDER_AUTOPILOT_REPOSITORY_PORT, FOLDER_AUTOPILOT_SERVICE], + }; + } +} diff --git a/services/api/test/features/fa/folder-autopilot.controller.test.ts b/services/api/test/features/fa/folder-autopilot.controller.test.ts new file mode 100644 index 00000000..3b1aa867 --- /dev/null +++ b/services/api/test/features/fa/folder-autopilot.controller.test.ts @@ -0,0 +1,138 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; + +import { createApiApplication } from '../../../src/bootstrap.js'; +import { createIamTenantContextV1 } from '../../../src/features/iam/application/tenant-context.js'; +import { InMemoryFolderAutopilotRepositoryAdapter } from '../../../src/features/fa/adapter/in-memory-folder-autopilot-repository.adapter.js'; +import type { FolderAutopilotDataModePolicyPortV1 } from '../../../src/features/fa/application/folder-autopilot.service.js'; +import type { RequestTenantContextPortV1 } from '../../../src/platform/http/request-tenant-context.port.js'; + +const ids = { + organizationId: '11111111-1111-4111-8111-111111111111', + workspaceId: '22222222-2222-4222-8222-222222222222', + profileId: '33333333-3333-4333-8333-333333333333', + inputBindingId: '44444444-4444-4444-8444-444444444444', + outputBindingId: '55555555-5555-4555-8555-555555555555', + deviceGrantId: '66666666-6666-4666-8666-666666666666', + deviceId: '77777777-7777-4777-8777-777777777777', + recipeId: '88888888-8888-4888-8888-888888888888', + policyVersionId: '99999999-9999-4999-8999-999999999999', +}; + +function context(workspaceId = ids.workspaceId, idempotencyKey = 'fa-http') { + const result = createIamTenantContextV1({ + actorId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + correlationId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + tenantScope: { scopeType: 'workspace', organizationId: ids.organizationId, workspaceId }, + authorizationEpoch: 1, + idempotencyKey, + }); + assert.equal(result.accepted, true); + if (!result.accepted) throw new Error('invalid context fixture'); + return result.value; +} + +const profile = { + profileId: ids.profileId, + version: 1, + payloadHash: 'a'.repeat(64), + stabilizationDelayMs: 1_000, + maxFilesPerScan: 100, + collisionPolicy: 'REVIEW', + undoWindowSeconds: 3_600, + outputLineageEnabled: true, + createdAt: '2026-08-04T00:00:00.000Z', +}; + +const policy: FolderAutopilotDataModePolicyPortV1 = { + resolveNarrowed: async (_context, requested) => + requested === 'LOCAL' + ? { accepted: true, value: { effectiveDataModePolicyRef: ids.policyVersionId } } + : { accepted: false, code: 'DATA_MODE_BROADENS_WORKSPACE' }, +}; + +void test('[FA-001..FA-007, FA-014, FA-015, FA-031] HTTP is tenant-scoped and content-free', async () => { + let current = context(); + const requestTenantContext: RequestTenantContextPortV1 = { + resolve: () => Promise.resolve(current), + }; + const repository = new InMemoryFolderAutopilotRepositoryAdapter(); + const { app } = await createApiApplication({ + requestTenantContext, + folderAutopilotRepository: repository, + folderAutopilotDataModePolicy: policy, + }); + try { + const rejectedUnknown = await app.inject({ + method: 'POST', + url: '/v1/autopilot-profiles', + payload: { ...profile, tenantScope: current.tenantScope, path: 'C:\\secret' }, + }); + assert.equal(rejectedUnknown.statusCode, 400); + + const createdProfile = await app.inject({ + method: 'POST', + url: '/v1/autopilot-profiles', + payload: profile, + }); + assert.equal(createdProfile.statusCode, 201); + assert.equal(createdProfile.json().accepted, true); + + for (const [bindingId, role] of [ + [ids.inputBindingId, 'INPUT'], + [ids.outputBindingId, 'OUTPUT'], + ] as const) { + const createdBinding = await app.inject({ + method: 'POST', + url: '/v1/autopilot-folder-bindings', + payload: { + bindingId, + deviceGrantId: ids.deviceGrantId, + role, + expectedCapabilityDigest: 'b'.repeat(64), + createdAt: profile.createdAt, + }, + }); + assert.equal(createdBinding.statusCode, 201); + assert.equal(createdBinding.json().accepted, true); + } + + const createdAssignment = await app.inject({ + method: 'POST', + url: '/v1/autopilot-assignments', + payload: { + assignmentId: ids.recipeId, + profileId: ids.profileId, + profileVersion: 1, + profileHash: profile.payloadHash, + jraRecipeVersionId: ids.recipeId, + jraRecipeVersionHash: 'c'.repeat(64), + deviceId: ids.deviceId, + inputBindingIds: [ids.inputBindingId], + outputBindingIds: [ids.outputBindingId], + dataModeConstraint: 'LOCAL', + createdAt: profile.createdAt, + }, + }); + assert.equal(createdAssignment.statusCode, 201); + assert.equal(createdAssignment.json().value.effectiveDataModePolicyRef, ids.policyVersionId); + + const patched = await app.inject({ + method: 'PATCH', + url: `/v1/autopilot-assignments/${ids.recipeId}`, + payload: { expectedRevision: 1, state: 'ACTIVE' }, + }); + assert.equal(patched.statusCode, 200); + assert.equal(patched.json().value.revision, 2); + + current = context('ffffffff-ffff-4fff-8fff-ffffffffffff', 'fa-sibling'); + const siblingRead = await app.inject({ + method: 'GET', + url: `/v1/autopilot-profiles/${ids.profileId}`, + }); + assert.equal(siblingRead.statusCode, 200); + assert.deepEqual(siblingRead.json(), { accepted: false, code: 'FA_PROFILE_NOT_FOUND' }); + } finally { + await app.close(); + } +}); From 6d5b2fef5921efef65e5856c5901f6053adcd7a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:32:04 +0700 Subject: [PATCH 18/62] feat(desktop): journal staged actions and undo plans --- .../folder-autopilot/local-journal.ts | 235 ++++++++++++++++++ .../test/folder-autopilot-journal.test.ts | 125 ++++++++++ 2 files changed, 360 insertions(+) create mode 100644 apps/desktop/src/features/folder-autopilot/local-journal.ts create mode 100644 apps/desktop/test/folder-autopilot-journal.test.ts diff --git a/apps/desktop/src/features/folder-autopilot/local-journal.ts b/apps/desktop/src/features/folder-autopilot/local-journal.ts new file mode 100644 index 00000000..5c4ebefc --- /dev/null +++ b/apps/desktop/src/features/folder-autopilot/local-journal.ts @@ -0,0 +1,235 @@ +export type JournalState = + | 'PREPARED' + | 'COMMITTING' + | 'COMMITTED' + | 'COMPENSATING' + | 'COMPENSATED' + | 'CONFLICT'; +export type JournalStepState = 'PENDING' | 'COMMITTED' | 'COMPENSATED'; +export type JournalAction = 'RENAME' | 'COPY' | 'MOVE'; +export type JournalErrorCode = + | 'DUPLICATE_STEP' + | 'INVALID_JOURNAL' + | 'INVALID_TRANSITION' + | 'RECOVERY_CONFLICT' + | 'UNDO_CONFLICT' + | 'UNDO_EXPIRED' + | 'UNDO_NOT_AVAILABLE'; + +export class JournalError extends Error { + readonly code: JournalErrorCode; + + constructor(code: JournalErrorCode) { + super(code); + this.name = 'JournalError'; + this.code = code; + } +} + +export interface JournalStepInput { + readonly operationId: string; + readonly action: JournalAction; + readonly sourcePath: string; + readonly destinationPath: string; + readonly beforeFingerprint: string; + readonly undoable: boolean; +} + +export interface JournalStep extends JournalStepInput { + readonly state: JournalStepState; + readonly afterFingerprint: string | null; +} + +export interface LocalJournal { + readonly executionId: string; + readonly planHash: string; + readonly state: JournalState; + readonly steps: readonly JournalStep[]; + readonly createdAtMs: number; + readonly undoExpiresAtMs: number; + readonly revision: number; +} + +export interface UndoOperation { + readonly operationId: string; + readonly action: 'RENAME'; + readonly sourcePath: string; + readonly destinationPath: string; + readonly expectedSourceFingerprint: string; +} + +export interface UndoPlan { + readonly executionId: string; + readonly planHash: string; + readonly operations: readonly UndoOperation[]; +} + +const SHA256 = /^[0-9a-f]{64}$/; +const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; +const MAX_STEPS = 100; +const MIN_UNDO_WINDOW_MS = 60_000; +const MAX_UNDO_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; + +function reject(code: JournalErrorCode): never { + throw new JournalError(code); +} + +function cloneJournal(journal: LocalJournal, updates: Partial): LocalJournal { + return Object.freeze({ ...journal, ...updates, revision: journal.revision + 1 }); +} + +function validateStep(step: JournalStepInput): void { + if ( + typeof step !== 'object' || + step === null || + !SAFE_ID.test(step.operationId) || + !['RENAME', 'COPY', 'MOVE'].includes(step.action) || + typeof step.sourcePath !== 'string' || + typeof step.destinationPath !== 'string' || + step.sourcePath.length === 0 || + step.destinationPath.length === 0 || + step.sourcePath.includes('\0') || + step.destinationPath.includes('\0') || + !SHA256.test(step.beforeFingerprint) || + typeof step.undoable !== 'boolean' + ) { + return reject('INVALID_JOURNAL'); + } +} + +export function createJournal({ + executionId, + planHash, + steps, + nowMs, + undoWindowMs, +}: { + readonly executionId: string; + readonly planHash: string; + readonly steps: readonly JournalStepInput[]; + readonly nowMs: number; + readonly undoWindowMs: number; +}): LocalJournal { + if ( + !SAFE_ID.test(executionId) || + !SHA256.test(planHash) || + !Number.isSafeInteger(nowMs) || + !Number.isSafeInteger(undoWindowMs) || + undoWindowMs < MIN_UNDO_WINDOW_MS || + undoWindowMs > MAX_UNDO_WINDOW_MS || + steps.length === 0 || + steps.length > MAX_STEPS || + new Set(steps.map((step) => step.operationId)).size !== steps.length + ) { + return reject('INVALID_JOURNAL'); + } + steps.forEach(validateStep); + return Object.freeze({ + executionId, + planHash, + state: 'PREPARED', + steps: Object.freeze( + steps.map((step) => Object.freeze({ ...step, state: 'PENDING', afterFingerprint: null })), + ), + createdAtMs: nowMs, + undoExpiresAtMs: nowMs + undoWindowMs, + revision: 0, + }); +} + +export function beginJournal(journal: LocalJournal): LocalJournal { + if (journal.state !== 'PREPARED') return reject('INVALID_TRANSITION'); + return cloneJournal(journal, { state: 'COMMITTING' }); +} + +export function recordJournalStep( + journal: LocalJournal, + operationId: string, + afterFingerprint: string, +): LocalJournal { + if (journal.state !== 'COMMITTING' || !SHA256.test(afterFingerprint)) { + return reject('INVALID_TRANSITION'); + } + const index = journal.steps.findIndex((step) => step.operationId === operationId); + if (index < 0) return reject('DUPLICATE_STEP'); + const step = journal.steps[index]; + if (step === undefined) return reject('DUPLICATE_STEP'); + if (step.state !== 'PENDING') return reject('DUPLICATE_STEP'); + const nextSteps = journal.steps.slice(); + nextSteps[index] = Object.freeze({ ...step, state: 'COMMITTED', afterFingerprint }); + const nextState = nextSteps.every((candidate) => candidate.state === 'COMMITTED') + ? 'COMMITTED' + : 'COMMITTING'; + return cloneJournal(journal, { state: nextState, steps: Object.freeze(nextSteps) }); +} + +export function failJournal(journal: LocalJournal): LocalJournal { + if (journal.state !== 'COMMITTING' || !journal.steps.some((step) => step.state === 'COMMITTED')) { + return reject('INVALID_TRANSITION'); + } + return cloneJournal(journal, { state: 'COMPENSATING' }); +} + +export function compensateJournal(journal: LocalJournal, operationId: string): LocalJournal { + if (journal.state !== 'COMPENSATING') return reject('INVALID_TRANSITION'); + const index = journal.steps.findIndex((step) => step.operationId === operationId); + if (index < 0) return reject('DUPLICATE_STEP'); + const step = journal.steps[index]; + if (step === undefined) return reject('DUPLICATE_STEP'); + if (step.state !== 'COMMITTED') return reject('DUPLICATE_STEP'); + const nextSteps = journal.steps.slice(); + nextSteps[index] = Object.freeze({ ...step, state: 'COMPENSATED' }); + const nextState = nextSteps + .filter((candidate) => candidate.afterFingerprint !== null) + .every((candidate) => candidate.state === 'COMPENSATED') + ? 'COMPENSATED' + : 'COMPENSATING'; + return cloneJournal(journal, { state: nextState, steps: Object.freeze(nextSteps) }); +} + +export function recoverJournal( + journal: LocalJournal, + checkpoints: ReadonlyMap, +): LocalJournal { + if (journal.state !== 'COMMITTING') return reject('INVALID_TRANSITION'); + if ([...checkpoints.values()].some((state) => state === 'UNKNOWN')) { + return reject('RECOVERY_CONFLICT'); + } + const nextSteps = journal.steps.map((step) => { + if (checkpoints.get(step.operationId) === 'COMMITTED' && step.state === 'PENDING') { + return Object.freeze({ ...step, state: 'COMMITTED' as const }); + } + return step; + }); + return cloneJournal(journal, { steps: Object.freeze(nextSteps) }); +} + +export function buildUndoPlan( + journal: LocalJournal, + { + nowMs, + currentFingerprints, + }: { readonly nowMs: number; readonly currentFingerprints: ReadonlyMap }, +): UndoPlan { + if (journal.state !== 'COMMITTED') return reject('UNDO_NOT_AVAILABLE'); + if (nowMs > journal.undoExpiresAtMs) return reject('UNDO_EXPIRED'); + const operations: UndoOperation[] = []; + for (const step of [...journal.steps].reverse()) { + if (!step.undoable || step.afterFingerprint === null) return reject('UNDO_NOT_AVAILABLE'); + if (currentFingerprints.get(step.destinationPath) !== step.afterFingerprint) { + return reject('UNDO_CONFLICT'); + } + operations.push({ + operationId: `undo-${step.operationId}`, + action: 'RENAME', + sourcePath: step.destinationPath, + destinationPath: step.sourcePath, + expectedSourceFingerprint: step.afterFingerprint, + }); + } + return Object.freeze({ + executionId: journal.executionId, + planHash: journal.planHash, + operations: Object.freeze(operations), + }); +} diff --git a/apps/desktop/test/folder-autopilot-journal.test.ts b/apps/desktop/test/folder-autopilot-journal.test.ts new file mode 100644 index 00000000..cddffe31 --- /dev/null +++ b/apps/desktop/test/folder-autopilot-journal.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import { + JournalError, + beginJournal, + buildUndoPlan, + compensateJournal, + createJournal, + failJournal, + recordJournalStep, + recoverJournal, + type JournalStepInput, +} from '../src/features/folder-autopilot/local-journal.ts'; + +const source = 'C:\\Approved\\invoice.csv'; +const destination = 'C:\\Output\\invoice-reviewed.csv'; +const before = 'a'.repeat(64); +const after = 'b'.repeat(64); + +const steps: readonly JournalStepInput[] = [ + { + operationId: 'rename-1', + action: 'RENAME', + sourcePath: source, + destinationPath: destination, + beforeFingerprint: before, + undoable: true, + }, + { + operationId: 'move-1', + action: 'MOVE', + sourcePath: destination, + destinationPath: 'C:\\Archive\\invoice-reviewed.csv', + beforeFingerprint: after, + undoable: true, + }, +]; + +function committedJournal() { + let journal = createJournal({ + executionId: 'execution-1', + planHash: 'c'.repeat(64), + steps, + nowMs: 1_000, + undoWindowMs: 60_000, + }); + journal = beginJournal(journal); + journal = recordJournalStep(journal, 'rename-1', after); + journal = recordJournalStep(journal, 'move-1', 'd'.repeat(64)); + return journal; +} + +describe('Folder Autopilot local journal', () => { + it('commits staged steps exactly once and exposes a reverse undo plan', () => { + const journal = committedJournal(); + expect(journal.state).toBe('COMMITTED'); + expect(journal.steps.every((step) => step.state === 'COMMITTED')).toBe(true); + + const undo = buildUndoPlan(journal, { + nowMs: 2_000, + currentFingerprints: new Map([ + ['C:\\Archive\\invoice-reviewed.csv', 'd'.repeat(64)], + [destination, after], + ]), + }); + + expect(undo.operations.map((operation) => operation.sourcePath)).toEqual([ + 'C:\\Archive\\invoice-reviewed.csv', + destination, + ]); + expect(undo.operations[0]!.destinationPath).toBe(destination); + expect(undo.planHash).toBe('c'.repeat(64)); + }); + + it('refuses undo when a later user edit changed an affected file', () => { + const journal = committedJournal(); + expect(() => + buildUndoPlan(journal, { + nowMs: 2_000, + currentFingerprints: new Map([ + ['C:\\Archive\\invoice-reviewed.csv', 'changed'.padEnd(64, '0')], + [destination, after], + ]), + }), + ).toThrowError(new JournalError('UNDO_CONFLICT')); + }); + + it('recovers a crashed commit or enters an explained conflict', () => { + let journal = beginJournal( + createJournal({ + executionId: 'execution-1', + planHash: 'c'.repeat(64), + steps, + nowMs: 1_000, + undoWindowMs: 60_000, + }), + ); + journal = recordJournalStep(journal, 'rename-1', after); + expect(recoverJournal(journal, new Map([['rename-1', 'COMMITTED']])).state).toBe('COMMITTING'); + expect(() => recoverJournal(journal, new Map([['rename-1', 'UNKNOWN']]))).toThrow( + 'RECOVERY_CONFLICT', + ); + }); + + it('supports compensation after a staged failure and bounds undo expiry', () => { + let journal = beginJournal( + createJournal({ + executionId: 'execution-1', + planHash: 'c'.repeat(64), + steps, + nowMs: 1_000, + undoWindowMs: 60_000, + }), + ); + journal = recordJournalStep(journal, 'rename-1', after); + journal = failJournal(journal); + journal = compensateJournal(journal, 'rename-1'); + expect(journal.state).toBe('COMPENSATED'); + expect(() => + buildUndoPlan(committedJournal(), { + nowMs: 61_001, + currentFingerprints: new Map(), + }), + ).toThrowError(new JournalError('UNDO_EXPIRED')); + }); +}); From 24917606cc59162d75683a1acb9aed8db952b425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:32:09 +0700 Subject: [PATCH 19/62] fix(desktop): keep autopilot safety tests strict-safe --- .../features/folder-autopilot/file-observation.ts | 7 ++++++- .../test/folder-autopilot-local-actions.test.ts | 7 +++---- .../test/folder-autopilot-observation.test.ts | 15 +++++++-------- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/features/folder-autopilot/file-observation.ts b/apps/desktop/src/features/folder-autopilot/file-observation.ts index 069dc4f9..b61944c6 100644 --- a/apps/desktop/src/features/folder-autopilot/file-observation.ts +++ b/apps/desktop/src/features/folder-autopilot/file-observation.ts @@ -179,7 +179,12 @@ export async function captureStableObservation({ intervalMs, sleep, }: CaptureStableObservationInput): Promise { - const first = await waitForStableFile(readStat, { maxAttempts, intervalMs, sleep }); + const options: StableFileOptions = { + ...(maxAttempts === undefined ? {} : { maxAttempts }), + ...(intervalMs === undefined ? {} : { intervalMs }), + ...(sleep === undefined ? {} : { sleep }), + }; + const first = await waitForStableFile(readStat, options); let bytes: Uint8Array; try { bytes = await readBytes(); diff --git a/apps/desktop/test/folder-autopilot-local-actions.test.ts b/apps/desktop/test/folder-autopilot-local-actions.test.ts index fb8a2f9b..d758db70 100644 --- a/apps/desktop/test/folder-autopilot-local-actions.test.ts +++ b/apps/desktop/test/folder-autopilot-local-actions.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; import { - LocalActionError, executeLocalPlan, type LocalActionPlan, type LocalFileSystem, @@ -60,7 +59,7 @@ describe('Folder Autopilot local typed actions', () => { deps, ); - expect(result[0].status).toBe('APPLIED'); + expect(result[0]!.status).toBe('APPLIED'); expect(deps.sourceGuard.assertContained).toHaveBeenCalledWith(sourcePath); expect(deps.destinationGuard.assertContained).toHaveBeenCalledWith(destinationPath); expect(rename).toHaveBeenCalledWith(sourcePath, destinationPath); @@ -98,7 +97,7 @@ describe('Folder Autopilot local typed actions', () => { }), collisionDeps, ), - ).rejects.toMatchObject({ code: 'DESTINATION_COLLISION' }); + ).rejects.toMatchObject({ code: 'DESTINATION_COLLISION' }); const staleDeps = dependencies({ readFingerprint: vi.fn(() => Promise.resolve('b'.repeat(64))) }); await expect( @@ -112,6 +111,6 @@ describe('Folder Autopilot local typed actions', () => { }), staleDeps, ), - ).rejects.toMatchObject({ code: 'STALE_PLAN' }); + ).rejects.toMatchObject({ code: 'STALE_PLAN' }); }); }); diff --git a/apps/desktop/test/folder-autopilot-observation.test.ts b/apps/desktop/test/folder-autopilot-observation.test.ts index bcd1d2db..dacf62eb 100644 --- a/apps/desktop/test/folder-autopilot-observation.test.ts +++ b/apps/desktop/test/folder-autopilot-observation.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; import { - StableFileError, captureStableObservation, fingerprintBytes, waitForStableFile, @@ -17,7 +16,7 @@ const stableStat: StableFileStat = { describe('Folder Autopilot stable local observations', () => { it('waits for two identical metadata samples before hashing', async () => { const readStat = vi - .fn<() => Promise>() + .fn() .mockResolvedValueOnce({ ...stableStat, sizeBytes: 3 }) .mockResolvedValue(stableStat); const sleep = vi.fn(() => Promise.resolve()); @@ -31,7 +30,7 @@ describe('Folder Autopilot stable local observations', () => { it('retries transient lock failures and reports a bounded stable result', async () => { const readStat = vi - .fn<() => Promise>() + .fn() .mockRejectedValueOnce(new Error('sharing violation')) .mockResolvedValue(stableStat); @@ -41,11 +40,11 @@ describe('Folder Autopilot stable local observations', () => { }); it('rejects links and non-files before bytes are read', async () => { - const readStat = vi.fn<() => Promise>().mockResolvedValue({ + const readStat = vi.fn().mockResolvedValue({ ...stableStat, isSymbolicLink: true, }); - await expect(waitForStableFile(readStat)).rejects.toMatchObject({ + await expect(waitForStableFile(readStat)).rejects.toMatchObject({ code: 'PATH_REPARSE_POINT', }); }); @@ -58,7 +57,7 @@ describe('Folder Autopilot stable local observations', () => { const observation = await captureStableObservation({ observationId: 'obs-001', displayName: 'Báo cáo.csv', - readStat: vi.fn<() => Promise>().mockResolvedValue(stableStat), + readStat: vi.fn().mockResolvedValue(stableStat), readBytes: vi.fn(() => Promise.resolve(bytes)), sleep: () => Promise.resolve(), }); @@ -71,7 +70,7 @@ describe('Folder Autopilot stable local observations', () => { it('refuses bytes when the file changes while it is being read', async () => { const readStat = vi - .fn<() => Promise>() + .fn() .mockResolvedValueOnce(stableStat) .mockResolvedValueOnce(stableStat) .mockResolvedValue({ ...stableStat, modifiedAtNs: 11 }); @@ -83,6 +82,6 @@ describe('Folder Autopilot stable local observations', () => { readBytes: () => Promise.resolve(new TextEncoder().encode('data')), sleep: () => Promise.resolve(), }), - ).rejects.toMatchObject({ code: 'FILE_CHANGED_DURING_READ' }); + ).rejects.toMatchObject({ code: 'FILE_CHANGED_DURING_READ' }); }); }); From 79d9d94a01a8c9f63e82621dfef034ffc4d738cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:33:13 +0700 Subject: [PATCH 20/62] feat(desktop): track undo completion state --- .../folder-autopilot/local-journal.ts | 20 ++++++++++++++++++- .../test/folder-autopilot-journal.test.ts | 16 +++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/features/folder-autopilot/local-journal.ts b/apps/desktop/src/features/folder-autopilot/local-journal.ts index 5c4ebefc..c33abf51 100644 --- a/apps/desktop/src/features/folder-autopilot/local-journal.ts +++ b/apps/desktop/src/features/folder-autopilot/local-journal.ts @@ -4,7 +4,9 @@ export type JournalState = | 'COMMITTED' | 'COMPENSATING' | 'COMPENSATED' - | 'CONFLICT'; + | 'CONFLICT' + | 'UNDOING' + | 'UNDONE'; export type JournalStepState = 'PENDING' | 'COMMITTED' | 'COMPENSATED'; export type JournalAction = 'RENAME' | 'COPY' | 'MOVE'; export type JournalErrorCode = @@ -233,3 +235,19 @@ export function buildUndoPlan( operations: Object.freeze(operations), }); } + +export function beginUndo( + journal: LocalJournal, + options: { + readonly nowMs: number; + readonly currentFingerprints: ReadonlyMap; + }, +): { readonly journal: LocalJournal; readonly plan: UndoPlan } { + const plan = buildUndoPlan(journal, options); + return { journal: cloneJournal(journal, { state: 'UNDOING' }), plan }; +} + +export function completeUndo(journal: LocalJournal): LocalJournal { + if (journal.state !== 'UNDOING') return reject('INVALID_TRANSITION'); + return cloneJournal(journal, { state: 'UNDONE' }); +} diff --git a/apps/desktop/test/folder-autopilot-journal.test.ts b/apps/desktop/test/folder-autopilot-journal.test.ts index cddffe31..e82c99ac 100644 --- a/apps/desktop/test/folder-autopilot-journal.test.ts +++ b/apps/desktop/test/folder-autopilot-journal.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest'; import { JournalError, beginJournal, + beginUndo, buildUndoPlan, + completeUndo, compensateJournal, createJournal, failJournal, @@ -122,4 +124,18 @@ describe('Folder Autopilot local journal', () => { }), ).toThrowError(new JournalError('UNDO_EXPIRED')); }); + + it('tracks the undo lifecycle without erasing the original journal', () => { + const { journal: undoing, plan } = beginUndo(committedJournal(), { + nowMs: 2_000, + currentFingerprints: new Map([ + ['C:\\Archive\\invoice-reviewed.csv', 'd'.repeat(64)], + [destination, after], + ]), + }); + expect(undoing.state).toBe('UNDOING'); + expect(plan.operations).toHaveLength(2); + expect(completeUndo(undoing).state).toBe('UNDONE'); + expect(() => completeUndo(completeUndo(undoing))).toThrow('INVALID_TRANSITION'); + }); }); From c7df4d1e1683bcf4c0f3d260d7cd78762233d648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:33:37 +0700 Subject: [PATCH 21/62] feat(android): add Folder Autopilot state model --- .../folderautopilot/FolderAutopilotModels.kt | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotModels.kt diff --git a/apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotModels.kt b/apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotModels.kt new file mode 100644 index 00000000..52609c5b --- /dev/null +++ b/apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotModels.kt @@ -0,0 +1,140 @@ +package com.databreeze.android.folderautopilot + +private val OPAQUE_IDENTIFIER = Regex("^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +private val SAFE_TEXT = Regex("^[^\\u0000-\\u001f\\u007f]{1,128}$") +private val PLAN_HASH = Regex("^[0-9a-f]{64}$") +private val REASON_CODE = Regex("^[A-Z][A-Z0-9_.-]{1,63}$") + +enum class FolderAutopilotAssignmentState { ACTIVE, PAUSED, RETIRED, INVALID } + +enum class FolderAutopilotWatcherState { HEALTHY, PAUSED, OVERFLOWED, OFFLINE } + +enum class FolderAutopilotApprovalDecision { PENDING, APPROVED, REJECTED, EXPIRED } + +enum class FolderAutopilotOutcome { + QUEUED, + WAITING_FOR_APPROVAL, + RUNNING, + HANDLED, + EXCEPTION, + UNDO_AVAILABLE, + UNDO_EXPIRED, +} + +enum class FolderAutopilotUndoState { AVAILABLE, REQUESTED, COMPLETED, CONFLICT, EXPIRED, NOT_ELIGIBLE } + +data class FolderAutopilotAssignmentSummary( + val assignmentId: String, + val displayName: String, + val state: FolderAutopilotAssignmentState, + val revision: Long, + val watcherState: FolderAutopilotWatcherState, +) { + init { + requireOpaqueIdentifier(assignmentId) + requireSafeText(displayName) + require(revision > 0) { "revision must be positive" } + } + + fun pause(): FolderAutopilotAssignmentSummary { + check(state == FolderAutopilotAssignmentState.ACTIVE) { "assignment is not active" } + return copy(state = FolderAutopilotAssignmentState.PAUSED, revision = revision + 1) + } +} + +data class FolderAutopilotApprovalSummary( + val approvalId: String, + val previewId: String, + val planHash: String, + val affectedCount: Int, + val blockedCount: Int, + val decision: FolderAutopilotApprovalDecision, + val expiresAt: String, +) { + init { + requireOpaqueIdentifier(approvalId) + requireOpaqueIdentifier(previewId) + requirePlanHash(planHash) + require(affectedCount >= 0) { "affectedCount must not be negative" } + require(blockedCount >= 0) { "blockedCount must not be negative" } + require(expiresAt.isNotBlank()) { "expiresAt must be present" } + } + + fun decide(next: FolderAutopilotApprovalDecision, expectedPlanHash: String): FolderAutopilotApprovalSummary { + require(next == FolderAutopilotApprovalDecision.APPROVED || next == FolderAutopilotApprovalDecision.REJECTED) { + "only an approval or rejection can be submitted" + } + requirePlanHash(expectedPlanHash) + check(decision == FolderAutopilotApprovalDecision.PENDING) { "approval is no longer pending" } + require(planHash == expectedPlanHash) { "approval plan hash changed" } + return copy(decision = next) + } +} + +data class FolderAutopilotOutcomeSummary( + val executionId: String, + val outcome: FolderAutopilotOutcome, + val affectedCount: Int, + val undoState: FolderAutopilotUndoState, +) { + init { + requireOpaqueIdentifier(executionId) + require(affectedCount >= 0) { "affectedCount must not be negative" } + } + + fun requestUndo(): FolderAutopilotOutcomeSummary { + check(undoState == FolderAutopilotUndoState.AVAILABLE) { "undo is not available" } + return copy(undoState = FolderAutopilotUndoState.REQUESTED) + } +} + +data class FolderAutopilotExceptionSummary( + val exceptionId: String, + val severity: String, + val reasonCode: String, +) { + init { + requireOpaqueIdentifier(exceptionId) + require(severity in setOf("INFO", "WARNING", "ERROR")) { "unsupported severity" } + requireReasonCode(reasonCode) + } +} + +data class FolderAutopilotMobileState( + val assignment: FolderAutopilotAssignmentSummary, + val approval: FolderAutopilotApprovalSummary, + val recentOutcome: FolderAutopilotOutcomeSummary, + val exceptions: List, +) { + init { + require(exceptions.size <= 50) { "too many exception summaries" } + require(exceptions.none { it.reasonCode.contains("PATH", ignoreCase = true) }) { + "path-bearing exception details are not allowed" + } + } + + fun pauseAssignment(): FolderAutopilotMobileState = copy(assignment = assignment.pause()) + + fun decideApproval( + decision: FolderAutopilotApprovalDecision, + expectedPlanHash: String, + ): FolderAutopilotMobileState = copy(approval = approval.decide(decision, expectedPlanHash)) + + fun requestUndo(): FolderAutopilotMobileState = copy(recentOutcome = recentOutcome.requestUndo()) +} + +private fun requireOpaqueIdentifier(value: String) { + require(OPAQUE_IDENTIFIER.matches(value)) { "identifier must be opaque and path-free" } +} + +private fun requireSafeText(value: String) { + require(SAFE_TEXT.matches(value) && value.trim() == value) { "text is not safe" } +} + +private fun requirePlanHash(value: String) { + require(PLAN_HASH.matches(value)) { "plan hash must be a lowercase SHA-256 value" } +} + +private fun requireReasonCode(value: String) { + require(REASON_CODE.matches(value)) { "reason code is not safe" } +} From 87a9b61367e4426ce5bcc922e44f6f564c1ddf25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:35:58 +0700 Subject: [PATCH 22/62] feat(desktop): gate local execution on content-free grants --- .../features/folder-autopilot/local-safety.ts | 133 ++++++++++++++++++ .../folder-autopilot-local-safety.test.ts | 63 +++++++++ 2 files changed, 196 insertions(+) create mode 100644 apps/desktop/src/features/folder-autopilot/local-safety.ts create mode 100644 apps/desktop/test/folder-autopilot-local-safety.test.ts diff --git a/apps/desktop/src/features/folder-autopilot/local-safety.ts b/apps/desktop/src/features/folder-autopilot/local-safety.ts new file mode 100644 index 00000000..e3b41432 --- /dev/null +++ b/apps/desktop/src/features/folder-autopilot/local-safety.ts @@ -0,0 +1,133 @@ +export type LocalGrantStatus = 'ACTIVE' | 'EXPIRED' | 'REVOKED' | 'SUSPENDED'; +export type LocalRequestedEffect = 'READ' | 'WRITE'; +export type LocalApprovalState = 'APPROVED' | 'NOT_REQUIRED' | 'PENDING'; +export type LocalSafetyReasonCode = + | 'APPROVAL_REQUIRED' + | 'AUTHORIZED' + | 'CAPABILITY_DIGEST_MISMATCH' + | 'DEVICE_GRANT_EXPIRED' + | 'DEVICE_GRANT_REVOKED' + | 'DEVICE_GRANT_SUSPENDED'; + +export interface LocalExecutionAuthorization { + readonly deviceGrantId: string; + readonly grantStatus: LocalGrantStatus; + readonly expectedCapabilityDigest: string; + readonly actualCapabilityDigest: string; + readonly effectiveDataModePolicyRef: string; + readonly planHash: string; + readonly sourceFingerprint: string; + readonly requestedEffect: LocalRequestedEffect; + readonly requiresApproval: boolean; + readonly approvalState: LocalApprovalState; +} + +export interface LocalExecutionDecision { + readonly accepted: boolean; + readonly reasonCode: LocalSafetyReasonCode; +} + +export interface ContentFreeExecutionPayload { + readonly deviceGrantId: string; + readonly effectiveDataModePolicyRef: string; + readonly planHash: string; + readonly requestedEffect: LocalRequestedEffect; + readonly sourceFingerprint: string; +} + +const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; +const SHA256 = /^[0-9a-f]{64}$/; +const AUTHORIZATION_KEYS = [ + 'actualCapabilityDigest', + 'approvalState', + 'deviceGrantId', + 'effectiveDataModePolicyRef', + 'expectedCapabilityDigest', + 'grantStatus', + 'planHash', + 'requestedEffect', + 'requiresApproval', + 'sourceFingerprint', +] as const; + +function reject(): never { + throw new Error('INVALID_EXECUTION_AUTHORIZATION'); +} + +function validateAuthorization(value: unknown): LocalExecutionAuthorization { + if (typeof value !== 'object' || value === null || Object.getPrototypeOf(value) !== Object.prototype) { + return reject(); + } + const keys = Reflect.ownKeys(value); + if ( + keys.length !== AUTHORIZATION_KEYS.length || + keys.some((key) => typeof key !== 'string' || !AUTHORIZATION_KEYS.includes(key as (typeof AUTHORIZATION_KEYS)[number])) + ) { + return reject(); + } + const input = value as Record<(typeof AUTHORIZATION_KEYS)[number], unknown>; + if ( + typeof input.deviceGrantId !== 'string' || + !SAFE_ID.test(input.deviceGrantId) || + typeof input.effectiveDataModePolicyRef !== 'string' || + !SAFE_ID.test(input.effectiveDataModePolicyRef) || + typeof input.expectedCapabilityDigest !== 'string' || + !SHA256.test(input.expectedCapabilityDigest) || + typeof input.actualCapabilityDigest !== 'string' || + !SHA256.test(input.actualCapabilityDigest) || + typeof input.planHash !== 'string' || + !SHA256.test(input.planHash) || + typeof input.sourceFingerprint !== 'string' || + !SHA256.test(input.sourceFingerprint) || + !['ACTIVE', 'EXPIRED', 'REVOKED', 'SUSPENDED'].includes(input.grantStatus as string) || + !['READ', 'WRITE'].includes(input.requestedEffect as string) || + !['APPROVED', 'NOT_REQUIRED', 'PENDING'].includes(input.approvalState as string) || + typeof input.requiresApproval !== 'boolean' + ) { + return reject(); + } + return input as LocalExecutionAuthorization; +} + +export function authorizeLocalExecution(value: LocalExecutionAuthorization): LocalExecutionDecision { + const authorization = validateAuthorization(value); + return evaluateAuthorization(authorization); +} + +function evaluateAuthorization( + authorization: LocalExecutionAuthorization, +): LocalExecutionDecision { + if (authorization.grantStatus === 'REVOKED') { + return { accepted: false, reasonCode: 'DEVICE_GRANT_REVOKED' }; + } + if (authorization.grantStatus === 'EXPIRED') { + return { accepted: false, reasonCode: 'DEVICE_GRANT_EXPIRED' }; + } + if (authorization.grantStatus === 'SUSPENDED') { + return { accepted: false, reasonCode: 'DEVICE_GRANT_SUSPENDED' }; + } + if (authorization.expectedCapabilityDigest !== authorization.actualCapabilityDigest) { + return { accepted: false, reasonCode: 'CAPABILITY_DIGEST_MISMATCH' }; + } + if (authorization.requiresApproval && authorization.approvalState !== 'APPROVED') { + return { accepted: false, reasonCode: 'APPROVAL_REQUIRED' }; + } + return { accepted: true, reasonCode: 'AUTHORIZED' }; +} + +export function buildContentFreeExecutionPayload( + value: LocalExecutionAuthorization, +): ContentFreeExecutionPayload { + const authorization = validateAuthorization(value); + const decision = evaluateAuthorization(authorization); + if (!decision.accepted) { + throw new Error(`LOCAL_EXECUTION_NOT_AUTHORIZED:${decision.reasonCode}`); + } + return Object.freeze({ + deviceGrantId: authorization.deviceGrantId, + effectiveDataModePolicyRef: authorization.effectiveDataModePolicyRef, + planHash: authorization.planHash, + requestedEffect: authorization.requestedEffect, + sourceFingerprint: authorization.sourceFingerprint, + }); +} diff --git a/apps/desktop/test/folder-autopilot-local-safety.test.ts b/apps/desktop/test/folder-autopilot-local-safety.test.ts new file mode 100644 index 00000000..0f0cf21c --- /dev/null +++ b/apps/desktop/test/folder-autopilot-local-safety.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { + authorizeLocalExecution, + buildContentFreeExecutionPayload, + type LocalExecutionAuthorization, +} from '../src/features/folder-autopilot/local-safety.ts'; + +const valid: LocalExecutionAuthorization = { + deviceGrantId: 'grant-001', + grantStatus: 'ACTIVE', + expectedCapabilityDigest: 'a'.repeat(64), + actualCapabilityDigest: 'a'.repeat(64), + effectiveDataModePolicyRef: 'policy-001', + planHash: 'b'.repeat(64), + sourceFingerprint: 'c'.repeat(64), + requestedEffect: 'WRITE', + requiresApproval: true, + approvalState: 'APPROVED', +}; + +describe('Folder Autopilot local execution safety boundary', () => { + it('accepts an active matching grant and emits only content-free metadata', () => { + const decision = authorizeLocalExecution(valid); + expect(decision).toEqual({ accepted: true, reasonCode: 'AUTHORIZED' }); + + const payload = buildContentFreeExecutionPayload(valid); + expect(payload).toEqual({ + deviceGrantId: 'grant-001', + effectiveDataModePolicyRef: 'policy-001', + planHash: 'b'.repeat(64), + requestedEffect: 'WRITE', + sourceFingerprint: 'c'.repeat(64), + }); + expect(JSON.stringify(payload)).not.toMatch(/path|handle|bytes|content/i); + }); + + it('fails closed for revoked grants, capability drift, and missing approval', () => { + expect(authorizeLocalExecution({ ...valid, grantStatus: 'REVOKED' })).toEqual({ + accepted: false, + reasonCode: 'DEVICE_GRANT_REVOKED', + }); + expect(authorizeLocalExecution({ ...valid, actualCapabilityDigest: 'd'.repeat(64) })).toEqual({ + accepted: false, + reasonCode: 'CAPABILITY_DIGEST_MISMATCH', + }); + expect(authorizeLocalExecution({ ...valid, approvalState: 'PENDING' })).toEqual({ + accepted: false, + reasonCode: 'APPROVAL_REQUIRED', + }); + expect(() => + buildContentFreeExecutionPayload({ ...valid, grantStatus: 'REVOKED' }), + ).toThrow('LOCAL_EXECUTION_NOT_AUTHORIZED:DEVICE_GRANT_REVOKED'); + }); + + it('rejects malformed metadata before any local action can run', () => { + expect(() => + authorizeLocalExecution({ ...valid, deviceGrantId: 'C:\\secret' }), + ).toThrow('INVALID_EXECUTION_AUTHORIZATION'); + expect(() => + buildContentFreeExecutionPayload({ ...valid, sourceFingerprint: 'not-a-digest' }), + ).toThrow('INVALID_EXECUTION_AUTHORIZATION'); + }); +}); From 4500e894966c67adc452ad85ee13c5fdd40f1034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:40:01 +0700 Subject: [PATCH 23/62] feat(android): add Folder Autopilot review companion --- .../databreeze/android/MainActivityTest.kt | 15 ++ .../com/databreeze/android/MainActivity.kt | 76 ++++++++- .../folderautopilot/FolderAutopilotScreen.kt | 145 ++++++++++++++++++ .../app/src/main/res/values-en/strings.xml | 21 +++ .../app/src/main/res/values/strings.xml | 21 +++ 5 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotScreen.kt diff --git a/apps/android/app/src/androidTest/java/com/databreeze/android/MainActivityTest.kt b/apps/android/app/src/androidTest/java/com/databreeze/android/MainActivityTest.kt index 14eb3eac..f7767e66 100644 --- a/apps/android/app/src/androidTest/java/com/databreeze/android/MainActivityTest.kt +++ b/apps/android/app/src/androidTest/java/com/databreeze/android/MainActivityTest.kt @@ -36,4 +36,19 @@ class MainActivityTest { composeRule.onNodeWithTag("capture-screen").assertIsDisplayed() composeRule.onNodeWithText(savedText).assertIsDisplayed() } + + @Test + fun folder_autopilot_keeps_actions_content_free_and_reversible() { + composeRule.onNodeWithTag("autopilot-button").performClick() + composeRule.onNodeWithTag("autopilot-screen").assertIsDisplayed() + + composeRule.onNodeWithTag("autopilot-pause-button").performClick() + composeRule.onNodeWithTag("autopilot-assignment-state").assertIsDisplayed() + + composeRule.onNodeWithTag("autopilot-approve-button").performClick() + composeRule.onNodeWithTag("autopilot-approval-state").assertIsDisplayed() + + composeRule.onNodeWithTag("autopilot-undo-button").performClick() + composeRule.onNodeWithTag("autopilot-undo-state").assertIsDisplayed() + } } diff --git a/apps/android/app/src/main/java/com/databreeze/android/MainActivity.kt b/apps/android/app/src/main/java/com/databreeze/android/MainActivity.kt index d3ba91d8..374b5de7 100644 --- a/apps/android/app/src/main/java/com/databreeze/android/MainActivity.kt +++ b/apps/android/app/src/main/java/com/databreeze/android/MainActivity.kt @@ -16,8 +16,10 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -30,11 +32,23 @@ import com.databreeze.android.storage.InMemoryLocalStore import com.databreeze.android.storage.LocalStorePort import com.databreeze.android.storage.SyncQueueEntity import com.databreeze.android.sync.SyncScheduler +import com.databreeze.android.folderautopilot.FolderAutopilotApprovalDecision +import com.databreeze.android.folderautopilot.FolderAutopilotAssignmentState +import com.databreeze.android.folderautopilot.FolderAutopilotAssignmentSummary +import com.databreeze.android.folderautopilot.FolderAutopilotExceptionSummary +import com.databreeze.android.folderautopilot.FolderAutopilotMobileState +import com.databreeze.android.folderautopilot.FolderAutopilotOutcome +import com.databreeze.android.folderautopilot.FolderAutopilotOutcomeSummary +import com.databreeze.android.folderautopilot.FolderAutopilotApprovalSummary +import com.databreeze.android.folderautopilot.FolderAutopilotUndoState +import com.databreeze.android.folderautopilot.FolderAutopilotWatcherState +import com.databreeze.android.folderautopilot.FolderAutopilotScreen import kotlinx.coroutines.launch private object AppRoutes { const val HOME = "home" const val CAPTURE = "capture" + const val AUTOPILOT = "autopilot" } private val localScope = AccountWorkspaceScope("local-account", "local-workspace") @@ -63,6 +77,7 @@ fun DataBreezeApp( syncScheduler: SyncScheduler? = null, ) { val navController = rememberNavController() + var autopilotState by remember { mutableStateOf(sampleFolderAutopilotState()) } DataBreezeTheme { Scaffold( topBar = { TopAppBar(title = { Text(stringResource(R.string.app_name)) }) }, @@ -73,7 +88,10 @@ fun DataBreezeApp( modifier = Modifier.padding(padding), ) { composable(AppRoutes.HOME) { - HomeScreen(onCapture = { navController.navigate(AppRoutes.CAPTURE) }) + HomeScreen( + onCapture = { navController.navigate(AppRoutes.CAPTURE) }, + onAutopilot = { navController.navigate(AppRoutes.AUTOPILOT) }, + ) } composable(AppRoutes.CAPTURE) { CaptureScreen( @@ -83,13 +101,32 @@ fun DataBreezeApp( onBack = { navController.popBackStack() }, ) } + composable(AppRoutes.AUTOPILOT) { + FolderAutopilotScreen( + state = autopilotState, + onPause = { autopilotState = autopilotState.pauseAssignment() }, + onApprove = { + autopilotState = autopilotState.decideApproval( + FolderAutopilotApprovalDecision.APPROVED, + autopilotState.approval.planHash, + ) + }, + onReject = { + autopilotState = autopilotState.decideApproval( + FolderAutopilotApprovalDecision.REJECTED, + autopilotState.approval.planHash, + ) + }, + onUndo = { autopilotState = autopilotState.requestUndo() }, + ) + } } } } } @Composable -private fun HomeScreen(onCapture: () -> Unit) { +private fun HomeScreen(onCapture: () -> Unit, onAutopilot: () -> Unit) { Column( modifier = Modifier .fillMaxSize() @@ -102,9 +139,44 @@ private fun HomeScreen(onCapture: () -> Unit) { Button(onClick = onCapture, modifier = Modifier.testTag("capture-button")) { Text(stringResource(R.string.capture_action)) } + Button(onClick = onAutopilot, modifier = Modifier.testTag("autopilot-button")) { + Text(stringResource(R.string.autopilot_title)) + } } } +private fun sampleFolderAutopilotState() = FolderAutopilotMobileState( + assignment = FolderAutopilotAssignmentSummary( + assignmentId = "assignment-1", + displayName = "Invoice intake", + state = FolderAutopilotAssignmentState.ACTIVE, + revision = 3, + watcherState = FolderAutopilotWatcherState.HEALTHY, + ), + approval = FolderAutopilotApprovalSummary( + approvalId = "approval-1", + previewId = "preview-1", + planHash = "a".repeat(64), + affectedCount = 2, + blockedCount = 1, + decision = FolderAutopilotApprovalDecision.PENDING, + expiresAt = "2026-08-05T00:00:00Z", + ), + recentOutcome = FolderAutopilotOutcomeSummary( + executionId = "execution-1", + outcome = FolderAutopilotOutcome.UNDO_AVAILABLE, + affectedCount = 2, + undoState = FolderAutopilotUndoState.AVAILABLE, + ), + exceptions = listOf( + FolderAutopilotExceptionSummary( + exceptionId = "exception-1", + severity = "WARNING", + reasonCode = "DESTINATION_COLLISION", + ), + ), +) + @Composable private fun CaptureScreen( localStore: LocalStorePort, diff --git a/apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotScreen.kt b/apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotScreen.kt new file mode 100644 index 00000000..e6e39205 --- /dev/null +++ b/apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotScreen.kt @@ -0,0 +1,145 @@ +package com.databreeze.android.folderautopilot + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.databreeze.android.R + +@Composable +fun FolderAutopilotScreen( + state: FolderAutopilotMobileState, + onPause: () -> Unit, + onApprove: () -> Unit, + onReject: () -> Unit, + onUndo: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding(20.dp) + .testTag("autopilot-screen"), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text(stringResource(R.string.autopilot_title), style = MaterialTheme.typography.headlineSmall) + Text(stringResource(R.string.autopilot_body), style = MaterialTheme.typography.bodyLarge) + + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(stringResource(R.string.autopilot_assignment_heading), style = MaterialTheme.typography.titleMedium) + Text(state.assignment.displayName, style = MaterialTheme.typography.bodyLarge) + Text( + stringResource( + R.string.autopilot_assignment_state, + state.assignment.state.name, + state.assignment.revision, + ), + modifier = Modifier.testTag("autopilot-assignment-state"), + ) + Text(stringResource(R.string.autopilot_watcher_state, state.assignment.watcherState.name)) + Button( + onClick = onPause, + enabled = state.assignment.state == FolderAutopilotAssignmentState.ACTIVE, + modifier = Modifier.testTag("autopilot-pause-button"), + ) { + Text( + if (state.assignment.state == FolderAutopilotAssignmentState.PAUSED) { + stringResource(R.string.autopilot_paused) + } else { + stringResource(R.string.autopilot_pause) + }, + ) + } + } + } + + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(stringResource(R.string.autopilot_approval_heading), style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.autopilot_preview_id, state.approval.previewId)) + Text(stringResource(R.string.autopilot_plan_hash, state.approval.planHash.take(12))) + Text( + stringResource( + R.string.autopilot_approval_counts, + state.approval.affectedCount, + state.approval.blockedCount, + ), + ) + Text( + stringResource(R.string.autopilot_approval_state, state.approval.decision.name), + modifier = Modifier.testTag("autopilot-approval-state"), + ) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Button( + onClick = onApprove, + enabled = state.approval.decision == FolderAutopilotApprovalDecision.PENDING, + modifier = Modifier.testTag("autopilot-approve-button"), + ) { + Text(stringResource(R.string.autopilot_approve)) + } + OutlinedButton( + onClick = onReject, + enabled = state.approval.decision == FolderAutopilotApprovalDecision.PENDING, + modifier = Modifier.testTag("autopilot-reject-button"), + ) { + Text(stringResource(R.string.autopilot_reject)) + } + } + } + } + + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(stringResource(R.string.autopilot_outcomes_heading), style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.autopilot_outcome_state, state.recentOutcome.outcome.name)) + Text(stringResource(R.string.autopilot_affected_count, state.recentOutcome.affectedCount)) + Text( + stringResource(R.string.autopilot_undo_state, state.recentOutcome.undoState.name), + modifier = Modifier.testTag("autopilot-undo-state"), + ) + Button( + onClick = onUndo, + enabled = state.recentOutcome.undoState == FolderAutopilotUndoState.AVAILABLE, + modifier = Modifier.testTag("autopilot-undo-button"), + ) { + Text(stringResource(R.string.autopilot_undo)) + } + } + } + + if (state.exceptions.isNotEmpty()) { + HorizontalDivider() + Text(stringResource(R.string.autopilot_exceptions_heading), style = MaterialTheme.typography.titleMedium) + state.exceptions.forEach { exception -> + Text( + stringResource(R.string.autopilot_exception, exception.severity, exception.reasonCode), + modifier = Modifier.testTag("autopilot-exception-${exception.exceptionId}"), + ) + } + } + } +} diff --git a/apps/android/app/src/main/res/values-en/strings.xml b/apps/android/app/src/main/res/values-en/strings.xml index d18c1478..7711c77b 100644 --- a/apps/android/app/src/main/res/values-en/strings.xml +++ b/apps/android/app/src/main/res/values-en/strings.xml @@ -9,4 +9,25 @@ Draft saved Save draft Back + Folder Autopilot + Review and approve safe actions without exposing source paths or file content. + Assignment + State: %1$s (revision %2$d) + Watcher: %1$s + Pause assignment + Paused + Approval queue + Preview: %1$s + Plan hash: %1$s… + Affected %1$d • blocked %2$d + Decision: %1$s + Approve + Reject + Recent outcomes + Outcome: %1$s + Affected items: %1$d + Undo: %1$s + Undo + Exceptions + %1$s • %2$s diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index ec54177f..f9d6aa4a 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -9,4 +9,25 @@ Đã lưu bản nháp Lưu bản nháp Quay lại + Tự động hóa thư mục + Xem và phê duyệt thao tác an toàn mà không hiển thị đường dẫn tên tệp. + Phân công + Trạng thái: %1$s (phiên bản %2$d) + Watcher: %1$s + Tạm dừng + Đã tạm dừng + Hàng đợi phê duyệt + Preview: %1$s + Mã kế hoạch: %1$s… + Ảnh hưởng %1$d • bị chặn %2$d + Quyết định: %1$s + Phê duyệt + Từ chối + Kết quả gần đây + Kết quả: %1$s + Số mục ảnh hưởng: %1$d + Hoàn tác: %1$s + Hoàn tác + Ngoại lệ + %1$s • %2$s From f32ab4ba2e013b6a1224d135c25644e267150485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:41:25 +0700 Subject: [PATCH 24/62] feat(desktop): allocate bounded autopilot collision names --- .../folder-autopilot/local-actions.ts | 65 +++++++++++++- .../folder-autopilot-local-actions.test.ts | 84 +++++++++++++++++++ 2 files changed, 145 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/features/folder-autopilot/local-actions.ts b/apps/desktop/src/features/folder-autopilot/local-actions.ts index cb8f2945..38209fe7 100644 --- a/apps/desktop/src/features/folder-autopilot/local-actions.ts +++ b/apps/desktop/src/features/folder-autopilot/local-actions.ts @@ -1,3 +1,5 @@ +import path from 'node:path'; + export type LocalAction = 'INSPECT' | 'VALIDATE' | 'RENAME' | 'COPY' | 'MOVE'; export type LocalCollisionPolicy = 'REVIEW' | 'SKIP' | 'UNIQUE_NAME'; export type LocalActionCode = @@ -26,6 +28,8 @@ export interface LocalFileSystem { exists(path: string): Promise; readFingerprint(path: string): Promise; copyExclusive(source: string, destination: string): Promise; + /** The adapter must reject rather than replace an existing destination. */ + renameExclusive?(source: string, destination: string): Promise; rename(source: string, destination: string): Promise; } @@ -53,9 +57,11 @@ export interface LocalActionReceipt { readonly operationId: string; readonly action: LocalAction; readonly status: 'APPLIED' | 'SKIPPED'; + readonly destinationPath?: string; } const MAX_OPERATIONS = 100; +const MAX_UNIQUE_NAME_ATTEMPTS = 1_000; const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; const SHA256 = /^[0-9a-f]{64}$/; @@ -102,6 +108,33 @@ function validateOperation(operation: LocalActionOperation): void { } } +function uniqueDestinationName(destinationPath: string, index: number): string { + const parsed = path.win32.parse(destinationPath); + return path.win32.join(parsed.dir, `${parsed.name} (${index})${parsed.ext}`); +} + +async function chooseDestination( + containedDestination: string, + collisionPolicy: LocalCollisionPolicy | undefined, + destinationGuard: LocalPathGuard, + fileSystem: LocalFileSystem, +): Promise<{ readonly path: string; readonly generated: boolean; readonly skipped: boolean }> { + const destination = containedDestination; + if (!(await fileSystem.exists(destination))) { + return { path: destination, generated: false, skipped: false }; + } + if (collisionPolicy === 'SKIP') return { path: destination, generated: false, skipped: true }; + if (collisionPolicy !== 'UNIQUE_NAME') return reject('DESTINATION_COLLISION'); + + for (let index = 1; index <= MAX_UNIQUE_NAME_ATTEMPTS; index += 1) { + const candidate = destinationGuard.assertContained(uniqueDestinationName(destination, index)); + if (!(await fileSystem.exists(candidate))) { + return { path: candidate, generated: true, skipped: false }; + } + } + return reject('DESTINATION_COLLISION'); +} + export async function executeLocalPlan( plan: LocalActionPlan, { sourceGuard, destinationGuard, fileSystem }: LocalActionDependencies, @@ -126,8 +159,21 @@ export async function executeLocalPlan( continue; } - const destination = destinationGuard.assertContained(operation.destinationPath as string); - if (source.toLowerCase() === destination.toLowerCase()) return reject('DESTINATION_RECURSION'); + const requestedDestination = destinationGuard.assertContained(operation.destinationPath as string); + if (source.toLowerCase() === requestedDestination.toLowerCase()) { + return reject('DESTINATION_RECURSION'); + } + const destinationSelection = await chooseDestination( + requestedDestination, + operation.collisionPolicy, + destinationGuard, + fileSystem, + ); + const destination = destinationSelection.path; + if (destinationSelection.skipped) { + receipts.push({ operationId: operation.operationId, action: operation.action, status: 'SKIPPED' }); + continue; + } if (await fileSystem.exists(destination)) { if (operation.collisionPolicy === 'SKIP') { receipts.push({ operationId: operation.operationId, action: operation.action, status: 'SKIPPED' }); @@ -137,11 +183,22 @@ export async function executeLocalPlan( } try { if (operation.action === 'COPY') await fileSystem.copyExclusive(source, destination); - else await fileSystem.rename(source, destination); + else if (fileSystem.renameExclusive !== undefined) { + await fileSystem.renameExclusive(source, destination); + } else { + await fileSystem.rename(source, destination); + } } catch { return reject('LOCAL_IO_FAILED'); } - receipts.push({ operationId: operation.operationId, action: operation.action, status: 'APPLIED' }); + receipts.push({ + operationId: operation.operationId, + action: operation.action, + status: 'APPLIED', + ...(destinationSelection.generated + ? { destinationPath: destination } + : {}), + }); } return receipts; } diff --git a/apps/desktop/test/folder-autopilot-local-actions.test.ts b/apps/desktop/test/folder-autopilot-local-actions.test.ts index d758db70..e6437967 100644 --- a/apps/desktop/test/folder-autopilot-local-actions.test.ts +++ b/apps/desktop/test/folder-autopilot-local-actions.test.ts @@ -84,6 +84,90 @@ describe('Folder Autopilot local typed actions', () => { expect(copyExclusive).not.toHaveBeenCalled(); }); + it('allocates a bounded deterministic unique name and returns the chosen destination', async () => { + const exists = vi + .fn() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + const deps = dependencies({ exists }); + const copyExclusive = vi.spyOn(deps.fileSystem, 'copyExclusive'); + await expect( + executeLocalPlan( + plan({ + operationId: 'copy-unique-1', + action: 'COPY', + sourcePath, + destinationPath, + sourceFingerprint: 'a'.repeat(64), + collisionPolicy: 'UNIQUE_NAME', + }), + deps, + ), + ).resolves.toEqual([ + { + operationId: 'copy-unique-1', + action: 'COPY', + status: 'APPLIED', + destinationPath: 'C:\\Output\\invoice-reviewed (2).csv', + }, + ]); + expect(copyExclusive).toHaveBeenCalledWith( + sourcePath, + 'C:\\Output\\invoice-reviewed (2).csv', + ); + expect(deps.destinationGuard.assertContained).toHaveBeenNthCalledWith( + 2, + 'C:\\Output\\invoice-reviewed (1).csv', + ); + expect(deps.destinationGuard.assertContained).toHaveBeenNthCalledWith( + 3, + 'C:\\Output\\invoice-reviewed (2).csv', + ); + }); + + it('prefers the exclusive rename port when the adapter provides it', async () => { + const renameExclusive = vi.fn>(() => + Promise.resolve(), + ); + const deps = dependencies({ renameExclusive }); + const rename = vi.spyOn(deps.fileSystem, 'rename'); + + await executeLocalPlan( + plan({ + operationId: 'rename-exclusive-1', + action: 'RENAME', + sourcePath, + destinationPath, + sourceFingerprint: 'a'.repeat(64), + }), + deps, + ); + + expect(renameExclusive).toHaveBeenCalledWith(sourcePath, destinationPath); + expect(rename).not.toHaveBeenCalled(); + }); + + it('rejects unique-name exhaustion at the bounded allocation limit', async () => { + const exists = vi.fn(() => Promise.resolve(true)); + const deps = dependencies({ exists }); + + await expect( + executeLocalPlan( + plan({ + operationId: 'copy-unique-exhausted', + action: 'COPY', + sourcePath, + destinationPath, + sourceFingerprint: 'a'.repeat(64), + collisionPolicy: 'UNIQUE_NAME', + }), + deps, + ), + ).rejects.toMatchObject({ code: 'DESTINATION_COLLISION' }); + expect(exists).toHaveBeenCalledTimes(1_001); + }); + it('fails closed for collisions, stale plans, and unknown local effects', async () => { const collisionDeps = dependencies({ exists: vi.fn(() => Promise.resolve(true)) }); await expect( From 26bf02f3e14ae629a4725fd6c927f409dbefab74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:43:49 +0700 Subject: [PATCH 25/62] feat(contracts): add folder autopilot v1 schemas --- .../contracts/compatibility/published.json | 2 +- .../contracts/compatibility/v1/baseline.json | 33 ++++++++-- .../com/databreeze/contracts/v1/Models.kt | 46 ++++++++++++++ .../com/databreeze/contracts/v1/Validation.kt | 6 ++ .../databreeze_contracts/v1/__init__.py | 6 ++ .../python/databreeze_contracts/v1/models.py | 46 ++++++++++++++ .../generated/typescript/v1/index.ts | 48 ++++++++++++++- .../generated/typescript/v1/validation.mjs | 3 + packages/contracts/manifest.json | 15 +++++ packages/contracts/package.json | 3 + .../v1/autopilot-folder-binding.schema.json | 28 +++++++++ .../v1/folder-autopilot-profile.schema.json | 36 +++++++++++ .../schemas/v1/recipe-assignment.schema.json | 52 ++++++++++++++++ packages/contracts/test/schemas.test.mjs | 60 +++++++++++++++++++ 14 files changed, 376 insertions(+), 8 deletions(-) create mode 100644 packages/contracts/schemas/v1/autopilot-folder-binding.schema.json create mode 100644 packages/contracts/schemas/v1/folder-autopilot-profile.schema.json create mode 100644 packages/contracts/schemas/v1/recipe-assignment.schema.json diff --git a/packages/contracts/compatibility/published.json b/packages/contracts/compatibility/published.json index fbdd0098..f3749fb3 100644 --- a/packages/contracts/compatibility/published.json +++ b/packages/contracts/compatibility/published.json @@ -4,7 +4,7 @@ { "contractVersion": 1, "baseline": "compatibility/v1/baseline.json", - "sha256": "cb1e4833e517b96eaa2ca6c0583838619bfa494e3b634b7d855b60eb2fb242ff" + "sha256": "313594a67ffb8f6fb41776874108dbcdadf951ff02f9ebfd3d597d4577fd626e" } ] } diff --git a/packages/contracts/compatibility/v1/baseline.json b/packages/contracts/compatibility/v1/baseline.json index 6d257209..c978b1f8 100644 --- a/packages/contracts/compatibility/v1/baseline.json +++ b/packages/contracts/compatibility/v1/baseline.json @@ -9,6 +9,12 @@ "path": "schemas/v1/actor-metadata.schema.json", "sha256": "fb9d12675478ae805bbe0163866c1cf8e4bb810dbe32ac780b1c1bf4975c856c" }, + { + "name": "autopilot-folder-binding", + "id": "https://schemas.databreeze.dev/contracts/v1/autopilot-folder-binding", + "path": "schemas/v1/autopilot-folder-binding.schema.json", + "sha256": "4a65514c7ab3250d98cea3d227622f6c5461ba433042682e6b384bbe4cd765f4" + }, { "name": "command-envelope", "id": "https://schemas.databreeze.dev/contracts/v1/command-envelope", @@ -33,6 +39,12 @@ "path": "schemas/v1/event-envelope.schema.json", "sha256": "54780e954b80a07de08d428a03c972cb9fcfe6682691521fe6d6c357b45753dd" }, + { + "name": "folder-autopilot-profile", + "id": "https://schemas.databreeze.dev/contracts/v1/folder-autopilot-profile", + "path": "schemas/v1/folder-autopilot-profile.schema.json", + "sha256": "025fea468ae1a46630913acc5608bba6d4f3a49c53c40cc538b167bc956e762d" + }, { "name": "identifier", "id": "https://schemas.databreeze.dev/contracts/v1/identifier", @@ -45,6 +57,12 @@ "path": "schemas/v1/problem-details.schema.json", "sha256": "c1209d3d234e75b13a84e7cfbbd2bec9f6d9f1daa3602ec2443f682770892272" }, + { + "name": "recipe-assignment", + "id": "https://schemas.databreeze.dev/contracts/v1/recipe-assignment", + "path": "schemas/v1/recipe-assignment.schema.json", + "sha256": "108b3c18c704558787894fd12b0a2018d78a1cb26b5e0e2ede04e7f0df4cdcbf" + }, { "name": "revision", "id": "https://schemas.databreeze.dev/contracts/v1/revision", @@ -71,11 +89,11 @@ "generatedPublicOutputs": [ { "path": "generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt", - "sha256": "cd60b56f750e382ada6a9bbaba1baefef3a43e8fdb41ee6a12c89e1b8e1f9487" + "sha256": "b2192fb6261d08744a04415e3478dd56f477edcd1e57245415a6c3eb7f77d0a0" }, { "path": "generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Validation.kt", - "sha256": "f4c98e5f568968160687ffaf9ddbba4db51e25266c5baf927cde8b24e6a2442a" + "sha256": "36de7f3b631b7ae1cf2a0c8af21d5acab208d034d7344ee746cdae4bfeee76ff" }, { "path": "generated/python/databreeze_contracts/__init__.py", @@ -87,7 +105,7 @@ }, { "path": "generated/python/databreeze_contracts/v1/__init__.py", - "sha256": "785ff1b0fde763730070345f43b494203f149b10879683364fcbec0fcc35cb7e" + "sha256": "13fe14e8acc4331a59a3a8a78b2245defefaf3ea6c0bb119e3a50e87caa87d5d" }, { "path": "generated/python/databreeze_contracts/v1/_validation.py", @@ -95,7 +113,7 @@ }, { "path": "generated/python/databreeze_contracts/v1/models.py", - "sha256": "435a3af5a513fd14fbf232c0f289b901f4b3722a12b9f3a559d59df404fee4ab" + "sha256": "88b2b669895974cf9ae8fadf11ce4cbb9693333ff05f147f26c9a611a91c9723" }, { "path": "generated/python/pyproject.toml", @@ -103,11 +121,11 @@ }, { "path": "generated/typescript/v1/index.ts", - "sha256": "59e3b40f806d91a6d82b81a59e8937e6e0716d4eab44dbd5d2740e8eeb14912b" + "sha256": "d6e259477eff513ad79d0ebfb515d5a6a3cc491d52a9b1d7cf5af9f6c14200ad" }, { "path": "generated/typescript/v1/validation.mjs", - "sha256": "0d48072b7dbc919e7ac1d9a3fbd8b864137594a98cf33e900d239564851fda7e" + "sha256": "dbb9e3201d1210ef0f638dbf65467001b5ebded40c1c02fa16c273f8fb73032b" } ], "publicPackageSurfaces": [ @@ -130,12 +148,15 @@ "import": "./generated/typescript/v1/validation.mjs" }, "./v1/actor-metadata": "./schemas/v1/actor-metadata.schema.json", + "./v1/autopilot-folder-binding": "./schemas/v1/autopilot-folder-binding.schema.json", "./v1/command-envelope": "./schemas/v1/command-envelope.schema.json", "./v1/correlation-metadata": "./schemas/v1/correlation-metadata.schema.json", "./v1/cursor-page": "./schemas/v1/cursor-page.schema.json", "./v1/event-envelope": "./schemas/v1/event-envelope.schema.json", + "./v1/folder-autopilot-profile": "./schemas/v1/folder-autopilot-profile.schema.json", "./v1/identifier": "./schemas/v1/identifier.schema.json", "./v1/problem-details": "./schemas/v1/problem-details.schema.json", + "./v1/recipe-assignment": "./schemas/v1/recipe-assignment.schema.json", "./v1/revision": "./schemas/v1/revision.schema.json", "./v1/tenant-scope": "./schemas/v1/tenant-scope.schema.json", "./v1/utc-timestamp": "./schemas/v1/utc-timestamp.schema.json" diff --git a/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt b/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt index 295949ab..c665607a 100644 --- a/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt +++ b/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Models.kt @@ -19,6 +19,17 @@ public data class ActorMetadata( public val actorType: String, ) +public data class AutopilotFolderBinding( + public val bindingId: Identifier, + public val createdAt: UtcTimestamp, + public val deviceGrantId: Identifier, + public val expectedCapabilityDigest: String, + public val revision: Revision, + public val role: String, + public val schemaVersion: Long, + public val tenantScope: TenantScope, +) + public data class CommandEnvelope( public val actor: ActorMetadata, public val commandId: Identifier, @@ -69,6 +80,21 @@ public data class EventEnvelopeEntity( public val revision: Revision, ) +public data class FolderAutopilotProfile( + public val collisionPolicy: String, + public val createdAt: UtcTimestamp, + public val maxFilesPerScan: Long, + public val outputLineageEnabled: Boolean, + public val payloadHash: String, + public val profileId: Identifier, + public val revision: Revision, + public val schemaVersion: Long, + public val stabilizationDelayMs: Long, + public val tenantScope: TenantScope, + public val undoWindowSeconds: Long, + public val version: Long, +) + public data class OrganizationScope( public val organizationId: Identifier, ) : TenantScope { @@ -119,6 +145,26 @@ public data class ProjectScope( public override val scopeType: String = "project" } +public data class RecipeAssignment( + public val assignmentId: Identifier, + public val createdAt: UtcTimestamp, + public val dataModeConstraint: String? = null, + public val deviceId: Identifier, + public val effectiveDataModePolicyRef: Identifier? = null, + public val idempotencyKey: String, + public val inputBindingIds: List, + public val jraRecipeVersionHash: String, + public val jraRecipeVersionId: Identifier, + public val outputBindingIds: List, + public val profileHash: String, + public val profileId: Identifier, + public val profileVersion: Long, + public val revision: Revision, + public val schemaVersion: Long, + public val state: String, + public val tenantScope: TenantScope, +) + public data class WorkspaceScope( public val organizationId: Identifier, public val workspaceId: Identifier, diff --git a/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Validation.kt b/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Validation.kt index 9422343e..f6e3a341 100644 --- a/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Validation.kt +++ b/packages/contracts/generated/kotlin/src/main/kotlin/com/databreeze/contracts/v1/Validation.kt @@ -49,12 +49,15 @@ private fun decodeSchema(encoded: String): String = private val schemaSources: Map = mapOf( "https://schemas.databreeze.dev/contracts/v1/actor-metadata" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2FjdG9yLW1ldGFkYXRhIiwiJGNvbW1lbnQiOiJTaGFyZWQgYWN0b3IgaWRlbnRpdHkgbWV0YWRhdGEgdXNlZCBieSBjb21tYW5kcyBhbmQgZXZlbnRzOyBzdXBwb3J0cyBBVUQtMDA0LiIsInRpdGxlIjoiQWN0b3IgTWV0YWRhdGEiLCJkZXNjcmlwdGlvbiI6IlRoZSBzdGFibGUgdHlwZSBhbmQgaWRlbnRpZmllciBvZiB0aGUgcHJpbmNpcGFsIHJlc3BvbnNpYmxlIGZvciBhbiBhY3Rpb24uIiwidHlwZSI6Im9iamVjdCIsImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZSwicmVxdWlyZWQiOlsiYWN0b3JUeXBlIiwiYWN0b3JJZCJdLCJwcm9wZXJ0aWVzIjp7ImFjdG9yVHlwZSI6eyJ0eXBlIjoic3RyaW5nIiwicGF0dGVybiI6Il5bYS16XVthLXowLTlfLV17MCw2Mn0kIn0sImFjdG9ySWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9fX0="), + "https://schemas.databreeze.dev/contracts/v1/autopilot-folder-binding" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2F1dG9waWxvdC1mb2xkZXItYmluZGluZyIsIiRjb21tZW50IjoiRkEtMDAxLi5GQS0wMDM6IGFuIG9wYXF1ZSBEU08gRGV2aWNlR3JhbnQgcmVmZXJlbmNlOyBuZXZlciBhIHBhdGgsIGxvY2FsIGhhbmRsZSwgb3IgcmV2b2NhdGlvbiByZWNvcmQuIiwidGl0bGUiOiJBdXRvcGlsb3QgRm9sZGVyIEJpbmRpbmciLCJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJzY2hlbWFWZXJzaW9uIiwiYmluZGluZ0lkIiwidGVuYW50U2NvcGUiLCJkZXZpY2VHcmFudElkIiwicm9sZSIsImV4cGVjdGVkQ2FwYWJpbGl0eURpZ2VzdCIsImNyZWF0ZWRBdCIsInJldmlzaW9uIl0sInByb3BlcnRpZXMiOnsic2NoZW1hVmVyc2lvbiI6eyJjb25zdCI6MX0sImJpbmRpbmdJZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sInRlbmFudFNjb3BlIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3RlbmFudC1zY29wZSJ9LCJkZXZpY2VHcmFudElkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwicm9sZSI6eyJ0eXBlIjoic3RyaW5nIiwicGF0dGVybiI6Il4oSU5QVVR8T1VUUFVUKSQifSwiZXhwZWN0ZWRDYXBhYmlsaXR5RGlnZXN0Ijp7InR5cGUiOiJzdHJpbmciLCJwYXR0ZXJuIjoiXlswLTlhLWZdezY0fSQifSwiY3JlYXRlZEF0Ijp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3V0Yy10aW1lc3RhbXAifSwicmV2aXNpb24iOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvcmV2aXNpb24ifX19"), "https://schemas.databreeze.dev/contracts/v1/command-envelope" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2NvbW1hbmQtZW52ZWxvcGUiLCIkY29tbWVudCI6IlBhcnRpYWwgZm91bmRhdGlvbiBjb3ZlcmFnZSBmb3IgSU5ULTAwNCBhbmQgSUFNLTAxOS4iLCJ0aXRsZSI6IklkZW1wb3RlbnQgQ29tbWFuZCBFbnZlbG9wZSIsImRlc2NyaXB0aW9uIjoiVGhlIHNoYXJlZCBjbG9zZWQgZW52ZWxvcGUgZm9yIGFuIGlkZW1wb3RlbnQsIHRlbmFudC1zY29wZWQgY29tbWFuZC4iLCJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJjb21tYW5kSWQiLCJjb21tYW5kVHlwZSIsInNjaGVtYVZlcnNpb24iLCJ0ZW5hbnRTY29wZSIsImFjdG9yIiwiY29ycmVsYXRpb24iLCJpc3N1ZWRBdCIsImlkZW1wb3RlbmN5S2V5IiwiZGF0YSJdLCJwcm9wZXJ0aWVzIjp7ImNvbW1hbmRJZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sImNvbW1hbmRUeXBlIjp7InR5cGUiOiJzdHJpbmciLCJwYXR0ZXJuIjoiXlthLXpdW2EtejAtOV8tXSooXFwuW2Etel1bYS16MC05Xy1dKikrJCJ9LCJzY2hlbWFWZXJzaW9uIjp7InR5cGUiOiJpbnRlZ2VyIiwibWluaW11bSI6MX0sInRlbmFudFNjb3BlIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3RlbmFudC1zY29wZSJ9LCJhY3RvciI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9hY3Rvci1tZXRhZGF0YSJ9LCJjb3JyZWxhdGlvbiI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9jb3JyZWxhdGlvbi1tZXRhZGF0YSJ9LCJpc3N1ZWRBdCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS91dGMtdGltZXN0YW1wIn0sImlkZW1wb3RlbmN5S2V5Ijp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjEsIm1heExlbmd0aCI6MjU1fSwiZGF0YSI6eyJ0eXBlIjoib2JqZWN0In19fQ=="), "https://schemas.databreeze.dev/contracts/v1/correlation-metadata" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2NvcnJlbGF0aW9uLW1ldGFkYXRhIiwiJGNvbW1lbnQiOiJQYXJ0aWFsIGZvdW5kYXRpb24gY292ZXJhZ2UgZm9yIEFVRC0wMDQgYW5kIElOVC0wMjEuIiwidGl0bGUiOiJDb3JyZWxhdGlvbiBNZXRhZGF0YSIsImRlc2NyaXB0aW9uIjoiQ29udGVudC1zYWZlIGlkZW50aWZpZXJzIHVzZWQgdG8gam9pbiBhIHJlcXVlc3Qgb3IgZXZlbnQgY2hhaW4uIiwidHlwZSI6Im9iamVjdCIsImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZSwicmVxdWlyZWQiOlsiY29ycmVsYXRpb25JZCJdLCJwcm9wZXJ0aWVzIjp7ImNvcnJlbGF0aW9uSWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9LCJjYXVzYXRpb25JZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sInJlcXVlc3RJZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn19fQ=="), "https://schemas.databreeze.dev/contracts/v1/cursor-page" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2N1cnNvci1wYWdlIiwiJGNvbW1lbnQiOiJTaGFyZWQgcGFnaW5hdGlvbiBzaGFwZSBzdXBwb3J0aW5nIElOVC0wMDUuIiwidGl0bGUiOiJDdXJzb3IgUGFnZSBFbnZlbG9wZSIsImRlc2NyaXB0aW9uIjoiVGhlIGNhbm9uaWNhbCBjbG9zZWQgcGFnZSBlbnZlbG9wZSB3aXRoIGEgVVRDIHNuYXBzaG90IGFuZCBvcGFxdWUgY29udGludWF0aW9uIGN1cnNvci4iLCJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJkYXRhIiwic25hcHNob3RBdCIsImhhc01vcmUiXSwicHJvcGVydGllcyI6eyJkYXRhIjp7InR5cGUiOiJhcnJheSIsIml0ZW1zIjp7fX0sIm5leHRDdXJzb3IiOnsidHlwZSI6InN0cmluZyIsIm1pbkxlbmd0aCI6MSwibWF4TGVuZ3RoIjo0MDk2fSwic25hcHNob3RBdCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS91dGMtdGltZXN0YW1wIn0sImhhc01vcmUiOnsidHlwZSI6ImJvb2xlYW4ifX0sImFsbE9mIjpbeyJpZiI6eyJwcm9wZXJ0aWVzIjp7Imhhc01vcmUiOnsiY29uc3QiOnRydWV9fSwicmVxdWlyZWQiOlsiaGFzTW9yZSJdfSwidGhlbiI6eyJwcm9wZXJ0aWVzIjp7Im5leHRDdXJzb3IiOnRydWV9LCJyZXF1aXJlZCI6WyJuZXh0Q3Vyc29yIl19LCJlbHNlIjp7Im5vdCI6eyJwcm9wZXJ0aWVzIjp7Im5leHRDdXJzb3IiOnRydWV9LCJyZXF1aXJlZCI6WyJuZXh0Q3Vyc29yIl19fX1dfQ=="), "https://schemas.databreeze.dev/contracts/v1/event-envelope" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2V2ZW50LWVudmVsb3BlIiwiJGNvbW1lbnQiOiJDYW5vbmljYWwgZXZlbnQgYmFzZSBzdXBwb3J0aW5nIEFVRC0wMDQsIEFVRC0wMDYsIElBTS0wMTksIGFuZCBJTlQtMDA4LiIsInRpdGxlIjoiQ2Fub25pY2FsIEV2ZW50IEVudmVsb3BlIiwiZGVzY3JpcHRpb24iOiJUaGUgc2hhcmVkIGNsb3NlZCBlbnZlbG9wZSBmb3IgYSB2ZXJzaW9uZWQsIHRlbmFudC1zY29wZWQgZG9tYWluIGV2ZW50LiIsInR5cGUiOiJvYmplY3QiLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2UsInJlcXVpcmVkIjpbImV2ZW50SWQiLCJldmVudFR5cGUiLCJzY2hlbWFWZXJzaW9uIiwidGVuYW50U2NvcGUiLCJlbnRpdHkiLCJhY3RvciIsImNvcnJlbGF0aW9uIiwic291cmNlQ29tcG9uZW50Iiwib2NjdXJyZWRBdCIsImRhdGEiXSwicHJvcGVydGllcyI6eyJldmVudElkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwiZXZlbnRUeXBlIjp7InR5cGUiOiJzdHJpbmciLCJwYXR0ZXJuIjoiXlthLXpdW2EtejAtOV8tXSooXFwuW2Etel1bYS16MC05Xy1dKikrJCJ9LCJzY2hlbWFWZXJzaW9uIjp7InR5cGUiOiJpbnRlZ2VyIiwibWluaW11bSI6MX0sInRlbmFudFNjb3BlIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3RlbmFudC1zY29wZSJ9LCJlbnRpdHkiOnsidHlwZSI6Im9iamVjdCIsImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZSwicmVxdWlyZWQiOlsiZW50aXR5VHlwZSIsImVudGl0eUlkIiwicmV2aXNpb24iXSwicHJvcGVydGllcyI6eyJlbnRpdHlUeXBlIjp7InR5cGUiOiJzdHJpbmciLCJwYXR0ZXJuIjoiXlthLXpdW2EtejAtOV8tXXswLDYyfSQifSwiZW50aXR5SWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9LCJyZXZpc2lvbiI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9yZXZpc2lvbiJ9fX0sImFjdG9yIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2FjdG9yLW1ldGFkYXRhIn0sImNvcnJlbGF0aW9uIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2NvcnJlbGF0aW9uLW1ldGFkYXRhIn0sInNvdXJjZUNvbXBvbmVudCI6eyJ0eXBlIjoic3RyaW5nIiwicGF0dGVybiI6Il5bYS16XVthLXowLTlfLV17MCw2Mn0kIn0sIm9jY3VycmVkQXQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvdXRjLXRpbWVzdGFtcCJ9LCJkYXRhIjp7InR5cGUiOiJvYmplY3QifX19"), + "https://schemas.databreeze.dev/contracts/v1/folder-autopilot-profile" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2ZvbGRlci1hdXRvcGlsb3QtcHJvZmlsZSIsIiRjb21tZW50IjoiRkEtMDAxLi5GQS0wMDc6IGltbXV0YWJsZSB0eXBlZCBwcm9maWxlIHBheWxvYWQ7IG5vIGxvY2FsIHBhdGggb3IgcmVjaXBlIGF1dGhvcml0eS4iLCJ0aXRsZSI6IkZvbGRlciBBdXRvcGlsb3QgUHJvZmlsZSIsInR5cGUiOiJvYmplY3QiLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2UsInJlcXVpcmVkIjpbInNjaGVtYVZlcnNpb24iLCJwcm9maWxlSWQiLCJ0ZW5hbnRTY29wZSIsInZlcnNpb24iLCJwYXlsb2FkSGFzaCIsInN0YWJpbGl6YXRpb25EZWxheU1zIiwibWF4RmlsZXNQZXJTY2FuIiwiY29sbGlzaW9uUG9saWN5IiwidW5kb1dpbmRvd1NlY29uZHMiLCJvdXRwdXRMaW5lYWdlRW5hYmxlZCIsImNyZWF0ZWRBdCIsInJldmlzaW9uIl0sInByb3BlcnRpZXMiOnsic2NoZW1hVmVyc2lvbiI6eyJjb25zdCI6MX0sInByb2ZpbGVJZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sInRlbmFudFNjb3BlIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3RlbmFudC1zY29wZSJ9LCJ2ZXJzaW9uIjp7InR5cGUiOiJpbnRlZ2VyIiwibWluaW11bSI6MSwibWF4aW11bSI6MTAwMDB9LCJwYXlsb2FkSGFzaCI6eyJ0eXBlIjoic3RyaW5nIiwicGF0dGVybiI6Il5bMC05YS1mXXs2NH0kIn0sInN0YWJpbGl6YXRpb25EZWxheU1zIjp7InR5cGUiOiJpbnRlZ2VyIiwibWluaW11bSI6MCwibWF4aW11bSI6ODY0MDAwMDB9LCJtYXhGaWxlc1BlclNjYW4iOnsidHlwZSI6ImludGVnZXIiLCJtaW5pbXVtIjoxLCJtYXhpbXVtIjoxMDAwMDB9LCJjb2xsaXNpb25Qb2xpY3kiOnsidHlwZSI6InN0cmluZyIsInBhdHRlcm4iOiJeKFJFVklFV3xTS0lQfFVOSVFVRV9OQU1FKSQifSwidW5kb1dpbmRvd1NlY29uZHMiOnsidHlwZSI6ImludGVnZXIiLCJtaW5pbXVtIjowLCJtYXhpbXVtIjo2MDQ4MDB9LCJvdXRwdXRMaW5lYWdlRW5hYmxlZCI6eyJ0eXBlIjoiYm9vbGVhbiJ9LCJjcmVhdGVkQXQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvdXRjLXRpbWVzdGFtcCJ9LCJyZXZpc2lvbiI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9yZXZpc2lvbiJ9fX0="), "https://schemas.databreeze.dev/contracts/v1/identifier" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIiLCIkY29tbWVudCI6IlBhcnRpYWwgZm91bmRhdGlvbiBjb3ZlcmFnZSBmb3IgSUFNLTAwMS4iLCJ0aXRsZSI6IlN0YWJsZSBVVUlEIElkZW50aWZpZXIiLCJkZXNjcmlwdGlvbiI6IkFuIG9wYXF1ZSBzdGFibGUgVVVJRCBpZGVudGlmaWVyLiIsInR5cGUiOiJzdHJpbmciLCJmb3JtYXQiOiJ1dWlkIn0="), "https://schemas.databreeze.dev/contracts/v1/problem-details" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3Byb2JsZW0tZGV0YWlscyIsIiRjb21tZW50IjoiUkZDIDc4MDctY29tcGF0aWJsZSBiYXNlIHdpdGggdGhlIHNhZmUgcHVibGljIGVycm9yIG1ldGFkYXRhIHJlcXVpcmVkIGJ5IElOVC0wMjEgYW5kIFdFQi0wMjEuIiwidGl0bGUiOiJQcm9ibGVtIERldGFpbHMiLCJkZXNjcmlwdGlvbiI6IkEgY2xvc2VkIFJGQyA3ODA3LWNvbXBhdGlibGUgcHJvYmxlbSBkb2N1bWVudCB3aXRoIERhdGFCcmVlemUgcHVibGljIGVycm9yIGV4dGVuc2lvbnMuIiwidHlwZSI6Im9iamVjdCIsImFkZGl0aW9uYWxQcm9wZXJ0aWVzIjpmYWxzZSwicmVxdWlyZWQiOlsidHlwZSIsInN0YXR1cyIsImNvZGUiLCJjb3JyZWxhdGlvbklkIiwicmV0cnlhYmxlIl0sImFueU9mIjpbeyJwcm9wZXJ0aWVzIjp7InRpdGxlS2V5Ijp0cnVlfSwicmVxdWlyZWQiOlsidGl0bGVLZXkiXX0seyJwcm9wZXJ0aWVzIjp7Im1lc3NhZ2VLZXkiOnRydWV9LCJyZXF1aXJlZCI6WyJtZXNzYWdlS2V5Il19XSwicHJvcGVydGllcyI6eyJ0eXBlIjp7InR5cGUiOiJzdHJpbmciLCJmb3JtYXQiOiJ1cmktcmVmZXJlbmNlIn0sInRpdGxlIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjF9LCJ0aXRsZUtleSI6eyJ0eXBlIjoic3RyaW5nIiwibWluTGVuZ3RoIjoxLCJtYXhMZW5ndGgiOjI1NX0sInN0YXR1cyI6eyJ0eXBlIjoiaW50ZWdlciIsIm1pbmltdW0iOjEwMCwibWF4aW11bSI6NTk5fSwiZGV0YWlsIjp7InR5cGUiOiJzdHJpbmcifSwiaW5zdGFuY2UiOnsidHlwZSI6InN0cmluZyIsImZvcm1hdCI6InVyaS1yZWZlcmVuY2UifSwiY29kZSI6eyJ0eXBlIjoic3RyaW5nIiwicGF0dGVybiI6Il5bQS1aXVtBLVowLTlfXXswLDEyN30kIn0sImNvcnJlbGF0aW9uSWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9LCJyZXRyeWFibGUiOnsidHlwZSI6ImJvb2xlYW4ifSwibWVzc2FnZUtleSI6eyJ0eXBlIjoic3RyaW5nIiwibWluTGVuZ3RoIjoxLCJtYXhMZW5ndGgiOjI1NX0sImZpZWxkRXJyb3JzIjp7InR5cGUiOiJhcnJheSIsIm1heEl0ZW1zIjoxMDAsIml0ZW1zIjp7InR5cGUiOiJvYmplY3QiLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2UsInJlcXVpcmVkIjpbImZpZWxkIiwiY29kZSJdLCJwcm9wZXJ0aWVzIjp7ImZpZWxkIjp7InR5cGUiOiJzdHJpbmciLCJtaW5MZW5ndGgiOjEsIm1heExlbmd0aCI6MjU1fSwiY29kZSI6eyJ0eXBlIjoic3RyaW5nIiwicGF0dGVybiI6Il5bQS1aXVtBLVowLTlfXXswLDEyN30kIn19fX0sInJldHJ5QWZ0ZXJTZWNvbmRzIjp7InR5cGUiOiJpbnRlZ2VyIiwibWluaW11bSI6MH0sImN1cnJlbnRSZXZpc2lvbiI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9yZXZpc2lvbiJ9LCJyZW1lZGlhdGlvbkFjdGlvbiI6eyJ0eXBlIjoic3RyaW5nIiwibWluTGVuZ3RoIjoxLCJtYXhMZW5ndGgiOjI1NX0sInJhdGVMaW1pdCI6eyJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJzY29wZSIsInJlc2V0QXQiXSwicHJvcGVydGllcyI6eyJzY29wZSI6eyJ0eXBlIjoic3RyaW5nIiwibWluTGVuZ3RoIjoxLCJtYXhMZW5ndGgiOjI1NX0sImxpbWl0Ijp7InR5cGUiOiJpbnRlZ2VyIiwibWluaW11bSI6MH0sInJlbWFpbmluZyI6eyJ0eXBlIjoiaW50ZWdlciIsIm1pbmltdW0iOjB9LCJyZXNldEF0Ijp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3V0Yy10aW1lc3RhbXAifX19fX0="), + "https://schemas.databreeze.dev/contracts/v1/recipe-assignment" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3JlY2lwZS1hc3NpZ25tZW50IiwiJGNvbW1lbnQiOiJGQS0wMDUuLkZBLTAwNywgRkEtMDE0LCBGQS0wMTUsIEZBLTAzMTogSlJBIGFuZCBEU08gYXJlIHJlZmVyZW5jZWQgYnkgb3BhcXVlIElEcyBhbmQgaGFzaGVzLiIsInRpdGxlIjoiRm9sZGVyIEF1dG9waWxvdCBSZWNpcGUgQXNzaWdubWVudCIsInR5cGUiOiJvYmplY3QiLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2UsInJlcXVpcmVkIjpbInNjaGVtYVZlcnNpb24iLCJhc3NpZ25tZW50SWQiLCJ0ZW5hbnRTY29wZSIsInByb2ZpbGVJZCIsInByb2ZpbGVWZXJzaW9uIiwicHJvZmlsZUhhc2giLCJqcmFSZWNpcGVWZXJzaW9uSWQiLCJqcmFSZWNpcGVWZXJzaW9uSGFzaCIsImRldmljZUlkIiwiaW5wdXRCaW5kaW5nSWRzIiwib3V0cHV0QmluZGluZ0lkcyIsImlkZW1wb3RlbmN5S2V5Iiwic3RhdGUiLCJyZXZpc2lvbiIsImNyZWF0ZWRBdCJdLCJwcm9wZXJ0aWVzIjp7InNjaGVtYVZlcnNpb24iOnsiY29uc3QiOjF9LCJhc3NpZ25tZW50SWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9LCJ0ZW5hbnRTY29wZSI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS90ZW5hbnQtc2NvcGUifSwicHJvZmlsZUlkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwicHJvZmlsZVZlcnNpb24iOnsidHlwZSI6ImludGVnZXIiLCJtaW5pbXVtIjoxLCJtYXhpbXVtIjoxMDAwMH0sInByb2ZpbGVIYXNoIjp7InR5cGUiOiJzdHJpbmciLCJwYXR0ZXJuIjoiXlswLTlhLWZdezY0fSQifSwianJhUmVjaXBlVmVyc2lvbklkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwianJhUmVjaXBlVmVyc2lvbkhhc2giOnsidHlwZSI6InN0cmluZyIsInBhdHRlcm4iOiJeWzAtOWEtZl17NjR9JCJ9LCJkZXZpY2VJZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sImlucHV0QmluZGluZ0lkcyI6eyJ0eXBlIjoiYXJyYXkiLCJpdGVtcyI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sIm1heEl0ZW1zIjozMn0sIm91dHB1dEJpbmRpbmdJZHMiOnsidHlwZSI6ImFycmF5IiwiaXRlbXMiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9LCJtYXhJdGVtcyI6MzJ9LCJkYXRhTW9kZUNvbnN0cmFpbnQiOnsidHlwZSI6InN0cmluZyIsInBhdHRlcm4iOiJeKExPQ0FMfEhZQlJJRHxDTE9VRCkkIn0sImVmZmVjdGl2ZURhdGFNb2RlUG9saWN5UmVmIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwiaWRlbXBvdGVuY3lLZXkiOnsidHlwZSI6InN0cmluZyIsIm1pbkxlbmd0aCI6MSwibWF4TGVuZ3RoIjoyMDB9LCJzdGF0ZSI6eyJ0eXBlIjoic3RyaW5nIiwicGF0dGVybiI6Il4oRFJBRlR8QUNUSVZFfFBBVVNFRHxSRVRJUkVEKSQifSwicmV2aXNpb24iOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvcmV2aXNpb24ifSwiY3JlYXRlZEF0Ijp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3V0Yy10aW1lc3RhbXAifX19"), "https://schemas.databreeze.dev/contracts/v1/revision" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3JldmlzaW9uIiwiJGNvbW1lbnQiOiJTdXBwb3J0cyBvcHRpbWlzdGljLWNvbmN1cnJlbmN5IHJldmlzaW9ucyBkZXNjcmliZWQgYnkgdGhlIGRvbWFpbiBhbmQgZGF0YSBtb2RlbC4iLCJ0aXRsZSI6IkVudGl0eSBSZXZpc2lvbiIsImRlc2NyaXB0aW9uIjoiQSBwb3NpdGl2ZSwgbW9ub3RvbmljYWxseSBpbmNyZWFzaW5nIGVudGl0eSByZXZpc2lvbi4iLCJ0eXBlIjoiaW50ZWdlciIsIm1pbmltdW0iOjF9"), "https://schemas.databreeze.dev/contracts/v1/tenant-scope" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3RlbmFudC1zY29wZSIsIiRjb21tZW50IjoiUGFydGlhbCBmb3VuZGF0aW9uIGNvdmVyYWdlIGZvciBJQU0tMDE5LiIsInRpdGxlIjoiVGVuYW50IFNjb3BlIiwiZGVzY3JpcHRpb24iOiJBIGRpc2NyaW1pbmF0ZWQgdGVuYW50IHNjb3BlIGNvbnRhaW5pbmcgdGhlIGNvbXBsZXRlIGFuY2VzdHJ5IHJlcXVpcmVkIGF0IGl0cyBsZXZlbC4iLCJvbmVPZiI6W3siJHJlZiI6IiMvJGRlZnMvb3JnYW5pemF0aW9uU2NvcGUifSx7IiRyZWYiOiIjLyRkZWZzL3dvcmtzcGFjZVNjb3BlIn0seyIkcmVmIjoiIy8kZGVmcy9wcm9qZWN0U2NvcGUifV0sIiRkZWZzIjp7Im9yZ2FuaXphdGlvblNjb3BlIjp7InR5cGUiOiJvYmplY3QiLCJhZGRpdGlvbmFsUHJvcGVydGllcyI6ZmFsc2UsInJlcXVpcmVkIjpbInNjb3BlVHlwZSIsIm9yZ2FuaXphdGlvbklkIl0sInByb3BlcnRpZXMiOnsic2NvcGVUeXBlIjp7ImNvbnN0Ijoib3JnYW5pemF0aW9uIn0sIm9yZ2FuaXphdGlvbklkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifX19LCJ3b3Jrc3BhY2VTY29wZSI6eyJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJzY29wZVR5cGUiLCJvcmdhbml6YXRpb25JZCIsIndvcmtzcGFjZUlkIl0sInByb3BlcnRpZXMiOnsic2NvcGVUeXBlIjp7ImNvbnN0Ijoid29ya3NwYWNlIn0sIm9yZ2FuaXphdGlvbklkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwid29ya3NwYWNlSWQiOnsiJHJlZiI6Imh0dHBzOi8vc2NoZW1hcy5kYXRhYnJlZXplLmRldi9jb250cmFjdHMvdjEvaWRlbnRpZmllciJ9fX0sInByb2plY3RTY29wZSI6eyJ0eXBlIjoib2JqZWN0IiwiYWRkaXRpb25hbFByb3BlcnRpZXMiOmZhbHNlLCJyZXF1aXJlZCI6WyJzY29wZVR5cGUiLCJvcmdhbml6YXRpb25JZCIsIndvcmtzcGFjZUlkIiwicHJvamVjdElkIl0sInByb3BlcnRpZXMiOnsic2NvcGVUeXBlIjp7ImNvbnN0IjoicHJvamVjdCJ9LCJvcmdhbml6YXRpb25JZCI6eyIkcmVmIjoiaHR0cHM6Ly9zY2hlbWFzLmRhdGFicmVlemUuZGV2L2NvbnRyYWN0cy92MS9pZGVudGlmaWVyIn0sIndvcmtzcGFjZUlkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifSwicHJvamVjdElkIjp7IiRyZWYiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL2lkZW50aWZpZXIifX19fX0="), "https://schemas.databreeze.dev/contracts/v1/utc-timestamp" to decodeSchema("eyIkc2NoZW1hIjoiaHR0cHM6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQvMjAyMC0xMi9zY2hlbWEiLCIkaWQiOiJodHRwczovL3NjaGVtYXMuZGF0YWJyZWV6ZS5kZXYvY29udHJhY3RzL3YxL3V0Yy10aW1lc3RhbXAiLCIkY29tbWVudCI6IlBhcnRpYWwgZm91bmRhdGlvbiBjb3ZlcmFnZSBmb3IgSUFNLTAwMSBhbmQgSU5ULTAwOC4iLCJ0aXRsZSI6IlVUQyBUaW1lc3RhbXAiLCJkZXNjcmlwdGlvbiI6IkFuIFJGQyAzMzM5IGRhdGUtdGltZSBub3JtYWxpemVkIHRvIFVUQyBhbmQgdGVybWluYXRlZCBieSB1cHBlcmNhc2UgWi4iLCJ0eXBlIjoic3RyaW5nIiwiZm9ybWF0IjoiZGF0ZS10aW1lIiwicGF0dGVybiI6IlokIn0="), @@ -67,6 +70,7 @@ private val schemaRegistry: SchemaRegistry = private fun constructGeneratedModel(schemaId: String, payload: JsonNode): Any = when (schemaId) { "https://schemas.databreeze.dev/contracts/v1/actor-metadata" -> mapper.treeToValue(payload, ActorMetadata::class.java) + "https://schemas.databreeze.dev/contracts/v1/autopilot-folder-binding" -> mapper.treeToValue(payload, AutopilotFolderBinding::class.java) "https://schemas.databreeze.dev/contracts/v1/command-envelope" -> mapper.convertValue( payload, object : TypeReference>() {}, @@ -80,8 +84,10 @@ private fun constructGeneratedModel(schemaId: String, payload: JsonNode): Any = payload, object : TypeReference>() {}, ) + "https://schemas.databreeze.dev/contracts/v1/folder-autopilot-profile" -> mapper.treeToValue(payload, FolderAutopilotProfile::class.java) "https://schemas.databreeze.dev/contracts/v1/identifier" -> mapper.treeToValue(payload, String::class.java) "https://schemas.databreeze.dev/contracts/v1/problem-details" -> mapper.treeToValue(payload, ProblemDetails::class.java) + "https://schemas.databreeze.dev/contracts/v1/recipe-assignment" -> mapper.treeToValue(payload, RecipeAssignment::class.java) "https://schemas.databreeze.dev/contracts/v1/revision" -> payload.longValue() "https://schemas.databreeze.dev/contracts/v1/tenant-scope" -> when (payload.required("scopeType").asText()) { "organization" -> mapper.treeToValue(payload, OrganizationScope::class.java) diff --git a/packages/contracts/generated/python/databreeze_contracts/v1/__init__.py b/packages/contracts/generated/python/databreeze_contracts/v1/__init__.py index 4d609bc1..6f01ee66 100644 --- a/packages/contracts/generated/python/databreeze_contracts/v1/__init__.py +++ b/packages/contracts/generated/python/databreeze_contracts/v1/__init__.py @@ -2,17 +2,20 @@ from .models import ( ActorMetadata, + AutopilotFolderBinding, CommandEnvelope, CorrelationMetadata, CursorPage, EventEnvelope, EventEnvelopeEntity, + FolderAutopilotProfile, Identifier, OrganizationScope, ProblemDetails, ProblemDetailsFieldErrorsItem, ProblemDetailsRateLimit, ProjectScope, + RecipeAssignment, Revision, TenantScope, UtcTimestamp, @@ -21,17 +24,20 @@ __all__ = [ "ActorMetadata", + "AutopilotFolderBinding", "CommandEnvelope", "CorrelationMetadata", "CursorPage", "EventEnvelope", "EventEnvelopeEntity", + "FolderAutopilotProfile", "Identifier", "OrganizationScope", "ProblemDetails", "ProblemDetailsFieldErrorsItem", "ProblemDetailsRateLimit", "ProjectScope", + "RecipeAssignment", "Revision", "TenantScope", "UtcTimestamp", diff --git a/packages/contracts/generated/python/databreeze_contracts/v1/models.py b/packages/contracts/generated/python/databreeze_contracts/v1/models.py index 00438505..b7e2bf4d 100644 --- a/packages/contracts/generated/python/databreeze_contracts/v1/models.py +++ b/packages/contracts/generated/python/databreeze_contracts/v1/models.py @@ -52,6 +52,16 @@ class ActorMetadata(ClosedModel): actorId: Identifier actorType: Annotated[StrictStr, StringConstraints(pattern=r"^[a-z][a-z0-9_-]{0,62}$")] +class AutopilotFolderBinding(ClosedModel): + bindingId: Identifier + createdAt: UtcTimestamp + deviceGrantId: Identifier + expectedCapabilityDigest: Annotated[StrictStr, StringConstraints(pattern=r"^[0-9a-f]{64}$")] + revision: Revision + role: Annotated[StrictStr, StringConstraints(pattern=r"^(INPUT|OUTPUT)$")] + schemaVersion: Literal[1] + tenantScope: TenantScope + class CommandEnvelope(ClosedModel, Generic[TData]): actor: ActorMetadata commandId: Identifier @@ -99,6 +109,20 @@ class EventEnvelopeEntity(ClosedModel): entityType: Annotated[StrictStr, StringConstraints(pattern=r"^[a-z][a-z0-9_-]{0,62}$")] revision: Revision +class FolderAutopilotProfile(ClosedModel): + collisionPolicy: Annotated[StrictStr, StringConstraints(pattern=r"^(REVIEW|SKIP|UNIQUE_NAME)$")] + createdAt: UtcTimestamp + maxFilesPerScan: Annotated[int, Field(strict=True, ge=1, le=100000)] + outputLineageEnabled: StrictBool + payloadHash: Annotated[StrictStr, StringConstraints(pattern=r"^[0-9a-f]{64}$")] + profileId: Identifier + revision: Revision + schemaVersion: Literal[1] + stabilizationDelayMs: Annotated[int, Field(strict=True, ge=0, le=86400000)] + tenantScope: TenantScope + undoWindowSeconds: Annotated[int, Field(strict=True, ge=0, le=604800)] + version: Annotated[int, Field(strict=True, ge=1, le=10000)] + class OrganizationScope(ClosedModel): organizationId: Identifier scopeType: Literal["organization"] @@ -142,6 +166,25 @@ class ProjectScope(ClosedModel): scopeType: Literal["project"] workspaceId: Identifier +class RecipeAssignment(ClosedModel): + assignmentId: Identifier + createdAt: UtcTimestamp + dataModeConstraint: Annotated[StrictStr, StringConstraints(pattern=r"^(LOCAL|HYBRID|CLOUD)$")] | None = None + deviceId: Identifier + effectiveDataModePolicyRef: Identifier | None = None + idempotencyKey: Annotated[StrictStr, StringConstraints(min_length=1, max_length=200)] + inputBindingIds: Annotated[list[Identifier], Field(max_length=32)] + jraRecipeVersionHash: Annotated[StrictStr, StringConstraints(pattern=r"^[0-9a-f]{64}$")] + jraRecipeVersionId: Identifier + outputBindingIds: Annotated[list[Identifier], Field(max_length=32)] + profileHash: Annotated[StrictStr, StringConstraints(pattern=r"^[0-9a-f]{64}$")] + profileId: Identifier + profileVersion: Annotated[int, Field(strict=True, ge=1, le=10000)] + revision: Revision + schemaVersion: Literal[1] + state: Annotated[StrictStr, StringConstraints(pattern=r"^(DRAFT|ACTIVE|PAUSED|RETIRED)$")] + tenantScope: TenantScope + class WorkspaceScope(ClosedModel): organizationId: Identifier scopeType: Literal["workspace"] @@ -150,14 +193,17 @@ class WorkspaceScope(ClosedModel): TenantScope: TypeAlias = Annotated[OrganizationScope | WorkspaceScope | ProjectScope, Field(discriminator="scopeType")] ActorMetadata.model_rebuild() +AutopilotFolderBinding.model_rebuild() CommandEnvelope.model_rebuild() CorrelationMetadata.model_rebuild() CursorPage.model_rebuild() EventEnvelope.model_rebuild() EventEnvelopeEntity.model_rebuild() +FolderAutopilotProfile.model_rebuild() OrganizationScope.model_rebuild() ProblemDetails.model_rebuild() ProblemDetailsFieldErrorsItem.model_rebuild() ProblemDetailsRateLimit.model_rebuild() ProjectScope.model_rebuild() +RecipeAssignment.model_rebuild() WorkspaceScope.model_rebuild() diff --git a/packages/contracts/generated/typescript/v1/index.ts b/packages/contracts/generated/typescript/v1/index.ts index 17114417..a2272583 100644 --- a/packages/contracts/generated/typescript/v1/index.ts +++ b/packages/contracts/generated/typescript/v1/index.ts @@ -9,6 +9,17 @@ export interface ActorMetadata { readonly actorType: string; } +export interface AutopilotFolderBinding { + readonly bindingId: Identifier; + readonly createdAt: UtcTimestamp; + readonly deviceGrantId: Identifier; + readonly expectedCapabilityDigest: string; + readonly revision: Revision; + readonly role: string; + readonly schemaVersion: 1; + readonly tenantScope: TenantScope; +} + export interface CommandEnvelope { readonly actor: ActorMetadata; readonly commandId: Identifier; @@ -58,6 +69,21 @@ export interface EventEnvelopeEntity { readonly revision: Revision; } +export interface FolderAutopilotProfile { + readonly collisionPolicy: string; + readonly createdAt: UtcTimestamp; + readonly maxFilesPerScan: number; + readonly outputLineageEnabled: boolean; + readonly payloadHash: string; + readonly profileId: Identifier; + readonly revision: Revision; + readonly schemaVersion: 1; + readonly stabilizationDelayMs: number; + readonly tenantScope: TenantScope; + readonly undoWindowSeconds: number; + readonly version: number; +} + export type Identifier = string; export interface OrganizationScope { @@ -104,6 +130,26 @@ export interface ProjectScope { readonly workspaceId: Identifier; } +export interface RecipeAssignment { + readonly assignmentId: Identifier; + readonly createdAt: UtcTimestamp; + readonly dataModeConstraint?: string; + readonly deviceId: Identifier; + readonly effectiveDataModePolicyRef?: Identifier; + readonly idempotencyKey: string; + readonly inputBindingIds: readonly Identifier[]; + readonly jraRecipeVersionHash: string; + readonly jraRecipeVersionId: Identifier; + readonly outputBindingIds: readonly Identifier[]; + readonly profileHash: string; + readonly profileId: Identifier; + readonly profileVersion: number; + readonly revision: Revision; + readonly schemaVersion: 1; + readonly state: string; + readonly tenantScope: TenantScope; +} + export type Revision = number; export type TenantScope = OrganizationScope | WorkspaceScope | ProjectScope; @@ -116,7 +162,7 @@ export interface WorkspaceScope { readonly workspaceId: Identifier; } -export type ContractV1SchemaId = "https://schemas.databreeze.dev/contracts/v1/actor-metadata" | "https://schemas.databreeze.dev/contracts/v1/command-envelope" | "https://schemas.databreeze.dev/contracts/v1/correlation-metadata" | "https://schemas.databreeze.dev/contracts/v1/cursor-page" | "https://schemas.databreeze.dev/contracts/v1/event-envelope" | "https://schemas.databreeze.dev/contracts/v1/identifier" | "https://schemas.databreeze.dev/contracts/v1/problem-details" | "https://schemas.databreeze.dev/contracts/v1/revision" | "https://schemas.databreeze.dev/contracts/v1/tenant-scope" | "https://schemas.databreeze.dev/contracts/v1/utc-timestamp"; +export type ContractV1SchemaId = "https://schemas.databreeze.dev/contracts/v1/actor-metadata" | "https://schemas.databreeze.dev/contracts/v1/autopilot-folder-binding" | "https://schemas.databreeze.dev/contracts/v1/command-envelope" | "https://schemas.databreeze.dev/contracts/v1/correlation-metadata" | "https://schemas.databreeze.dev/contracts/v1/cursor-page" | "https://schemas.databreeze.dev/contracts/v1/event-envelope" | "https://schemas.databreeze.dev/contracts/v1/folder-autopilot-profile" | "https://schemas.databreeze.dev/contracts/v1/identifier" | "https://schemas.databreeze.dev/contracts/v1/problem-details" | "https://schemas.databreeze.dev/contracts/v1/recipe-assignment" | "https://schemas.databreeze.dev/contracts/v1/revision" | "https://schemas.databreeze.dev/contracts/v1/tenant-scope" | "https://schemas.databreeze.dev/contracts/v1/utc-timestamp"; export type ContractV1ParseResult = | { readonly accepted: true; readonly value: TValue } diff --git a/packages/contracts/generated/typescript/v1/validation.mjs b/packages/contracts/generated/typescript/v1/validation.mjs index b4cc1597..4445d2d6 100644 --- a/packages/contracts/generated/typescript/v1/validation.mjs +++ b/packages/contracts/generated/typescript/v1/validation.mjs @@ -5,12 +5,15 @@ import addFormats from 'ajv-formats'; const schemas = [ {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/actor-metadata","$comment":"Shared actor identity metadata used by commands and events; supports AUD-004.","title":"Actor Metadata","description":"The stable type and identifier of the principal responsible for an action.","type":"object","additionalProperties":false,"required":["actorType","actorId"],"properties":{"actorType":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,62}$"},"actorId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"}}}, + {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/autopilot-folder-binding","$comment":"FA-001..FA-003: an opaque DSO DeviceGrant reference; never a path, local handle, or revocation record.","title":"Autopilot Folder Binding","type":"object","additionalProperties":false,"required":["schemaVersion","bindingId","tenantScope","deviceGrantId","role","expectedCapabilityDigest","createdAt","revision"],"properties":{"schemaVersion":{"const":1},"bindingId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"tenantScope":{"$ref":"https://schemas.databreeze.dev/contracts/v1/tenant-scope"},"deviceGrantId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"role":{"type":"string","pattern":"^(INPUT|OUTPUT)$"},"expectedCapabilityDigest":{"type":"string","pattern":"^[0-9a-f]{64}$"},"createdAt":{"$ref":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp"},"revision":{"$ref":"https://schemas.databreeze.dev/contracts/v1/revision"}}}, {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/command-envelope","$comment":"Partial foundation coverage for INT-004 and IAM-019.","title":"Idempotent Command Envelope","description":"The shared closed envelope for an idempotent, tenant-scoped command.","type":"object","additionalProperties":false,"required":["commandId","commandType","schemaVersion","tenantScope","actor","correlation","issuedAt","idempotencyKey","data"],"properties":{"commandId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"commandType":{"type":"string","pattern":"^[a-z][a-z0-9_-]*(\\.[a-z][a-z0-9_-]*)+$"},"schemaVersion":{"type":"integer","minimum":1},"tenantScope":{"$ref":"https://schemas.databreeze.dev/contracts/v1/tenant-scope"},"actor":{"$ref":"https://schemas.databreeze.dev/contracts/v1/actor-metadata"},"correlation":{"$ref":"https://schemas.databreeze.dev/contracts/v1/correlation-metadata"},"issuedAt":{"$ref":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp"},"idempotencyKey":{"type":"string","minLength":1,"maxLength":255},"data":{"type":"object"}}}, {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/correlation-metadata","$comment":"Partial foundation coverage for AUD-004 and INT-021.","title":"Correlation Metadata","description":"Content-safe identifiers used to join a request or event chain.","type":"object","additionalProperties":false,"required":["correlationId"],"properties":{"correlationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"causationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"requestId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"}}}, {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/cursor-page","$comment":"Shared pagination shape supporting INT-005.","title":"Cursor Page Envelope","description":"The canonical closed page envelope with a UTC snapshot and opaque continuation cursor.","type":"object","additionalProperties":false,"required":["data","snapshotAt","hasMore"],"properties":{"data":{"type":"array","items":{}},"nextCursor":{"type":"string","minLength":1,"maxLength":4096},"snapshotAt":{"$ref":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp"},"hasMore":{"type":"boolean"}},"allOf":[{"if":{"properties":{"hasMore":{"const":true}},"required":["hasMore"]},"then":{"properties":{"nextCursor":true},"required":["nextCursor"]},"else":{"not":{"properties":{"nextCursor":true},"required":["nextCursor"]}}}]}, {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/event-envelope","$comment":"Canonical event base supporting AUD-004, AUD-006, IAM-019, and INT-008.","title":"Canonical Event Envelope","description":"The shared closed envelope for a versioned, tenant-scoped domain event.","type":"object","additionalProperties":false,"required":["eventId","eventType","schemaVersion","tenantScope","entity","actor","correlation","sourceComponent","occurredAt","data"],"properties":{"eventId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"eventType":{"type":"string","pattern":"^[a-z][a-z0-9_-]*(\\.[a-z][a-z0-9_-]*)+$"},"schemaVersion":{"type":"integer","minimum":1},"tenantScope":{"$ref":"https://schemas.databreeze.dev/contracts/v1/tenant-scope"},"entity":{"type":"object","additionalProperties":false,"required":["entityType","entityId","revision"],"properties":{"entityType":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,62}$"},"entityId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"revision":{"$ref":"https://schemas.databreeze.dev/contracts/v1/revision"}}},"actor":{"$ref":"https://schemas.databreeze.dev/contracts/v1/actor-metadata"},"correlation":{"$ref":"https://schemas.databreeze.dev/contracts/v1/correlation-metadata"},"sourceComponent":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,62}$"},"occurredAt":{"$ref":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp"},"data":{"type":"object"}}}, + {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/folder-autopilot-profile","$comment":"FA-001..FA-007: immutable typed profile payload; no local path or recipe authority.","title":"Folder Autopilot Profile","type":"object","additionalProperties":false,"required":["schemaVersion","profileId","tenantScope","version","payloadHash","stabilizationDelayMs","maxFilesPerScan","collisionPolicy","undoWindowSeconds","outputLineageEnabled","createdAt","revision"],"properties":{"schemaVersion":{"const":1},"profileId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"tenantScope":{"$ref":"https://schemas.databreeze.dev/contracts/v1/tenant-scope"},"version":{"type":"integer","minimum":1,"maximum":10000},"payloadHash":{"type":"string","pattern":"^[0-9a-f]{64}$"},"stabilizationDelayMs":{"type":"integer","minimum":0,"maximum":86400000},"maxFilesPerScan":{"type":"integer","minimum":1,"maximum":100000},"collisionPolicy":{"type":"string","pattern":"^(REVIEW|SKIP|UNIQUE_NAME)$"},"undoWindowSeconds":{"type":"integer","minimum":0,"maximum":604800},"outputLineageEnabled":{"type":"boolean"},"createdAt":{"$ref":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp"},"revision":{"$ref":"https://schemas.databreeze.dev/contracts/v1/revision"}}}, {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/identifier","$comment":"Partial foundation coverage for IAM-001.","title":"Stable UUID Identifier","description":"An opaque stable UUID identifier.","type":"string","format":"uuid"}, {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/problem-details","$comment":"RFC 7807-compatible base with the safe public error metadata required by INT-021 and WEB-021.","title":"Problem Details","description":"A closed RFC 7807-compatible problem document with DataBreeze public error extensions.","type":"object","additionalProperties":false,"required":["type","status","code","correlationId","retryable"],"anyOf":[{"properties":{"titleKey":true},"required":["titleKey"]},{"properties":{"messageKey":true},"required":["messageKey"]}],"properties":{"type":{"type":"string","format":"uri-reference"},"title":{"type":"string","minLength":1},"titleKey":{"type":"string","minLength":1,"maxLength":255},"status":{"type":"integer","minimum":100,"maximum":599},"detail":{"type":"string"},"instance":{"type":"string","format":"uri-reference"},"code":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"},"correlationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"retryable":{"type":"boolean"},"messageKey":{"type":"string","minLength":1,"maxLength":255},"fieldErrors":{"type":"array","maxItems":100,"items":{"type":"object","additionalProperties":false,"required":["field","code"],"properties":{"field":{"type":"string","minLength":1,"maxLength":255},"code":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}}}},"retryAfterSeconds":{"type":"integer","minimum":0},"currentRevision":{"$ref":"https://schemas.databreeze.dev/contracts/v1/revision"},"remediationAction":{"type":"string","minLength":1,"maxLength":255},"rateLimit":{"type":"object","additionalProperties":false,"required":["scope","resetAt"],"properties":{"scope":{"type":"string","minLength":1,"maxLength":255},"limit":{"type":"integer","minimum":0},"remaining":{"type":"integer","minimum":0},"resetAt":{"$ref":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp"}}}}}, + {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/recipe-assignment","$comment":"FA-005..FA-007, FA-014, FA-015, FA-031: JRA and DSO are referenced by opaque IDs and hashes.","title":"Folder Autopilot Recipe Assignment","type":"object","additionalProperties":false,"required":["schemaVersion","assignmentId","tenantScope","profileId","profileVersion","profileHash","jraRecipeVersionId","jraRecipeVersionHash","deviceId","inputBindingIds","outputBindingIds","idempotencyKey","state","revision","createdAt"],"properties":{"schemaVersion":{"const":1},"assignmentId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"tenantScope":{"$ref":"https://schemas.databreeze.dev/contracts/v1/tenant-scope"},"profileId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"profileVersion":{"type":"integer","minimum":1,"maximum":10000},"profileHash":{"type":"string","pattern":"^[0-9a-f]{64}$"},"jraRecipeVersionId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"jraRecipeVersionHash":{"type":"string","pattern":"^[0-9a-f]{64}$"},"deviceId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"inputBindingIds":{"type":"array","items":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"maxItems":32},"outputBindingIds":{"type":"array","items":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"maxItems":32},"dataModeConstraint":{"type":"string","pattern":"^(LOCAL|HYBRID|CLOUD)$"},"effectiveDataModePolicyRef":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"idempotencyKey":{"type":"string","minLength":1,"maxLength":200},"state":{"type":"string","pattern":"^(DRAFT|ACTIVE|PAUSED|RETIRED)$"},"revision":{"$ref":"https://schemas.databreeze.dev/contracts/v1/revision"},"createdAt":{"$ref":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp"}}}, {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/revision","$comment":"Supports optimistic-concurrency revisions described by the domain and data model.","title":"Entity Revision","description":"A positive, monotonically increasing entity revision.","type":"integer","minimum":1}, {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/tenant-scope","$comment":"Partial foundation coverage for IAM-019.","title":"Tenant Scope","description":"A discriminated tenant scope containing the complete ancestry required at its level.","oneOf":[{"$ref":"#/$defs/organizationScope"},{"$ref":"#/$defs/workspaceScope"},{"$ref":"#/$defs/projectScope"}],"$defs":{"organizationScope":{"type":"object","additionalProperties":false,"required":["scopeType","organizationId"],"properties":{"scopeType":{"const":"organization"},"organizationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"}}},"workspaceScope":{"type":"object","additionalProperties":false,"required":["scopeType","organizationId","workspaceId"],"properties":{"scopeType":{"const":"workspace"},"organizationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"workspaceId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"}}},"projectScope":{"type":"object","additionalProperties":false,"required":["scopeType","organizationId","workspaceId","projectId"],"properties":{"scopeType":{"const":"project"},"organizationId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"workspaceId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"},"projectId":{"$ref":"https://schemas.databreeze.dev/contracts/v1/identifier"}}}}}, {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://schemas.databreeze.dev/contracts/v1/utc-timestamp","$comment":"Partial foundation coverage for IAM-001 and INT-008.","title":"UTC Timestamp","description":"An RFC 3339 date-time normalized to UTC and terminated by uppercase Z.","type":"string","format":"date-time","pattern":"Z$"}, diff --git a/packages/contracts/manifest.json b/packages/contracts/manifest.json index e76ffe63..e952ece8 100644 --- a/packages/contracts/manifest.json +++ b/packages/contracts/manifest.json @@ -7,6 +7,11 @@ "id": "https://schemas.databreeze.dev/contracts/v1/actor-metadata", "path": "schemas/v1/actor-metadata.schema.json" }, + { + "name": "autopilot-folder-binding", + "id": "https://schemas.databreeze.dev/contracts/v1/autopilot-folder-binding", + "path": "schemas/v1/autopilot-folder-binding.schema.json" + }, { "name": "command-envelope", "id": "https://schemas.databreeze.dev/contracts/v1/command-envelope", @@ -27,6 +32,11 @@ "id": "https://schemas.databreeze.dev/contracts/v1/event-envelope", "path": "schemas/v1/event-envelope.schema.json" }, + { + "name": "folder-autopilot-profile", + "id": "https://schemas.databreeze.dev/contracts/v1/folder-autopilot-profile", + "path": "schemas/v1/folder-autopilot-profile.schema.json" + }, { "name": "identifier", "id": "https://schemas.databreeze.dev/contracts/v1/identifier", @@ -37,6 +47,11 @@ "id": "https://schemas.databreeze.dev/contracts/v1/problem-details", "path": "schemas/v1/problem-details.schema.json" }, + { + "name": "recipe-assignment", + "id": "https://schemas.databreeze.dev/contracts/v1/recipe-assignment", + "path": "schemas/v1/recipe-assignment.schema.json" + }, { "name": "revision", "id": "https://schemas.databreeze.dev/contracts/v1/revision", diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 2abb1818..e276b4df 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -10,12 +10,15 @@ "import": "./generated/typescript/v1/validation.mjs" }, "./v1/actor-metadata": "./schemas/v1/actor-metadata.schema.json", + "./v1/autopilot-folder-binding": "./schemas/v1/autopilot-folder-binding.schema.json", "./v1/command-envelope": "./schemas/v1/command-envelope.schema.json", "./v1/correlation-metadata": "./schemas/v1/correlation-metadata.schema.json", "./v1/cursor-page": "./schemas/v1/cursor-page.schema.json", "./v1/event-envelope": "./schemas/v1/event-envelope.schema.json", + "./v1/folder-autopilot-profile": "./schemas/v1/folder-autopilot-profile.schema.json", "./v1/identifier": "./schemas/v1/identifier.schema.json", "./v1/problem-details": "./schemas/v1/problem-details.schema.json", + "./v1/recipe-assignment": "./schemas/v1/recipe-assignment.schema.json", "./v1/revision": "./schemas/v1/revision.schema.json", "./v1/tenant-scope": "./schemas/v1/tenant-scope.schema.json", "./v1/utc-timestamp": "./schemas/v1/utc-timestamp.schema.json" diff --git a/packages/contracts/schemas/v1/autopilot-folder-binding.schema.json b/packages/contracts/schemas/v1/autopilot-folder-binding.schema.json new file mode 100644 index 00000000..0cfc1838 --- /dev/null +++ b/packages/contracts/schemas/v1/autopilot-folder-binding.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.databreeze.dev/contracts/v1/autopilot-folder-binding", + "$comment": "FA-001..FA-003: an opaque DSO DeviceGrant reference; never a path, local handle, or revocation record.", + "title": "Autopilot Folder Binding", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "bindingId", + "tenantScope", + "deviceGrantId", + "role", + "expectedCapabilityDigest", + "createdAt", + "revision" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "bindingId": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "tenantScope": { "$ref": "https://schemas.databreeze.dev/contracts/v1/tenant-scope" }, + "deviceGrantId": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "role": { "type": "string", "pattern": "^(INPUT|OUTPUT)$" }, + "expectedCapabilityDigest": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "createdAt": { "$ref": "https://schemas.databreeze.dev/contracts/v1/utc-timestamp" }, + "revision": { "$ref": "https://schemas.databreeze.dev/contracts/v1/revision" } + } +} diff --git a/packages/contracts/schemas/v1/folder-autopilot-profile.schema.json b/packages/contracts/schemas/v1/folder-autopilot-profile.schema.json new file mode 100644 index 00000000..99207b2c --- /dev/null +++ b/packages/contracts/schemas/v1/folder-autopilot-profile.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.databreeze.dev/contracts/v1/folder-autopilot-profile", + "$comment": "FA-001..FA-007: immutable typed profile payload; no local path or recipe authority.", + "title": "Folder Autopilot Profile", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "profileId", + "tenantScope", + "version", + "payloadHash", + "stabilizationDelayMs", + "maxFilesPerScan", + "collisionPolicy", + "undoWindowSeconds", + "outputLineageEnabled", + "createdAt", + "revision" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "profileId": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "tenantScope": { "$ref": "https://schemas.databreeze.dev/contracts/v1/tenant-scope" }, + "version": { "type": "integer", "minimum": 1, "maximum": 10000 }, + "payloadHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "stabilizationDelayMs": { "type": "integer", "minimum": 0, "maximum": 86400000 }, + "maxFilesPerScan": { "type": "integer", "minimum": 1, "maximum": 100000 }, + "collisionPolicy": { "type": "string", "pattern": "^(REVIEW|SKIP|UNIQUE_NAME)$" }, + "undoWindowSeconds": { "type": "integer", "minimum": 0, "maximum": 604800 }, + "outputLineageEnabled": { "type": "boolean" }, + "createdAt": { "$ref": "https://schemas.databreeze.dev/contracts/v1/utc-timestamp" }, + "revision": { "$ref": "https://schemas.databreeze.dev/contracts/v1/revision" } + } +} diff --git a/packages/contracts/schemas/v1/recipe-assignment.schema.json b/packages/contracts/schemas/v1/recipe-assignment.schema.json new file mode 100644 index 00000000..9fd168e8 --- /dev/null +++ b/packages/contracts/schemas/v1/recipe-assignment.schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.databreeze.dev/contracts/v1/recipe-assignment", + "$comment": "FA-005..FA-007, FA-014, FA-015, FA-031: JRA and DSO are referenced by opaque IDs and hashes.", + "title": "Folder Autopilot Recipe Assignment", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "assignmentId", + "tenantScope", + "profileId", + "profileVersion", + "profileHash", + "jraRecipeVersionId", + "jraRecipeVersionHash", + "deviceId", + "inputBindingIds", + "outputBindingIds", + "idempotencyKey", + "state", + "revision", + "createdAt" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "assignmentId": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "tenantScope": { "$ref": "https://schemas.databreeze.dev/contracts/v1/tenant-scope" }, + "profileId": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "profileVersion": { "type": "integer", "minimum": 1, "maximum": 10000 }, + "profileHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "jraRecipeVersionId": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "jraRecipeVersionHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "deviceId": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "inputBindingIds": { + "type": "array", + "items": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "maxItems": 32 + }, + "outputBindingIds": { + "type": "array", + "items": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "maxItems": 32 + }, + "dataModeConstraint": { "type": "string", "pattern": "^(LOCAL|HYBRID|CLOUD)$" }, + "effectiveDataModePolicyRef": { "$ref": "https://schemas.databreeze.dev/contracts/v1/identifier" }, + "idempotencyKey": { "type": "string", "minLength": 1, "maxLength": 200 }, + "state": { "type": "string", "pattern": "^(DRAFT|ACTIVE|PAUSED|RETIRED)$" }, + "revision": { "$ref": "https://schemas.databreeze.dev/contracts/v1/revision" }, + "createdAt": { "$ref": "https://schemas.databreeze.dev/contracts/v1/utc-timestamp" } + } +} diff --git a/packages/contracts/test/schemas.test.mjs b/packages/contracts/test/schemas.test.mjs index a3e6f633..b4e03f54 100644 --- a/packages/contracts/test/schemas.test.mjs +++ b/packages/contracts/test/schemas.test.mjs @@ -15,12 +15,15 @@ const schemaBase = 'https://schemas.databreeze.dev/contracts/v1'; const ids = { actorMetadata: `${schemaBase}/actor-metadata`, + autopilotFolderBinding: `${schemaBase}/autopilot-folder-binding`, commandEnvelope: `${schemaBase}/command-envelope`, correlationMetadata: `${schemaBase}/correlation-metadata`, cursorPage: `${schemaBase}/cursor-page`, eventEnvelope: `${schemaBase}/event-envelope`, + folderAutopilotProfile: `${schemaBase}/folder-autopilot-profile`, identifier: `${schemaBase}/identifier`, problemDetails: `${schemaBase}/problem-details`, + recipeAssignment: `${schemaBase}/recipe-assignment`, revision: `${schemaBase}/revision`, tenantScope: `${schemaBase}/tenant-scope`, utcTimestamp: `${schemaBase}/utc-timestamp`, @@ -62,12 +65,15 @@ test('publishes the complete deterministic v1 registry and compiles every real s const { ajv, manifest, schemas } = loadContracts(); const expectedNames = [ 'actor-metadata', + 'autopilot-folder-binding', 'command-envelope', 'correlation-metadata', 'cursor-page', 'event-envelope', + 'folder-autopilot-profile', 'identifier', 'problem-details', + 'recipe-assignment', 'revision', 'tenant-scope', 'utc-timestamp', @@ -100,12 +106,15 @@ test('exports only declared registry schema and generated TypeScript entry point '.', './v1', './v1/actor-metadata', + './v1/autopilot-folder-binding', './v1/command-envelope', './v1/correlation-metadata', './v1/cursor-page', './v1/event-envelope', + './v1/folder-autopilot-profile', './v1/identifier', './v1/problem-details', + './v1/recipe-assignment', './v1/revision', './v1/tenant-scope', './v1/utc-timestamp', @@ -158,6 +167,57 @@ test('rejects incomplete or discriminator-mismatched tenant ancestry', () => { assert.equal(validate({ scopeType: 'workspace', organizationId, workspaceId, projectId }), false); }); +test('[FA-001..FA-007, FA-014, FA-015, FA-031] compiles closed profile, binding, and assignment contracts', () => { + const profile = { + schemaVersion: 1, + profileId: organizationId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + version: 1, + payloadHash: 'a'.repeat(64), + stabilizationDelayMs: 1000, + maxFilesPerScan: 100, + collisionPolicy: 'REVIEW', + undoWindowSeconds: 3600, + outputLineageEnabled: true, + createdAt: '2026-08-01T01:30:00.125Z', + revision: 1, + }; + const binding = { + schemaVersion: 1, + bindingId: actorId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + deviceGrantId: projectId, + role: 'INPUT', + expectedCapabilityDigest: 'b'.repeat(64), + createdAt: '2026-08-01T01:30:00.125Z', + revision: 1, + }; + const assignment = { + schemaVersion: 1, + assignmentId: correlationId, + tenantScope: { scopeType: 'workspace', organizationId, workspaceId }, + profileId: organizationId, + profileVersion: 1, + profileHash: 'a'.repeat(64), + jraRecipeVersionId: projectId, + jraRecipeVersionHash: 'c'.repeat(64), + deviceId: actorId, + inputBindingIds: [actorId], + outputBindingIds: [projectId], + dataModeConstraint: 'LOCAL', + effectiveDataModePolicyRef: correlationId, + idempotencyKey: 'assignment-1', + state: 'DRAFT', + revision: 1, + createdAt: '2026-08-01T01:30:00.125Z', + }; + assert.equal(validatorFor(ids.folderAutopilotProfile)(profile), true); + assert.equal(validatorFor(ids.autopilotFolderBinding)(binding), true); + assert.equal(validatorFor(ids.recipeAssignment)(assignment), true); + assert.equal(validatorFor(ids.autopilotFolderBinding)({ ...binding, path: 'C:\\secret' }), false); + assert.equal(validatorFor(ids.recipeAssignment)({ ...assignment, localHandle: 'secret' }), false); +}); + test('accepts closed correlation metadata and rejects undeclared context', () => { const validate = validatorFor(ids.correlationMetadata); From 59bbb2f4a372b140e8f3c5c9cc6817f3641c4ac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:43:56 +0700 Subject: [PATCH 26/62] feat(fa): add dashboard and authority facades --- .../fa/api/folder-autopilot.controller.ts | 72 +++++++++++++++++++ .../features/fa/api/folder-autopilot.dto.ts | 44 ++++++++++++ .../application/folder-autopilot.service.ts | 59 ++++++++++++++- services/api/src/features/fa/fa.module.ts | 10 ++- .../fa/folder-autopilot.controller.test.ts | 41 +++++++++++ 5 files changed, 224 insertions(+), 2 deletions(-) diff --git a/services/api/src/features/fa/api/folder-autopilot.controller.ts b/services/api/src/features/fa/api/folder-autopilot.controller.ts index 6bc2a860..c6777aba 100644 --- a/services/api/src/features/fa/api/folder-autopilot.controller.ts +++ b/services/api/src/features/fa/api/folder-autopilot.controller.ts @@ -2,6 +2,8 @@ import { Body, Controller, Get, + HttpCode, + HttpStatus, Inject, Param, Patch, @@ -19,12 +21,17 @@ import { import { FOLDER_AUTOPILOT_SERVICE, + FOLDER_AUTOPILOT_JRA_FACADE_PORT, FolderAutopilotService, + type FolderAutopilotJraFacadePortV1, } from '../application/folder-autopilot.service.js'; import { CreateAutopilotFolderBindingDto, CreateFolderAutopilotProfileDto, CreateRecipeAssignmentDto, + FolderAutopilotApprovalDecisionDto, + FolderAutopilotUndoRequestDto, + PauseRecipeAssignmentDto, UpdateRecipeAssignmentDto, } from './folder-autopilot.dto.js'; import { @@ -38,9 +45,35 @@ import { export class FolderAutopilotController { public constructor( @Inject(FOLDER_AUTOPILOT_SERVICE) private readonly service: FolderAutopilotService, + @Inject(FOLDER_AUTOPILOT_JRA_FACADE_PORT) + private readonly jraFacade: FolderAutopilotJraFacadePortV1, @Inject(REQUEST_TENANT_CONTEXT) private readonly requestContext: RequestTenantContextPortV1, ) {} + @Get('autopilot-dashboard') + @ApiOperation({ summary: 'Read content-free Folder Autopilot dashboard projections' }) + public async dashboard(@Req() request: unknown): Promise { + const context = await this.requestContext.resolve(request); + const [profiles, bindings, assignments] = await Promise.all([ + this.service.listProfiles(context), + this.service.listBindings(context), + this.service.listAssignments(context), + ]); + return { + accepted: true, + value: { + profiles: profiles.accepted ? profiles.value : [], + bindings: bindings.accepted ? bindings.value : [], + assignments: assignments.accepted ? assignments.value : [], + previews: [], + approvals: [], + executions: [], + exceptions: [], + health: [], + }, + }; + } + @Post('autopilot-profiles') @ApiOperation({ summary: 'Register an immutable, content-free Folder Autopilot profile' }) @ApiBody({ type: CreateFolderAutopilotProfileDto }) @@ -147,4 +180,43 @@ export class FolderAutopilotController { input.state, ); } + + @Post('autopilot-assignments/:assignmentId/pause') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Pause an assignment projection with optimistic concurrency' }) + @ApiBody({ type: PauseRecipeAssignmentDto }) + public async pauseAssignment( + @Req() request: unknown, + @Param('assignmentId') assignmentId: string, + @Body() input: PauseRecipeAssignmentDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.service.updateAssignmentState(context, assignmentId, input.expectedRevision, 'PAUSED'); + } + + @Post('autopilot-approvals/:approvalId/decision') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Submit a decision through the JRA-owned approval facade' }) + @ApiBody({ type: FolderAutopilotApprovalDecisionDto }) + public async decideApproval( + @Req() request: unknown, + @Param('approvalId') approvalId: string, + @Body() input: FolderAutopilotApprovalDecisionDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.service.decideApproval(context, approvalId, { ...input }, this.jraFacade); + } + + @Post('autopilot-executions/:executionId/undo') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Request undo through the JRA/desktop effect facade' }) + @ApiBody({ type: FolderAutopilotUndoRequestDto }) + public async requestUndo( + @Req() request: unknown, + @Param('executionId') executionId: string, + @Body() input: FolderAutopilotUndoRequestDto, + ): Promise { + const context = await this.requestContext.resolve(request); + return this.service.requestUndo(context, executionId, { ...input }, this.jraFacade); + } } diff --git a/services/api/src/features/fa/api/folder-autopilot.dto.ts b/services/api/src/features/fa/api/folder-autopilot.dto.ts index bee16f9a..117bf34c 100644 --- a/services/api/src/features/fa/api/folder-autopilot.dto.ts +++ b/services/api/src/features/fa/api/folder-autopilot.dto.ts @@ -162,3 +162,47 @@ export class UpdateRecipeAssignmentDto { @IsIn(['DRAFT', 'ACTIVE', 'PAUSED', 'RETIRED']) state!: 'DRAFT' | 'ACTIVE' | 'PAUSED' | 'RETIRED'; } + +export class PauseRecipeAssignmentDto { + @ApiProperty({ type: 'integer', minimum: 1 }) + @IsInt() + @Min(1) + expectedRevision!: number; +} + +export class FolderAutopilotApprovalDecisionDto { + @ApiProperty({ format: 'uuid', description: 'JRA-owned approval request identifier.' }) + @IsUUID() + jraApprovalRequestId!: string; + + @ApiProperty({ pattern: sha256Pattern }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + subjectHash!: string; + + @ApiProperty({ pattern: sha256Pattern }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + planHash!: string; + + @ApiProperty({ enum: ['APPROVE', 'REJECT'] }) + @IsIn(['APPROVE', 'REJECT']) + decision!: 'APPROVE' | 'REJECT'; + + @ApiProperty({ maxLength: 500 }) + @IsString() + @MaxLength(500) + decisionReason!: string; +} + +export class FolderAutopilotUndoRequestDto { + @ApiProperty({ type: 'integer', minimum: 1 }) + @IsInt() + @Min(1) + expectedRevision!: number; + + @ApiProperty({ pattern: sha256Pattern }) + @IsString() + @Matches(/^[0-9a-f]{64}$/u) + planHash!: string; +} diff --git a/services/api/src/features/fa/application/folder-autopilot.service.ts b/services/api/src/features/fa/application/folder-autopilot.service.ts index 3bcd0053..0a20835f 100644 --- a/services/api/src/features/fa/application/folder-autopilot.service.ts +++ b/services/api/src/features/fa/application/folder-autopilot.service.ts @@ -17,11 +17,11 @@ import type { DataModeV1 } from '@databreeze/domain/data-mode/v1'; import type { IamTenantContextV1 } from '../../iam/application/tenant-context.js'; import type { FolderAutopilotRepositoryPortV1, - FolderAutopilotTransactionPortV1, } from './folder-autopilot-repository.port.js'; export const FOLDER_AUTOPILOT_SERVICE = Symbol('FOLDER_AUTOPILOT_SERVICE'); export const FOLDER_AUTOPILOT_DATA_MODE_POLICY_PORT = Symbol('FOLDER_AUTOPILOT_DATA_MODE_POLICY_PORT'); +export const FOLDER_AUTOPILOT_JRA_FACADE_PORT = Symbol('FOLDER_AUTOPILOT_JRA_FACADE_PORT'); export type FolderAutopilotDataModePolicyResultV1 = | { readonly accepted: true; readonly value: { readonly effectiveDataModePolicyRef: string } } @@ -49,6 +49,45 @@ export class UnavailableFolderAutopilotDataModePolicyAdapter } } +export interface FolderAutopilotJraFacadePortV1 { + decideApproval( + context: IamTenantContextV1, + executionId: string, + input: Readonly>, + ): Promise; + requestUndo( + context: IamTenantContextV1, + executionId: string, + input: Readonly>, + ): Promise; +} + +export type FolderAutopilotFacadeResultV1 = + | { readonly accepted: true; readonly value: Readonly> } + | { + readonly accepted: false; + readonly code: 'FA_JRA_APPROVAL_FACADE_UNAVAILABLE' | 'FA_JRA_UNDO_FACADE_UNAVAILABLE'; + }; + +/** JRA remains the sole approval/effect authority; this adapter fails closed until composed. */ +export class UnavailableFolderAutopilotJraFacadeAdapter implements FolderAutopilotJraFacadePortV1 { + public decideApproval( + _context: IamTenantContextV1, + _executionId: string, + _input: Readonly>, + ): Promise { + return Promise.resolve({ accepted: false, code: 'FA_JRA_APPROVAL_FACADE_UNAVAILABLE' as const }); + } + + public requestUndo( + _context: IamTenantContextV1, + _executionId: string, + _input: Readonly>, + ): Promise { + return Promise.resolve({ accepted: false, code: 'FA_JRA_UNDO_FACADE_UNAVAILABLE' as const }); + } +} + export type FolderAutopilotServiceErrorV1 = | FolderAutopilotErrorCodeV1 | 'FA_PROFILE_NOT_FOUND' @@ -301,4 +340,22 @@ export class FolderAutopilotService { ): Promise> { return Object.freeze({ accepted: true, value: await this.repository.listAssignments(context) }); } + + public decideApproval( + context: IamTenantContextV1, + executionId: string, + input: Readonly>, + facade: FolderAutopilotJraFacadePortV1, + ): Promise { + return facade.decideApproval(context, executionId, input); + } + + public requestUndo( + context: IamTenantContextV1, + executionId: string, + input: Readonly>, + facade: FolderAutopilotJraFacadePortV1, + ): Promise { + return facade.requestUndo(context, executionId, input); + } } diff --git a/services/api/src/features/fa/fa.module.ts b/services/api/src/features/fa/fa.module.ts index 9807de1e..d3467a8c 100644 --- a/services/api/src/features/fa/fa.module.ts +++ b/services/api/src/features/fa/fa.module.ts @@ -8,10 +8,13 @@ import { } from './adapter/prisma-folder-autopilot-repository.adapter.js'; import { FOLDER_AUTOPILOT_DATA_MODE_POLICY_PORT, + FOLDER_AUTOPILOT_JRA_FACADE_PORT, FOLDER_AUTOPILOT_SERVICE, FolderAutopilotService, type FolderAutopilotDataModePolicyPortV1, + type FolderAutopilotJraFacadePortV1, UnavailableFolderAutopilotDataModePolicyAdapter, + UnavailableFolderAutopilotJraFacadeAdapter, } from './application/folder-autopilot.service.js'; import { FOLDER_AUTOPILOT_REPOSITORY_PORT, @@ -29,6 +32,8 @@ export interface FaModuleOptions { readonly folderAutopilotRepository?: FolderAutopilotRepositoryPortV1; /** DSO owns policy authority; FA receives only this narrow facade. */ readonly folderAutopilotDataModePolicy?: FolderAutopilotDataModePolicyPortV1; + /** JRA owns ApprovalRequest/Decision and effects; FA receives only this facade. */ + readonly folderAutopilotJraFacade?: FolderAutopilotJraFacadePortV1; readonly requestTenantContext?: RequestTenantContextPortV1; } @@ -42,6 +47,8 @@ export class FaModule { : new PrismaFolderAutopilotRepositoryAdapter(options.folderAutopilotDatabase)); const dataModePolicy = options.folderAutopilotDataModePolicy ?? new UnavailableFolderAutopilotDataModePolicyAdapter(); + const jraFacade = + options.folderAutopilotJraFacade ?? new UnavailableFolderAutopilotJraFacadeAdapter(); return { module: FaModule, controllers: [FolderAutopilotController], @@ -51,11 +58,12 @@ export class FaModule { provide: FOLDER_AUTOPILOT_DATA_MODE_POLICY_PORT, useValue: dataModePolicy, }, + { provide: FOLDER_AUTOPILOT_JRA_FACADE_PORT, useValue: jraFacade }, { provide: FOLDER_AUTOPILOT_SERVICE, useFactory: ( folderRepository: FolderAutopilotRepositoryPortV1, - policy: FolderAutopilotDataModePolicyPortV1 | undefined, + policy: FolderAutopilotDataModePolicyPortV1, ): FolderAutopilotService => new FolderAutopilotService(folderRepository, policy), inject: [FOLDER_AUTOPILOT_REPOSITORY_PORT, FOLDER_AUTOPILOT_DATA_MODE_POLICY_PORT], }, diff --git a/services/api/test/features/fa/folder-autopilot.controller.test.ts b/services/api/test/features/fa/folder-autopilot.controller.test.ts index 3b1aa867..0ae4984b 100644 --- a/services/api/test/features/fa/folder-autopilot.controller.test.ts +++ b/services/api/test/features/fa/folder-autopilot.controller.test.ts @@ -125,6 +125,47 @@ void test('[FA-001..FA-007, FA-014, FA-015, FA-031] HTTP is tenant-scoped and co assert.equal(patched.statusCode, 200); assert.equal(patched.json().value.revision, 2); + const dashboard = await app.inject({ method: 'GET', url: '/v1/autopilot-dashboard' }); + assert.equal(dashboard.statusCode, 200); + assert.equal(dashboard.json().accepted, true); + assert.equal(Array.isArray(dashboard.json().value.assignments), true); + + const pause = await app.inject({ + method: 'POST', + url: `/v1/autopilot-assignments/${ids.recipeId}/pause`, + payload: { expectedRevision: 2 }, + }); + assert.equal(pause.statusCode, 200); + assert.equal(pause.json().value.state, 'PAUSED'); + + const approvalUnavailable = await app.inject({ + method: 'POST', + url: `/v1/autopilot-approvals/${ids.recipeId}/decision`, + payload: { + jraApprovalRequestId: ids.recipeId, + subjectHash: 'd'.repeat(64), + planHash: 'e'.repeat(64), + decision: 'APPROVE', + decisionReason: 'Ready for the JRA approval service.', + }, + }); + assert.equal(approvalUnavailable.statusCode, 200); + assert.deepEqual(approvalUnavailable.json(), { + accepted: false, + code: 'FA_JRA_APPROVAL_FACADE_UNAVAILABLE', + }); + + const undoUnavailable = await app.inject({ + method: 'POST', + url: `/v1/autopilot-executions/${ids.recipeId}/undo`, + payload: { expectedRevision: 1, planHash: 'e'.repeat(64) }, + }); + assert.equal(undoUnavailable.statusCode, 200); + assert.deepEqual(undoUnavailable.json(), { + accepted: false, + code: 'FA_JRA_UNDO_FACADE_UNAVAILABLE', + }); + current = context('ffffffff-ffff-4fff-8fff-ffffffffffff', 'fa-sibling'); const siblingRead = await app.inject({ method: 'GET', From 4da920a76bb62e917e6f40efb6521382e69c587a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:44:39 +0700 Subject: [PATCH 27/62] test(android): specify Folder Autopilot offline intent queue --- .../FolderAutopilotOfflineQueueTest.kt | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 apps/android/app/src/test/java/com/databreeze/android/folderautopilot/FolderAutopilotOfflineQueueTest.kt diff --git a/apps/android/app/src/test/java/com/databreeze/android/folderautopilot/FolderAutopilotOfflineQueueTest.kt b/apps/android/app/src/test/java/com/databreeze/android/folderautopilot/FolderAutopilotOfflineQueueTest.kt new file mode 100644 index 00000000..d9625324 --- /dev/null +++ b/apps/android/app/src/test/java/com/databreeze/android/folderautopilot/FolderAutopilotOfflineQueueTest.kt @@ -0,0 +1,80 @@ +package com.databreeze.android.folderautopilot + +import com.databreeze.android.storage.AccountWorkspaceScope +import com.databreeze.android.storage.InMemoryLocalStore +import com.databreeze.android.sync.SyncScheduler +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Assert.assertTrue +import org.junit.Test + +class FolderAutopilotOfflineQueueTest { + private val scope = AccountWorkspaceScope("account-1", "workspace-1") + private val assignment = FolderAutopilotAssignmentSummary( + assignmentId = "assignment-1", + displayName = "Invoice intake", + state = FolderAutopilotAssignmentState.ACTIVE, + revision = 3, + watcherState = FolderAutopilotWatcherState.HEALTHY, + ) + private val approval = FolderAutopilotApprovalSummary( + approvalId = "approval-1", + previewId = "preview-1", + planHash = "a".repeat(64), + affectedCount = 1, + blockedCount = 0, + decision = FolderAutopilotApprovalDecision.PENDING, + expiresAt = "2026-08-05T00:00:00Z", + ) + private val outcome = FolderAutopilotOutcomeSummary( + executionId = "execution-1", + outcome = FolderAutopilotOutcome.UNDO_AVAILABLE, + affectedCount = 1, + undoState = FolderAutopilotUndoState.AVAILABLE, + ) + + @Test + fun queues_only_opaque_ids_revisions_and_hashes() = runBlocking { + val store = InMemoryLocalStore() + val scheduler = RecordingScheduler() + val queue = FolderAutopilotOfflineActionQueue(store, scope, scheduler) { 1_000L } + + queue.enqueuePause(assignment) + queue.enqueueApproval(approval, FolderAutopilotApprovalDecision.APPROVED) + queue.enqueueUndo(outcome) + + val queued = store.snapshotQueue(scope) + assertEquals(3, queued.size) + assertTrue(queued.all { it.operationType.startsWith("autopilot.") }) + assertTrue(queued.all { it.payloadHash.matches(Regex("sha256:[0-9a-f]{64}")) }) + assertTrue(queued.all { it.mutationId.contains("/").not() }) + assertEquals(3, scheduler.enqueued.size) + assertEquals(1_000L, queued.first().createdAtEpochMs) + } + + @Test + fun approval_queue_requires_pending_state_and_exact_plan_hash() = runBlocking { + val store = InMemoryLocalStore() + val queue = FolderAutopilotOfflineActionQueue(store, scope, null) { 2_000L } + + queue.enqueueApproval(approval, FolderAutopilotApprovalDecision.APPROVED) + val completed = approval.copy(decision = FolderAutopilotApprovalDecision.APPROVED) + try { + queue.enqueueApproval(completed, FolderAutopilotApprovalDecision.REJECTED) + fail("an already-decided approval must not be queued") + } catch (_: IllegalStateException) { + // Expected fail-closed behavior. + } + } + + private class RecordingScheduler : SyncScheduler { + val enqueued = mutableListOf() + + override fun enqueue(scope: AccountWorkspaceScope, cursor: String?, revision: Long?) { + enqueued += scope + } + + override fun cancel(scope: AccountWorkspaceScope) = Unit + } +} From 8b0b449acaf902937f24b29738b4e89d6fac074c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:44:45 +0700 Subject: [PATCH 28/62] feat(android): queue Folder Autopilot intents offline --- .../FolderAutopilotOfflineActionQueue.kt | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotOfflineActionQueue.kt diff --git a/apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotOfflineActionQueue.kt b/apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotOfflineActionQueue.kt new file mode 100644 index 00000000..65e2e5ec --- /dev/null +++ b/apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotOfflineActionQueue.kt @@ -0,0 +1,79 @@ +package com.databreeze.android.folderautopilot + +import com.databreeze.android.storage.AccountWorkspaceScope +import com.databreeze.android.storage.LocalStorePort +import com.databreeze.android.storage.SyncQueueEntity +import com.databreeze.android.sync.SyncScheduler +import java.security.MessageDigest + +/** + * Stores only resumable Folder Autopilot intent locally. The queue never receives a path, + * filename, source value, preview bytes, or an executable action. + */ +class FolderAutopilotOfflineActionQueue( + private val store: LocalStorePort, + private val scope: AccountWorkspaceScope, + private val scheduler: SyncScheduler?, + private val clock: () -> Long = { System.currentTimeMillis() }, +) { + suspend fun enqueuePause(assignment: FolderAutopilotAssignmentSummary): String { + check(assignment.state == FolderAutopilotAssignmentState.ACTIVE) { "assignment is not active" } + val mutationId = mutationId("pause", assignment.assignmentId, assignment.revision.toString()) + return enqueue( + mutationId = mutationId, + operationType = "autopilot.pause", + canonicalPayload = "$mutationId|${assignment.assignmentId}|${assignment.revision}", + ) + } + + suspend fun enqueueApproval( + approval: FolderAutopilotApprovalSummary, + decision: FolderAutopilotApprovalDecision, + expectedPlanHash: String = approval.planHash, + ): String { + val next = approval.decide(decision, expectedPlanHash) + val mutationId = mutationId("approval", next.approvalId, next.decision.name.lowercase()) + return enqueue( + mutationId = mutationId, + operationType = "autopilot.approval", + canonicalPayload = "$mutationId|${next.approvalId}|${next.planHash}|${next.decision}", + ) + } + + suspend fun enqueueUndo(outcome: FolderAutopilotOutcomeSummary): String { + check(outcome.undoState == FolderAutopilotUndoState.AVAILABLE) { "undo is not available" } + val mutationId = mutationId("undo", outcome.executionId) + return enqueue( + mutationId = mutationId, + operationType = "autopilot.undo", + canonicalPayload = "$mutationId|${outcome.executionId}", + ) + } + + private suspend fun enqueue( + mutationId: String, + operationType: String, + canonicalPayload: String, + ): String { + store.enqueue( + SyncQueueEntity( + accountId = scope.accountId, + workspaceId = scope.workspaceId, + mutationId = mutationId, + operationType = operationType, + payloadHash = "sha256:${sha256(canonicalPayload)}", + createdAtEpochMs = clock(), + ), + ) + scheduler?.enqueue(scope) + return mutationId + } + + private fun mutationId(action: String, vararg parts: String): String = + (listOf("autopilot", action) + parts).joinToString("-") +} + +private fun sha256(value: String): String = MessageDigest + .getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString(separator = "") { byte -> "%02x".format(byte) } From 0f645aa5226d0bf0b49a9ed0a8ca1eed76f590b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:45:07 +0700 Subject: [PATCH 29/62] fix(desktop): fail closed on unsafe local actions --- .../folder-autopilot/local-actions.ts | 89 ++++++++++++++++--- .../folder-autopilot-local-actions.test.ts | 60 ++++++++++++- 2 files changed, 135 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/features/folder-autopilot/local-actions.ts b/apps/desktop/src/features/folder-autopilot/local-actions.ts index 38209fe7..57112814 100644 --- a/apps/desktop/src/features/folder-autopilot/local-actions.ts +++ b/apps/desktop/src/features/folder-autopilot/local-actions.ts @@ -6,8 +6,12 @@ export type LocalActionCode = | 'APPROVAL_REQUIRED' | 'DESTINATION_COLLISION' | 'DESTINATION_RECURSION' + | 'EXCLUSIVE_RENAME_REQUIRED' + | 'INVALID_LOCAL_PATH' | 'INVALID_PLAN' | 'LOCAL_IO_FAILED' + | 'PATH_OUTSIDE_AUTHORIZATION' + | 'PATH_REPARSE_POINT' | 'STALE_PLAN'; export class LocalActionError extends Error { @@ -30,6 +34,7 @@ export interface LocalFileSystem { copyExclusive(source: string, destination: string): Promise; /** The adapter must reject rather than replace an existing destination. */ renameExclusive?(source: string, destination: string): Promise; + /** Legacy non-exclusive operation; the local executor never invokes it. */ rename(source: string, destination: string): Promise; } @@ -69,6 +74,58 @@ function reject(code: LocalActionCode): never { throw new LocalActionError(code); } +const CONTAINMENT_CODES = [ + 'INVALID_LOCAL_PATH', + 'PATH_OUTSIDE_AUTHORIZATION', + 'PATH_REPARSE_POINT', +] as const; + +function isContainmentCode(value: unknown): value is (typeof CONTAINMENT_CODES)[number] { + return ( + typeof value === 'string' && + CONTAINMENT_CODES.includes(value as (typeof CONTAINMENT_CODES)[number]) + ); +} + +function failClosed(error: unknown): never { + if (error instanceof LocalActionError) return reject(error.code); + if (typeof error === 'object' && error !== null) { + const code = (error as { readonly code?: unknown }).code; + if (isContainmentCode(code)) return reject(code); + } + return reject('LOCAL_IO_FAILED'); +} + +function assertContained(guard: LocalPathGuard, candidate: string): string { + try { + const contained = guard.assertContained(candidate); + if (typeof contained !== 'string' || contained.length === 0) return reject('LOCAL_IO_FAILED'); + return contained; + } catch (error) { + return failClosed(error); + } +} + +async function pathExists(fileSystem: LocalFileSystem, candidate: string): Promise { + try { + const exists = await fileSystem.exists(candidate); + if (typeof exists !== 'boolean') return reject('LOCAL_IO_FAILED'); + return exists; + } catch (error) { + return failClosed(error); + } +} + +async function readFingerprint(fileSystem: LocalFileSystem, candidate: string): Promise { + try { + const fingerprint = await fileSystem.readFingerprint(candidate); + if (typeof fingerprint !== 'string') return reject('LOCAL_IO_FAILED'); + return fingerprint; + } catch (error) { + return failClosed(error); + } +} + function isWriteAction(action: LocalAction): boolean { return action === 'RENAME' || action === 'COPY' || action === 'MOVE'; } @@ -120,15 +177,18 @@ async function chooseDestination( fileSystem: LocalFileSystem, ): Promise<{ readonly path: string; readonly generated: boolean; readonly skipped: boolean }> { const destination = containedDestination; - if (!(await fileSystem.exists(destination))) { + if (!(await pathExists(fileSystem, destination))) { return { path: destination, generated: false, skipped: false }; } if (collisionPolicy === 'SKIP') return { path: destination, generated: false, skipped: true }; if (collisionPolicy !== 'UNIQUE_NAME') return reject('DESTINATION_COLLISION'); for (let index = 1; index <= MAX_UNIQUE_NAME_ATTEMPTS; index += 1) { - const candidate = destinationGuard.assertContained(uniqueDestinationName(destination, index)); - if (!(await fileSystem.exists(candidate))) { + const candidate = assertContained( + destinationGuard, + uniqueDestinationName(destination, index), + ); + if (!(await pathExists(fileSystem, candidate))) { return { path: candidate, generated: true, skipped: false }; } } @@ -150,8 +210,14 @@ export async function executeLocalPlan( const receipts: LocalActionReceipt[] = []; for (const operation of operations) { validateOperation(operation); - const source = sourceGuard.assertContained(operation.sourcePath); - const expectedFingerprint = await fileSystem.readFingerprint(source); + if ( + (operation.action === 'RENAME' || operation.action === 'MOVE') && + typeof fileSystem.renameExclusive !== 'function' + ) { + return reject('EXCLUSIVE_RENAME_REQUIRED'); + } + const source = assertContained(sourceGuard, operation.sourcePath); + const expectedFingerprint = await readFingerprint(fileSystem, source); if (expectedFingerprint !== operation.sourceFingerprint) return reject('STALE_PLAN'); if (!isWriteAction(operation.action)) { @@ -159,7 +225,10 @@ export async function executeLocalPlan( continue; } - const requestedDestination = destinationGuard.assertContained(operation.destinationPath as string); + const requestedDestination = assertContained( + destinationGuard, + operation.destinationPath as string, + ); if (source.toLowerCase() === requestedDestination.toLowerCase()) { return reject('DESTINATION_RECURSION'); } @@ -174,7 +243,7 @@ export async function executeLocalPlan( receipts.push({ operationId: operation.operationId, action: operation.action, status: 'SKIPPED' }); continue; } - if (await fileSystem.exists(destination)) { + if (await pathExists(fileSystem, destination)) { if (operation.collisionPolicy === 'SKIP') { receipts.push({ operationId: operation.operationId, action: operation.action, status: 'SKIPPED' }); continue; @@ -183,11 +252,7 @@ export async function executeLocalPlan( } try { if (operation.action === 'COPY') await fileSystem.copyExclusive(source, destination); - else if (fileSystem.renameExclusive !== undefined) { - await fileSystem.renameExclusive(source, destination); - } else { - await fileSystem.rename(source, destination); - } + else await fileSystem.renameExclusive!(source, destination); } catch { return reject('LOCAL_IO_FAILED'); } diff --git a/apps/desktop/test/folder-autopilot-local-actions.test.ts b/apps/desktop/test/folder-autopilot-local-actions.test.ts index e6437967..e8515a8f 100644 --- a/apps/desktop/test/folder-autopilot-local-actions.test.ts +++ b/apps/desktop/test/folder-autopilot-local-actions.test.ts @@ -13,6 +13,7 @@ function dependencies(overrides: Partial = {}) { exists: vi.fn(() => Promise.resolve(false)), readFingerprint: vi.fn(() => Promise.resolve('a'.repeat(64))), copyExclusive: vi.fn(() => Promise.resolve()), + renameExclusive: vi.fn(() => Promise.resolve()), rename: vi.fn(() => Promise.resolve()), ...overrides, }; @@ -47,7 +48,7 @@ describe('Folder Autopilot local typed actions', () => { it('revalidates containment and source fingerprint before a rename', async () => { const deps = dependencies(); - const rename = vi.spyOn(deps.fileSystem, 'rename'); + const renameExclusive = vi.spyOn(deps.fileSystem, 'renameExclusive'); const result = await executeLocalPlan( plan({ operationId: 'rename-1', @@ -62,7 +63,7 @@ describe('Folder Autopilot local typed actions', () => { expect(result[0]!.status).toBe('APPLIED'); expect(deps.sourceGuard.assertContained).toHaveBeenCalledWith(sourcePath); expect(deps.destinationGuard.assertContained).toHaveBeenCalledWith(destinationPath); - expect(rename).toHaveBeenCalledWith(sourcePath, destinationPath); + expect(renameExclusive).toHaveBeenCalledWith(sourcePath, destinationPath); }); it('never overwrites a destination and handles SKIP explicitly', async () => { @@ -89,6 +90,7 @@ describe('Folder Autopilot local typed actions', () => { .fn() .mockResolvedValueOnce(true) .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) .mockResolvedValueOnce(false); const deps = dependencies({ exists }); const copyExclusive = vi.spyOn(deps.fileSystem, 'copyExclusive'); @@ -148,6 +150,60 @@ describe('Folder Autopilot local typed actions', () => { expect(rename).not.toHaveBeenCalled(); }); + it('fails closed when only a non-exclusive rename primitive is available', async () => { + const deps = dependencies(); + delete deps.fileSystem.renameExclusive; + const rename = vi.spyOn(deps.fileSystem, 'rename'); + + await expect( + executeLocalPlan( + plan({ + operationId: 'rename-unsafe-1', + action: 'RENAME', + sourcePath, + destinationPath, + sourceFingerprint: 'a'.repeat(64), + }), + deps, + ), + ).rejects.toMatchObject({ code: 'EXCLUSIVE_RENAME_REQUIRED' }); + expect(rename).not.toHaveBeenCalled(); + }); + + it('maps guard and filesystem failures to content-free stable errors', async () => { + const sourceGuardFailure = dependencies({ + readFingerprint: vi.fn(() => Promise.reject(new Error('source C:\\secret\\file.csv'))), + }); + await expect( + executeLocalPlan( + plan({ + operationId: 'read-failure-1', + action: 'INSPECT', + sourcePath, + sourceFingerprint: 'a'.repeat(64), + }), + sourceGuardFailure, + ), + ).rejects.toMatchObject({ code: 'LOCAL_IO_FAILED', message: 'LOCAL_IO_FAILED' }); + + const guardFailure = dependencies(); + guardFailure.destinationGuard.assertContained = vi.fn(() => { + throw new Error('destination C:\\secret\\file.csv'); + }); + await expect( + executeLocalPlan( + plan({ + operationId: 'guard-failure-1', + action: 'COPY', + sourcePath, + destinationPath, + sourceFingerprint: 'a'.repeat(64), + }), + guardFailure, + ), + ).rejects.toMatchObject({ code: 'LOCAL_IO_FAILED', message: 'LOCAL_IO_FAILED' }); + }); + it('rejects unique-name exhaustion at the bounded allocation limit', async () => { const exists = vi.fn(() => Promise.resolve(true)); const deps = dependencies({ exists }); From 204191f926d27a33dca0479f79e95aa5c85f86a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:45:09 +0700 Subject: [PATCH 30/62] docs: record Folder Autopilot client slice evidence --- .../fa-web-android-slice-2026-08-04.md | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/release-evidence/fa-web-android-slice-2026-08-04.md diff --git a/docs/release-evidence/fa-web-android-slice-2026-08-04.md b/docs/release-evidence/fa-web-android-slice-2026-08-04.md new file mode 100644 index 00000000..72509e29 --- /dev/null +++ b/docs/release-evidence/fa-web-android-slice-2026-08-04.md @@ -0,0 +1,43 @@ +# Folder Autopilot Web and Android slice — 2026-08-04 + +This record covers the content-free Web and Android review boundary for the Folder Autopilot +feature. It is a client slice, not a claim that all FA P0/P1 requirements are complete. + +## Delivered + +- Web exposes a lazy-loaded `autopilot` route and navigation registration with Vietnamese and + English copy. +- Web dashboard parsing rejects unknown fields and source-bearing values; mutation requests carry + only opaque identifiers, revisions, policy values, decision, and immutable plan hashes. +- Web presents profile authoring, assignment pause, preview approval/rejection, exception, outcome, + and undo projections without rendering local paths, source bytes, formulas, or local handles. +- Android presents a compact assignment, approval, outcome, exception, and undo companion surface. +- Android state transitions fail closed on stale assignments, non-pending approvals, plan-hash + mismatch, and repeated undo requests. +- Android offline intent queue stores only bounded operation names, opaque IDs, revisions, and + SHA-256 payload hashes in the existing Room/InMemory queue; WorkManager scheduling remains + replaceable through `SyncScheduler`. + +## Commits + +- `459c095` — Web safe API boundary tests +- `436507f` — Web content-free API client +- `a0cad73` — Web authoring/review surface tests +- `95af11b` — Web workspace surfaces and lazy route +- `0529f70` — Android state-model tests +- `ed5a44f` — Android state model +- `c3fe924` — Android review companion and instrumentation coverage +- `23cf2c5` — Android offline queue tests +- `162f502` — Android offline action queue + +## Checks + +- `corepack pnpm --filter @databreeze/web typecheck` +- `corepack pnpm --filter @databreeze/web exec vitest run test/folder-autopilot-api.test.ts test/folder-autopilot-page.test.tsx` +- `apps/android/gradlew.bat :app:compileDebugKotlin --no-daemon --offline --console=plain` +- `apps/android/gradlew.bat :app:testDebugUnitTest --tests com.databreeze.android.folderautopilot.FolderAutopilotOfflineQueueTest --tests com.databreeze.android.folderautopilot.FolderAutopilotModelsTest --no-daemon --offline --console=plain` +- `apps/android/gradlew.bat :app:compileDebugAndroidTestKotlin --no-daemon --offline --console=plain` + +Instrumentation execution requires an attached Android emulator/device; compilation passed in this +worktree. The complete Folder Autopilot module remains gated by its backend, Desktop watcher, +engine, evidence, approval, recovery, and traceability plans. From e6f6d05354acdd2909707528e3cd19a2439d38bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:47:18 +0700 Subject: [PATCH 31/62] fix(android): persist Autopilot actions before local state --- .../com/databreeze/android/MainActivity.kt | 53 +++++++++++++++---- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/apps/android/app/src/main/java/com/databreeze/android/MainActivity.kt b/apps/android/app/src/main/java/com/databreeze/android/MainActivity.kt index 374b5de7..2fc9d8f0 100644 --- a/apps/android/app/src/main/java/com/databreeze/android/MainActivity.kt +++ b/apps/android/app/src/main/java/com/databreeze/android/MainActivity.kt @@ -37,6 +37,7 @@ import com.databreeze.android.folderautopilot.FolderAutopilotAssignmentState import com.databreeze.android.folderautopilot.FolderAutopilotAssignmentSummary import com.databreeze.android.folderautopilot.FolderAutopilotExceptionSummary import com.databreeze.android.folderautopilot.FolderAutopilotMobileState +import com.databreeze.android.folderautopilot.FolderAutopilotOfflineActionQueue import com.databreeze.android.folderautopilot.FolderAutopilotOutcome import com.databreeze.android.folderautopilot.FolderAutopilotOutcomeSummary import com.databreeze.android.folderautopilot.FolderAutopilotApprovalSummary @@ -78,6 +79,10 @@ fun DataBreezeApp( ) { val navController = rememberNavController() var autopilotState by remember { mutableStateOf(sampleFolderAutopilotState()) } + val autopilotActions = remember(localStore, scope, syncScheduler) { + FolderAutopilotOfflineActionQueue(localStore, scope, syncScheduler) + } + val autopilotActionScope = rememberCoroutineScope() DataBreezeTheme { Scaffold( topBar = { TopAppBar(title = { Text(stringResource(R.string.app_name)) }) }, @@ -104,20 +109,48 @@ fun DataBreezeApp( composable(AppRoutes.AUTOPILOT) { FolderAutopilotScreen( state = autopilotState, - onPause = { autopilotState = autopilotState.pauseAssignment() }, + onPause = { + autopilotActionScope.launch { + val current = autopilotState + autopilotActions.enqueuePause(current.assignment) + autopilotState = current.pauseAssignment() + } + }, onApprove = { - autopilotState = autopilotState.decideApproval( - FolderAutopilotApprovalDecision.APPROVED, - autopilotState.approval.planHash, - ) + autopilotActionScope.launch { + val current = autopilotState + autopilotActions.enqueueApproval( + current.approval, + FolderAutopilotApprovalDecision.APPROVED, + current.approval.planHash, + ) + autopilotState = current.decideApproval( + FolderAutopilotApprovalDecision.APPROVED, + current.approval.planHash, + ) + } }, onReject = { - autopilotState = autopilotState.decideApproval( - FolderAutopilotApprovalDecision.REJECTED, - autopilotState.approval.planHash, - ) + autopilotActionScope.launch { + val current = autopilotState + autopilotActions.enqueueApproval( + current.approval, + FolderAutopilotApprovalDecision.REJECTED, + current.approval.planHash, + ) + autopilotState = current.decideApproval( + FolderAutopilotApprovalDecision.REJECTED, + current.approval.planHash, + ) + } + }, + onUndo = { + autopilotActionScope.launch { + val current = autopilotState + autopilotActions.enqueueUndo(current.recentOutcome) + autopilotState = current.requestUndo() + } }, - onUndo = { autopilotState = autopilotState.requestUndo() }, ) } } From 2685abd601ee522af65a866bda1e46a6af1083e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:49:03 +0700 Subject: [PATCH 32/62] fix(android): bound Autopilot offline mutation ids --- .../folderautopilot/FolderAutopilotOfflineActionQueue.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotOfflineActionQueue.kt b/apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotOfflineActionQueue.kt index 65e2e5ec..7e299b3d 100644 --- a/apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotOfflineActionQueue.kt +++ b/apps/android/app/src/main/java/com/databreeze/android/folderautopilot/FolderAutopilotOfflineActionQueue.kt @@ -70,7 +70,7 @@ class FolderAutopilotOfflineActionQueue( } private fun mutationId(action: String, vararg parts: String): String = - (listOf("autopilot", action) + parts).joinToString("-") + "autopilot-$action-${sha256(parts.joinToString("\\u0000")).take(48)}" } private fun sha256(value: String): String = MessageDigest From 51704177314634a382e96355f29432ceb47641e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:49:13 +0700 Subject: [PATCH 33/62] docs: record bounded offline identifiers --- docs/release-evidence/fa-web-android-slice-2026-08-04.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-evidence/fa-web-android-slice-2026-08-04.md b/docs/release-evidence/fa-web-android-slice-2026-08-04.md index 72509e29..bcd57515 100644 --- a/docs/release-evidence/fa-web-android-slice-2026-08-04.md +++ b/docs/release-evidence/fa-web-android-slice-2026-08-04.md @@ -29,6 +29,8 @@ feature. It is a client slice, not a claim that all FA P0/P1 requirements are co - `c3fe924` — Android review companion and instrumentation coverage - `23cf2c5` — Android offline queue tests - `162f502` — Android offline action queue +- `1047fa8` — Android UI persists actions before local transitions +- `7aa7a3c` — bounded deterministic offline mutation identifiers ## Checks From 6126688ea869e7547f56bae9b465e2c51ff34825 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Acharn=C3=A9?= Date: Tue, 4 Aug 2026 15:50:56 +0700 Subject: [PATCH 34/62] fix(web): show Folder Autopilot profile list --- .../folder-autopilot-page.tsx | 45 ++++++++++++++++++- apps/web/test/folder-autopilot-page.test.tsx | 3 ++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/apps/web/src/features/folder-autopilot/folder-autopilot-page.tsx b/apps/web/src/features/folder-autopilot/folder-autopilot-page.tsx index 8cbab571..464f4b50 100644 --- a/apps/web/src/features/folder-autopilot/folder-autopilot-page.tsx +++ b/apps/web/src/features/folder-autopilot/folder-autopilot-page.tsx @@ -15,6 +15,7 @@ import { type FolderAutopilotExecution, type FolderAutopilotProfileInput, type FolderAutopilotPreview, + type FolderAutopilotProfile, } from './folder-autopilot-api.ts'; function dateLabel(locale: ReturnType, value: string): string { @@ -45,7 +46,13 @@ function assignmentHealth( return dashboard.health.find((item) => item.assignmentId === assignmentId)?.watcherState; } -function ProfileAuthoring({ onSaved }: { readonly onSaved: () => void }) { +function ProfileAuthoring({ + profiles, + onSaved, +}: { + readonly profiles: readonly FolderAutopilotProfile[]; + readonly onSaved: () => void; +}) { const locale = useLocale(); const [input, setInput] = useState({ displayName: '', @@ -82,6 +89,40 @@ function ProfileAuthoring({ onSaved }: { readonly onSaved: () => void }) {

{appMessage(locale, 'autopilot.profile.heading')}

JRA profile facade + {profiles.length === 0 ? ( +

{appMessage(locale, 'autopilot.reason.none')}

+ ) : ( +
+ {profiles.map((profile) => ( +
+
+
+

{profile.displayName}

+ {profile.profileId} +
+ {profile.dataModeConstraint} +
+
+
+
{appMessage(locale, 'autopilot.profile.collision')}
+
{profile.collisionPolicy}
+
+
+
{appMessage(locale, 'autopilot.profile.confidence')}
+
{profile.confidenceThreshold}
+
+
+
{appMessage(locale, 'autopilot.profile.approval')}
+
{profile.approvalRequired ? 'Required' : 'Optional'}
+
+
+
+ ))} +
+ )}
void submit(event)}>