A sync that changed nothing says so again
Docs / docs (push) Successful in 32s
Playwright Tests / test-playwright (1, 2) (push) Successful in 2m58s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m9s
pre-commit / pre-commit (push) Failing after 2m23s
Test Backend / test-backend (push) Successful in 3m17s
Playwright Tests / merge-reports (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 28s

Comparing the per-node digest against an engine that does not record one is
comparing against nothing, and reporting every node as changed on every sync
for ever — which is what a client newer than its engine did, since `NodeDef`
drops fields it has never heard of. A node is named now only when both sides
carry a digest, so a no-op sync is `unchanged` again and the signal one syncs
for is back.

That silence had also been the only sign of the mismatch, so sync now names
it: one line saying the engine stored no record of what a node's code reaches,
with both versions in it and what to run. Bumped to 0.1.6 — the digest changed
the stored document's shape, and a version that does not move makes two
different engines indistinguishable, which is the thing it was made
load-bearing for a day ago.

`— draft` was printed whenever there was simply nothing to publish, which
reads as work left unfinished. It is said only when a draft is genuinely
there, and `— published` when one was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9Hdrmf2cwNABCnE5x9UJa
This commit is contained in:
2026-08-27 22:58:31 +02:00
co-authored by Claude Opus 5
parent 91ef2bbe9a
commit f8a7dfb101
6 changed files with 84 additions and 17 deletions
+21 -6
View File
@@ -301,8 +301,18 @@ def cmd_sync(args: argparse.Namespace) -> int:
continue
what = "created" if report.created else "updated"
detail = ", ".join(report.changed)
state = "published" if report.published else "draft"
_say(f" {report.flow}: {what} ({detail}) — {state}")
# Nothing to publish is not the same as left as a draft, and saying
# the second when it was the first reads as work not finished.
state = "published" if report.published else "draft" if report.drafted else ""
_say(f" {report.flow}: {what} ({detail})" + (f"{state}" if state else ""))
if any(report.forgot_code for report in reports):
engine = _engine_version(client)
_say(
" note: this engine did not store what each node's code reaches, "
"so its cache is still keyed on the whole repository. It is "
f"{engine or 'older'} and this client is {__version__}"
"`pip install -U fluksio` there."
)
stamp = origin["commit"][:7] + ("-dirty" if origin["dirty"] else "")
_say(f"Stamped with {stamp or 'no commit'} from {repo}.")
return 0
@@ -984,12 +994,17 @@ def _list_names(client: Client, args: argparse.Namespace) -> int:
return 0
def _engine_version(client: Client) -> str:
"""What the engine says it is, or empty if it is older than saying so."""
try:
return str((client.summary() or {}).get("version") or "")
except (SyncError, ApiError, httpx.HTTPError):
return ""
def _too_old(client: Client) -> str:
"""A route this client knows and the engine does not."""
try:
version = (client.summary() or {}).get("version") or ""
except (ApiError, httpx.HTTPError):
version = ""
version = _engine_version(client)
engine = f"the engine is {version}" if version else "the engine is older"
return (
f"this engine has no export endpoints — {engine} and this client is "
+35 -8
View File
@@ -636,6 +636,13 @@ class SyncReport:
self.created = False
self.changed: list[str] = []
self.published = False
#: A draft exists and was left alone, because publishing was not asked
#: for. False when there was simply nothing to publish, which is not
#: the same thing and used to read as if it were.
self.drafted = False
#: The engine stored the node document without what each node's code
#: reaches, which means it is older than this client.
self.forgot_code = False
@property
def unchanged(self) -> bool:
@@ -729,6 +736,14 @@ def _sync_one(
document = target.document(origin)
moved = _moved_code(stored, document)
saved = client.put_flow(document | {"version": version})
# An engine older than the per-node digest parses the document without
# those fields and stores it without them, silently: no error, no 404, and
# a cache still keyed on the whole repository.
kept = _digests(saved.get("definition"))
report.forgot_code = any(
digest and not kept.get(node_id)
for node_id, digest in _digests(document).items()
)
stored_version = int((saved.get("definition") or {}).get("version") or version)
if report.created or stored_version != version:
# The store bumps the version only when the content actually changed,
@@ -751,12 +766,23 @@ def _sync_one(
if node_id not in report.changed:
report.changed.append(node_id)
if publish and (client.get_flow(target.name) or {}).get("has_draft"):
has_draft = bool((client.get_flow(target.name) or {}).get("has_draft"))
if publish and has_draft:
client.publish(target.name, version)
report.published = True
else:
# Only when one is actually there. "Nothing needed publishing" and
# "left as a draft" are different answers to the same question.
report.drafted = has_draft
return report
def _digests(document: dict[str, Any] | None) -> dict[str, str]:
"""Each node's recorded code digest, by node id."""
nodes = (document or {}).get("nodes") or []
return {str(node.get("id")): str(node.get("code_digest") or "") for node in nodes}
def _moved_code(stored: dict[str, Any] | None, document: dict[str, Any]) -> list[str]:
"""The nodes whose reachable code changed since the last sync.
@@ -764,18 +790,19 @@ def _moved_code(stored: dict[str, Any] | None, document: dict[str, Any]) -> list
the shim reflects the declared ports rather than what the function calls
into — so editing a helper printed as nothing at all, which reads as
"there was nothing to do".
A node is named only when *both* sides carry a digest. An engine that does
not record one drops the field on the way in, so comparing against nothing
would report every node as changed on every sync, for ever — which is a
worse answer than the silence this replaced.
"""
if stored is None:
return []
before = {
node.get("id"): node.get("code_digest") or ""
for node in (stored.get("definition") or {}).get("nodes") or []
}
before = _digests(stored.get("definition"))
return [
node_id
for node in document.get("nodes") or []
if (node_id := node.get("id")) in before
and before[node_id] != (node.get("code_digest") or "")
for node_id, digest in _digests(document).items()
if digest and before.get(node_id) and before[node_id] != digest
]