Skip to content
This repository was archived by the owner on Jul 24, 2026. It is now read-only.
This repository was archived by the owner on Jul 24, 2026. It is now read-only.

Catalog discovery skips symlinked entries: a symlinked catalog reads as empty #103

Description

@schickling-assistant

Catalog discovery drops symlinked entries, so a catalog whose specs are symlinks reads as empty. We hit this with a catalog whose entries are managed symlinks (Nix home.file), and it took a while to spot because the most-used command still works.

References are to d453e21 (current main).

The mechanism

walkFiles, src/catalog.ts#L52-L68:

if (d.isDirectory()) out.push(...walkFiles(p, depth + 1));
else if (d.isFile()) out.push(p);

Dirent.isFile() / isDirectory() reflect lstat, not stat. For a symlink dirent both are false — so a symlinked entry is neither collected nor descended into. It is not skipped by an explicit rule; it just falls off the end of the if chain.

Reproduced against a live catalog whose sole entry is a symlink:

cos.toml  isFile()=false  isSymbolicLink()=true  isDirectory()=false
walkFiles would collect: []

What that costs

So the failure mode is narrow but real: declared agents cannot be launched from the catalog, while everything that resolves by direct path keeps working.

What we have not observed

We have not seen convoy up fail to bring up an agent on a cold host. Confirming that would mean stopping a live agent, which we did not want to do. The claim above is inference from three separately verified facts — the dirent behaviour, the readCatalog call sites, and the reconcile teardown condition — not an observation. If there is a path we have missed that recovers discovery for symlinked entries, we would rather be wrong here.

Suggested fix

Worth being careful with: adding || d.isSymbolicLink() to the file branch is wrong, because a symlinked directory would then be collected as a file and never descended into. That is exactly the case that starts mattering under the v2 layout, where the {hostname}/{id}/ chain itself must survive the dirent test. Routing on the resolved type instead:

if (d.isDirectory()) out.push(...walkFiles(p, depth + 1));
else if (d.isFile()) out.push(p);
else if (d.isSymbolicLink()) {
  let st;
  try { st = statSync(p); } catch { continue; }  // follows the link; a broken link is skipped
  if (st.isDirectory()) out.push(...walkFiles(p, depth + 1));
  else if (st.isFile()) out.push(p);
}

The existing depth > 8 cap already guards against symlink loops, so no extra cycle detection is needed. The try/catch keeps a dangling symlink from throwing, matching how the rest of the walk degrades quietly.

Happy to send this as a PR with a test if you would like it — holding off since it touches discovery semantics and you may want it shaped differently.

Separately: three catalog layouts are in play

Noticed while tracing the above, and probably worth pinning before the v2 spec lands:

  1. Flatcatalog/<identity>.toml. What agentFilePath resolves, used by render and retire as their fast path. Our live catalog uses this.
  2. Nested, no agents/catalog/<host>/<identity>/agent.toml. What newAgentSpecPath, catalog.ts#L181-L183 writes on convoy add.
  3. Nested, with agents/catalog/agents/{hostname}/{id}/. What the v2 working draft specifies.

Discovery is content-based, so all three are readable — that design is a genuine strength and we are not asking you to give it up. But convoy add writes (2) while the spec describes (3), so a catalog convoy authored and a catalog rendered from the spec have different shapes on disk. There is also a small internal wrinkle: an agent written by convoy add at (2) does not match render/retire's flat fast-path at (1) and falls through to discovery — which is the code path this issue is about.

Which layout do you consider canonical? Happy to align on whatever you pick; mostly we would like the writer and the spec to agree.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions