Sync Tools
The basic file flow – mirror, store, pull, list, status – is documented in Files & Mirrors. This page covers the rest of the sync surface: configuring remotes, setting routing policy, and the destructive operations.
For the conceptual model see Local Mirrors.
Remote configuration
Section titled “Remote configuration”A remote is a backend storage configuration. One row per remote in the remotes table. The same Portuni instance can have many remotes – e.g. one Google Shared Drive per organization.
A local workspace (neither central mode nor a central-mode sync agent — see Data Modes) cannot register or route to a remote at all. portuni_setup_remote and portuni_set_routing_policy refuse with error code LOCAL_MODE_NO_REMOTE there, and so does every push/pull operation: portuni_store, portuni_pull, portuni_snapshot, POST /nodes/:id/sync, and POST /nodes/:id/files/:fileId/resolve. Sharing files across machines runs through central mode instead.
A local workspace’s portuni_status still works — it just never reports anything push/pull/conflict-shaped. Tracked files there classify as only clean (present on disk) or deleted_local (tracked, gone from disk); an untracked file is new_local. push, pull, conflict, remote_missing, and remote_error cannot occur without a remote to compare against.
portuni_setup_remote
Section titled “portuni_setup_remote”Create or update a named remote (upsert) and store its credentials. Calling it again with an existing name replaces that remote’s config and drops the cached adapter.
| Parameter | Type | Required | Description |
|---|---|---|---|
name |
string | yes | Unique remote name (e.g. drive-workflow, drive-tempo) |
type |
enum | yes | gdrive, dropbox, s3, fs, webdav, or sftp |
config |
object | yes | Backend-specific configuration. For gdrive: { shared_drive_id, root_folder_id? } |
service_account_json |
string | conditional | Required for gdrive. Stored via TokenStore (file / keychain / varlock), never in Turso |
For a Service Account remote the shared_drive_id is mandatory – service accounts have no My Drive storage quota, so they can only write into Shared Drives.
portuni_setup_remote (service account, central-mode only) is the only way to configure a Drive remote — there is no per-user OAuth connect flow. See Setting Up Remotes.
portuni_list_remotes
Section titled “portuni_list_remotes”| Parameter | Type | Required | Description |
|---|---|---|---|
| (none) |
Returns: array of { name, type, authenticated } per remote – authenticated reflects whether credentials for this remote exist on the current device (fs remotes are always true). No config object and no routing rules are returned; routing lives in portuni_set_routing_policy below.
Routing policy
Section titled “Routing policy”Routing tells Portuni which remote to use for a given (node_type, org_slug) combination. Rules are priority-ordered – the first match wins.
portuni_set_routing_policy
Section titled “portuni_set_routing_policy”Replace the entire remote_routing table with a new list of rules. Every existing rule that is not in the new list is deleted. Use only when the user explicitly asks to overwrite the routing policy.
| Parameter | Type | Required | Description |
|---|---|---|---|
rules |
array | yes | Full ordered list of routing rules — see shape below |
Each rule object:
| Field | Type | Description |
|---|---|---|
priority |
number | Lower wins. Use 100 for typical defaults, 10 for high-priority overrides |
node_type |
string | null | Specific node type (project, process, area, principle, organization), or null for any |
org_slug |
string | null | Specific org sync_key, or null for any |
remote_name |
string | Target remote (must already exist) |
Example — route every project across every org to the shared projects-hub drive, but route Workflow’s processes to the workflow-specific drive:
portuni_set_routing_policy { rules: [ { priority: 10, node_type: "process", org_slug: "workflow", remote_name: "drive-workflow" }, { priority: 100, node_type: "process", org_slug: null, remote_name: "shared-processes" }, { priority: 100, node_type: "project", org_slug: null, remote_name: "projects-hub" } ]}resolveRemote(nodeType, orgSlug) picks the first rule whose node_type either matches or is null, and whose org_slug either matches or is null, ordered by ascending priority then insertion order.
Returns: { count: number } — the number of rules now in the table.
Snapshot
Section titled “Snapshot”portuni_snapshot
Section titled “portuni_snapshot”Export a Google Docs/Sheets/Slides URL to PDF / Markdown / DOCX and store it as a tracked file on the node. Use when the user wants a point-in-time copy of a native Google doc tracked on a node — e.g. archiving a spec snapshot before continuing edits.
| Parameter | Type | Required | Description |
|---|---|---|---|
node_id |
string | yes | Node to attach the exported file to |
doc_url |
string | yes | URL of a Google Doc / Sheet / Slide. Must contain /d/<id>/ — the Drive file ID is extracted from this segment |
format |
enum | no | pdf (default), markdown, or docx |
filename |
string | no | Override the default filename (snapshot-<timestamp>.<ext>) |
subpath |
string | null | no | Optional subfolder within the node’s section |
Returns: { file_id, filename, remote_path }. With a local mirror the exported buffer is stored via the same flow as portuni_store; without one (central server, teammate session) it is created directly on the remote and registered. In agent mode the device then pulls the file into its mirror and adds local_path (null when the node is not mirrored on that device).
Deliberate sync run
Section titled “Deliberate sync run”portuni_status only reports the current classification — it never touches the remote or cleans anything up. Reconciling drift against the remote happens in a deliberate sync run, triggered by the desktop/web UI’s “Synchronizovat” action (or, for a teammate mirror in central mode, by the sync agent). One run does, in order:
- Retry pending file ops — replays any move/rename/delete whose remote step didn’t finish last time (see Destructive operations below).
- Remote sweep — a tracked file whose remote object is confirmed gone is removed and tombstoned; a file that appeared anywhere under
wip/,outputs/, orresources/(at any depth) is adopted and pulled in the same run (a dot-prefixed filename or subfolder is skipped). A record never pushed from this device is left alone, and nothing is destroyed if the remote itself can’t be confirmed reachable. The sweep also refreshescurrent_remote_hashfor any tracked, present record — central-mode classification reads that column as its only source of remote truth, so a record with a NULL hash used to read asremote_missingforever even though the object was right there in the sweep’s own listing (#273), and a record whose hash went stale (a teammate editing the file directly in Drive) used to read as permanently clean, so the edit was never pulled by any device (#276). For a backend that reports a content hash on listing (Drive) this refresh costs no extra remote call; a backend that doesn’t (e.g. a plain filesystem remote) only gets a NULL hash resolved (by downloading and hashing), since re-verifying an already-known hash there would mean downloading every tracked file’s content on every sync. - Reconcile. Resolve every tracked record whose remote state is unknown — central holds no hash for it and this device has never observed one. A missing hash means “nobody has looked”, never “the object is gone” (the sweep proves absence by deleting the record), so without this step such a record is skipped as
remote_missingon every run, forever. Bounded per run, and self-extinguishing: a resolved record never comes back. - Status scan — a pure read of what is now known.
statusScanCentralhas nofastparameter: re-deriving truth is the step above, not a mode of reading. - Push every
pushcandidate, pull everypullcandidate. A push the remote refuses because it already holds different content is reported underconflicts, noterrors— the refusal itself proves the remote object exists, and the device records the hash it just observed, so the file reads as aconflictfrom then on and the row offers “Ponechat lokální”/“Vzít z remote” instead of a push that can never land. This matters most when central’s owncurrent_remote_hashis NULL: classification would otherwise keep reading the file as an ordinary pending upload forever. Adeleted_localfile is reported, not auto-restored — that needs an explicit decision (see Resolving conflicts and deletions). Every push and pull is serialized per local path against any other push/pull of that same file on this device (a background push fromportuni_store’s create flow, a sync-run push, a foreground pull, an editor save) — an edit landing mid-push is rehashed and stays a push candidate instead of being masked as clean, and a pull’s dirty-local check can’t be raced by a write landing after the check but before the overwrite (#277). - Clean up untracked local copies that match a delete or move/rename tombstone.
- Adopt whatever local files are still untracked — including an edited copy of a file just deleted on the remote, which wins over the deletion and gets pushed back.
Background sync jobs (bulk “Synchronizovat vše”)
Section titled “Background sync jobs (bulk “Synchronizovat vše”)”Running the sequence above for one node is a single blocking request (POST /nodes/:id/sync, unchanged — this is what portuni_status’s consumers and the MCP-adjacent tooling still use). Syncing every pending node at once no longer loops that request client-side: the web/desktop UI’s “Synchronizovat vše” instead starts a background job that runs server-side with bounded concurrency across nodes, so closing the overview, switching windows, or one slow node no longer blocks the rest of the batch.
| Endpoint | Method | Description |
|---|---|---|
/sync/jobs |
POST | Starts a job. Body { node_ids?: string[] } — omitted defaults to every node with actionable pending work (GET /sync/pending’s total > 0 set). Returns 202 with the job summary immediately; a second start while one is already running for the same user reattaches to it instead of racing a duplicate, appending any node the running job does not already cover. |
/sync/jobs/:id |
GET | Job status: { id, status: "running" | "done", started_at, finished_at, total, completed, errored, nodes: [{ node_id, status, result?, error? }] }. |
/sync/jobs/current |
GET | { job: <summary> | null } — the caller’s own currently-running job, so a reopened UI can reattach without remembering the job id. |
Job state is in-memory on the server/sidecar process — it does not survive a restart, only a UI remount (closing/reopening the overview, switching windows). Each node’s own sync work is unaffected either way: runNodeSync/syncRunCentral per node is the same idempotent call the direct route makes, so a lost job is a lost progress view, never lost or duplicated sync work.
GET /sync/pending — actionable work vs. decisions
Section titled “GET /sync/pending — actionable work vs. decisions”The cross-mirror aggregate behind the footer badge, the quit guard, and /sync/jobs’ default node set splits each node’s (and the aggregate’s) count in two:
total— actionable:push+ untracked file count. This is exactly what a sync run (or a background job) can clear.decisions— needs a human:conflict+deleted_local. A run leaves both untouched by design (see Resolving conflicts and deletions), so counting them intototalused to make the badge/quit guard warn about work “Synchronizovat vše” could never actually finish. A node with decisions but no actionable work still appears in the overview (not hidden), just withtotal: 0— and its row offers “Rozhodnout”, which opens the node, rather than “Synchronizovat”: a run on such a node would report nothing and change nothing. “Synchronizovat vše” likewise covers only nodes withtotal > 0.
remote_missing is reported per node but counted in neither — a run does not push or pull it either, and (per the remote-sweep hash backfill above) most remote_missing misclassifications now self-correct on the next sweep instead of needing a decision at all. In central mode a device also falls back to a remote hash it observed first-hand on an earlier push or pull when central’s record carries none, so a file it has provably reached stops reading as remote_missing even before the next sweep.
Destructive operations
Section titled “Destructive operations”All three operations below are confirm-first. The first call returns a preview without acting; show the preview to the user, then call again with confirmed: true to execute. Best-effort ordered (remote, then local, then DB) — a partial failure returns repair_needed with a hint, and the operation’s intent is recorded so the next sync run retries it automatically until it completes.
portuni_move_file
Section titled “portuni_move_file”Move a tracked file within its node (new subpath or section) or to a different node.
| Parameter | Type | Required | Description |
|---|---|---|---|
file_id |
string | yes | File to move |
new_node_id |
string | no | Move to a different node (cross-node move) |
new_section |
enum | no | wip, outputs, or resources — re-section within the same node |
new_subpath |
string | null | no | New subpath within the section. Pass null to clear |
confirmed |
boolean | no | First call returns a preview; pass true on the second call to execute |
Returns either a preview (when confirmed is omitted or false) or the executed result. Partial failures return repair_needed: true with a hint. Like portuni_rename_folder, the remote step stats both the source and destination first and refuses the move outright if an object already sits at the destination path — an untracked file that hasn’t been adopted yet is never silently duplicated or overwritten. A move between two different remotes is a copy followed by a delete, so it is not atomic: when the copy lands and the delete of the source fails, that fact is recorded with the operation’s intent, and the next sync run finishes it by removing the source copy. Without that record both objects are present and indistinguishable, which the retry refuses to resolve on a guess.
portuni_rename_folder
Section titled “portuni_rename_folder”Rename a subpath within a node’s sync layout. Updates remote_path for every file under the prefix, one remote operation per file.
| Parameter | Type | Required | Description |
|---|---|---|---|
node_id |
string | yes | Node whose folder to rename |
old_prefix |
string | yes | Existing subpath prefix (relative to the node’s section root) |
new_prefix |
string | yes | New subpath prefix |
dry_run |
boolean | no | Defaults to true — returns a preview of affected files. Call again with dry_run: false to apply |
limit |
number | no | Max files to rename in this apply call (default 20). Ignored for dry_run |
An apply call is bounded by limit so a large folder can’t time out the caller mid-run. When the result’s remaining is greater than 0, call again with the same node_id/old_prefix/new_prefix — already-renamed files no longer match old_prefix, so the next call picks up exactly where the previous one left off, with no extra state to track. Each file’s remote step stats both the source and destination first, so a retry that finds the object already at the destination reports it ok with already_at_target: true instead of failing.
sync_key itself is immutable — this tool only changes the visible subpath. The underlying identifier the system uses for routing does not change.
portuni_delete_file
Section titled “portuni_delete_file”Delete a tracked file. Two modes.
| Parameter | Type | Required | Description |
|---|---|---|---|
file_id |
string | yes | File to delete |
mode |
enum | no | complete (default — removes remote + local + DB row) or unregister_only (DB row only; use when the file is already gone from disk and remote) |
confirmed |
boolean | no | First call returns a preview; pass true on the second call to execute |
Returns the preview or the executed result. For Drive remotes, “delete” means moving to Drive’s trash (30-day recovery window) – Portuni does not hard-delete via the remote API.
Adoption
Section titled “Adoption”portuni_adopt_files
Section titled “portuni_adopt_files”Register existing remote files (not currently tracked) as files rows for the given node. Non-destructive. Use after portuni_status surfaces new_remote entries to bring them under tracking.
| Parameter | Type | Required | Description |
|---|---|---|---|
node_id |
string | yes | Node to adopt files into |
paths |
string[] | yes | Paths on the remote (relative to the node’s remote root) |
status |
enum | no | wip (default) or output |
Returns: array of adopted files rows, including computed hashes. Existing tracked files at the same paths are skipped (idempotent).
Resolving conflicts and deletions
Section titled “Resolving conflicts and deletions”A conflict (both sides changed) or deleted_local (locally removed, still on the remote) file needs a human decision — Portuni never auto-merges or auto-restores. As an agent, resolve it with the same tools used elsewhere:
| Situation | Action | Tool |
|---|---|---|
conflict, keep the local version |
Push local over remote | portuni_store |
conflict, take the remote version |
Overwrite local with remote | portuni_pull(file_id, force: true) |
deleted_local, restore it |
Download the remote copy back into the mirror | portuni_pull(file_id) |
The desktop/web UI exposes the same three actions as buttons on the file row, backed by POST /nodes/:id/files/:fileId/resolve with { action: "keep_local" | "take_remote" | "restore" }. It 404s if the file doesn’t belong to the node; it 409s on restore when it would clobber a local change that was never pushed (the same guard portuni_pull applies without force), and on keep_local when the node has no mirror on this device.
See also
Section titled “See also”- Files & Mirrors – the core file flow (mirror, store, pull, list, status)
- Local Mirrors – how mirrors and remotes fit together
- Setting up remotes – step-by-step Google Drive Service Account setup