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:
- Flat —
catalog/<identity>.toml. What agentFilePath resolves, used by render and retire as their fast path. Our live catalog uses this.
- Nested, no
agents/ — catalog/<host>/<identity>/agent.toml. What newAgentSpecPath, catalog.ts#L181-L183 writes on convoy add.
- 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.
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(currentmain).The mechanism
walkFiles,src/catalog.ts#L52-L68:Dirent.isFile()/isDirectory()reflectlstat, notstat. 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 theifchain.Reproduced against a live catalog whose sole entry is a symlink:
What that costs
readCatalogreturns zero entries for such a catalog. Callers:up.ts#L338,up.ts#L376,commands.ts#L738,commands.ts#L949,rename.ts#L92,rename.ts#L177.renderstill works, which is why this is easy to miss.agentFilePath,agent-file.ts#L104-L106joins a direct path andexistsSync/readFileSyncboth follow symlinks — discovery is bypassed entirely.reconcile.ts#L65-L71only tears down entries that are present in the plan andretired = true. A vanished entry produces no teardown; it just produces nolauncheither.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 upfail 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, thereadCatalogcall sites, and thereconcileteardown 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:The existing
depth > 8cap already guards against symlink loops, so no extra cycle detection is needed. Thetry/catchkeeps 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:
catalog/<identity>.toml. WhatagentFilePathresolves, used byrenderandretireas their fast path. Our live catalog uses this.agents/—catalog/<host>/<identity>/agent.toml. WhatnewAgentSpecPath,catalog.ts#L181-L183writes onconvoy add.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 addwrites (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 byconvoy addat (2) does not matchrender/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.