mirror of
https://github.com/logos-co/logos-logoscore-cli.git
synced 2026-08-30 20:31:09 +00:00
master
16
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c502fcc991 |
fix(search): one version per row, and name the rest in package show
The AVAILABLE VERSIONS column listed every release inline, which pushed the table to 117 columns and wrapped every row -- blockchain_module alone carries seven versions. Search now shows the newest and `(+N)` for the rest, which fits 99 columns. The full list moves to `package show NAME`, on an `available:` line. That takes a catalog lookup, and the same lookup closes a related gap: `show` used to refuse anything not installed, which is exactly the package you ask about after a search. It now answers from the catalog and says `installed: no`; PACKAGE_NOT_FOUND is raised only when the name is in neither place. `--json` for an installed package is unchanged, and skips the extra call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0c2ab43292 |
feat(logosctl): show catalog package versions (#106)
* feat(logosctl): show catalog package versions * fix(logosctl): format package version list |
||
|
|
0f0be25959 |
fix(client): fail at once when the daemon is gone, and stop reporting that as data
Against a session whose daemon is no longer there, `logosctl module ls` waited 22 seconds and then printed `[]` and exited 0. Not "failed slowly" -- reported success, with an empty module list, about a daemon that did not exist. `stats` did the same. `call`, `package`, `catalog` and `key` waited the same 20 seconds before reporting RPC_FAILED. Only `stop` and `status` were quick, because #100 gave them a guard the other fourteen commands never got. The mechanism is the one #100 diagnosed. A LocalSocket client "connects" to a socket path with no listener without complaint, QtRO surfaces no transport error for an absent peer, and the request is therefore neither answered nor refused -- so it waits out Timeout(20000) (logos-protocol, cpp/logos_mode.h) and a dead daemon is indistinguishable from a slow one until the deadline fires. Connecting is not the check it looks like. A session outlives its daemon in two shapes, and they need different evidence. CRASHED SESSION. daemon/state.json is still on disk naming a pid that is gone. This is #100's check, and it was copied into stop_command and status_command. It now lives in one place -- detectStaleSession(), called from Command::ensureConnected() -- which is the single door every RPC-opening command goes through, so all of them inherit it instead of the two that had it hand-written. StatusCommand still calls the helper itself, one step earlier, because its answer to "no daemon" is a status report rather than an error. #100's instance_id gate is preserved exactly: the guard fires only when the state file describes the daemon THIS client dials. A remote client can have a co-resident daemon's leftovers sitting in its own session directory, and its dial spec carries no instance_id at all, so an empty one never matches. The liveness syscall now runs before the client-config read, so the common path (daemon running) does not parse client/config.yaml twice per command. STOPPED SESSION. The tidier way to get here, and the one the pid guard cannot see: a clean `daemon stop` REMOVES daemon/state.json, leaving client/config.yaml and the token behind with no pid left to find dead. Every command still waited the full 20s. RpcClient::connect() now asks the socket instead, before it builds a LogosAPIClient (localEndpointProvablyAbsent, src/local_endpoint.h): the dial resolves to QDir::tempPath()/logos_core_service_<instance_id>, because the SDK asks for the bare name (LogosInstance::id) and Qt resolves a bare QLocalSocket/QLocalServer name against the temp dir. Deriving it the same way is what makes the answer sound rather than a guess. A stat alone is NOT enough, which cost this patch a wrong first draft. The socket file outlives the daemon: a hard kill leaves it, and a clean stop leaves it for the window between the shutdown reply and QLocalServer's destructor -- which is exactly when the next command gets typed. Measured through the new CLI sweep, stat-only vs stat-plus-connect over the same abandoned socket: 85.3s (every command timed out) vs 0.8s. So presence settles nothing and being REFUSED does; ECONNREFUSED is the same signal logos::isSocketDead uses to decide a socket is safe for the daemon's boot reaper to unlink. That function is not reused directly only because it sits behind the logos-protocol link, which logosctl_testlib deliberately does without. The check fails closed on everything short of proof: a socket that accepts us, any other connect() error, a path too long for sun_path, a non-socket inode, a tcp/tcp_ssl dial, an empty instance_id, Windows (named pipes, no inode). Refusing a reachable daemon would be far worse than the wait being removed. AN UNANSWERED QUERY IS NOT AN EMPTY ONE. The exit-0 half is a separate defect and survives independently of the timing: listModules() and getModuleStats() answered a failed RPC with LogosList::array(), the only two calls in the client that reported failure as data. Both now return optional<LogosList>, and the commands report DAEMON_UNREACHABLE with exit 2. `status` had the same shape by a different route -- RpcClient::getStatus synthesises a not_running report and marks it `rpc_error`, and that report has a "daemon" key, so it reached the success branch and exited 0 while printing "not running". It exits 1 now, as docs/project.md always said it did. `status` also connects directly rather than through ensureConnected(): that helper PRINTS a NO_DAEMON envelope, and letting it do so put two JSON documents on stdout for one command, which no `jq` invocation survives. Nothing opts out of the guard. `watch` is the one command with a case for waiting -- a daemon that has not started yet is a reasonable thing to watch for -- but it does no waiting today: it connects once and gives up, so failing in milliseconds is what it already meant to do. The four commands the issue listed that are NOT covered (`token issue|revoke|list`, `daemon|client config`) never call ensureConnected at all: they read and write the session's own files and have no daemon to be absent. TESTS. * CLITest.{Crashed,CleanlyStopped}Session_EveryRpcCommandFailsAtOnce and SocketLeftOverWithNoListener_EveryRpcCommandFailsAtOnce: all 17 commands against all three shapes, end-to-end, killed at 5s so exit 124 means the command was still waiting. Driven against the pre-fix binary via $LOGOSCTL_BINARY these fail with 124 on 15 of 17 commands, 80.3s. * CLITest.*_StatusReportsNotRunningAtOnce: exit 1, names the pid where there is one, and exactly one JSON document. * CommandTest.EveryRpcCommand_*: the 17 commands x 4 session shapes, against a mock, asserting on connectAttempts/rpcCalls -- a guard that fired is visible as the ABSENCE of contact. Three of the four shapes are the controls: live pid, foreign instance_id, and no state file at all must still dial. * LocalEndpointTest.*: the path derivation against QDir::tempPath(), plus a verdict for each shape the path can be in -- missing, socket with no listener, LIVE listener, and a regular file wearing the name. * CommandTest.{ListModules,Stats}_{UnansweredRpc,AnsweredWithNothing}_* and Status_{UnansweredRpc,LiveDaemon}_*: both sides of the empty-vs-unanswered line. CommandTest had no Status_ coverage at all, which is how exit 0 survived. Before/after over the shipped binaries, same stale session, macOS: module ls exit 0 after 22s printing [] -> exit 2 in <1s, names the pid stats exit 0 after 20s printing [] -> exit 2 in <1s status exit 0 after 20s -> exit 1 in <1s call/package/catalog/key 20s, RPC_FAILED -> exit 2 in <1s and against a cleanly stopped session, where nothing was fast before, all of the above are now under a second too. Live-daemon behaviour is unchanged and checked: 249 unit + 30 CLI + 25 integration tests pass for logosctl and 20 CLI + 24 integration for logoscore via `nix build .#checks.<sys>.tests-logosctl` / `-logoscore`. The 25 integration tests drive real daemons through logosctl, so a wrong socket path would fail them loudly rather than silently refusing live sessions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
162dbc9fff |
fix(daemon stop): stop losing the shutdown reply, and stop calling that a failure
`logosctl daemon stop` printed {"code":"RPC_FAILED","message":"shutdown RPC
call failed."} and exited 3 for shutdowns that had already succeeded. It cost
the "Stop the daemon" step of doctests/logosctl-daemon.test.yaml one failure
out of nine identical shutdowns in the same macOS CI job; the daemon really
had stopped, and `daemon status` two seconds later said so.
Two independent defects, one on each side of the call.
DAEMON. CoreServiceImpl::shutdown() returned {"status":"ok"} and left the
event loop from a detached std::thread that slept 200ms and called
QCoreApplication::quit(). The reply is not on the wire at that point: the
transport serialises it after the handler returns and hands it to the socket,
which only pushes it out when the event loop services that socket's write
notifier. quit() is not a queued event -- QCoreApplication::exit() interrupts
the dispatcher directly -- so if the main thread was descheduled for longer
than the sleep, the loop came back, exited, and the buffered reply died with
the process. QtRO surfaces no transport error for this; the client just waited
out its 20s deadline and saw nothing.
The quit now runs on the main thread, from a timer, and drains the event loop
before ending it. The 200ms is now a courtesy margin rather than the
correctness mechanism, and $LOGOSCTL_SHUTDOWN_GRACE_MS makes it settable --
including to 0, which the new regression test uses because it is the setting
that used to lose the reply outright.
QtRO offers nothing better: QRemoteObjectHostBase has no per-reply
write-completion signal and no client-disconnect signal, so "quit when the
response has actually been flushed" is not reachable without forking Qt, and
the daemon also serves plain TCP/TLS through a different transport.
CLIENT. RpcClient::shutdown() reported RPC_FAILED whenever the reply was not
an object -- including when there was no reply. But a missing reply is the
expected outcome of asking a process to die, and both docs said so already:
docs/spec.md promised "the client treats the connection loss as a successful
shutdown" and docs/project.md promised exit 0 for it. Neither was implemented.
It now answers the question the reply was standing in for, from evidence: the
pid recorded in daemon/state.json (snapshotted before the call, since a clean
shutdown deletes that file) is watched for up to 15s, or for a remote daemon
the endpoint is re-probed. Gone means success, with `confirmed_by` naming the
evidence; still running means a real error, with a message that says which.
Blindly treating silence as success would have been the more dangerous
mistake -- a wedged daemon is also silent -- so it is not what this does.
That inference is only sound about a pid that was alive to begin with, so
`stop` now refuses a stale session up front the way `daemon status` already
does: a state.json naming this client's instance and a dead pid means there is
no daemon to stop (NO_DAEMON, exit 2). Without it, a session left behind by
last week's daemon would "connect" to nothing, time out, observe that the pid
is gone, and call that a successful shutdown.
TESTS.
* ShutdownReplyTest.StopSucceedsWithNoGracePeriod (integration): 60
start/stop cycles at LOGOSCTL_SHUTDOWN_GRACE_MS=0, asserting the command
succeeds, the daemon is actually gone, and the reply arrived rather than
being reconstructed from the process exiting. Measured through this
fixture on macOS: 6 losses in 100 cycles before the daemon fix, 0 in 120
after.
* CommandTest.Stop_StaleSession_* : the stale-session guard, its live-pid
control, and the remote-client case it must not block. CommandTest now
isolates HOME and the config dir, so the suite no longer reads whichever
~/.logosctl the developer happens to have.
* ProcessUtil.WaitForProcessExit* : the primitive the confirmation rests on.
A/B over the shipped binaries, 30 stop cycles per arm at zero grace, macOS:
pre-fix 8 failures; daemon fix only 0 (no reply lost); client fix only 0
(20 replies lost, every command still correct); both 0. At the default 200ms
grace both arms are clean, which is why this presented as a rare CI flake.
Independent of PR #99: that PR does not touch either function, and the two
diffs do not overlap.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
ed19258375 |
fix(core_service): report METHOD_FAILED from the error channel, not a null value
callModuleMethod judged failure with `ret.is_null()` because it called the
one invokeRemoteMethod overload that has no CallError* parameter
(logos_api_client.h:352-356, whose body forwards to the QVariant overload
with the error channel dropped). A method that legitimately returns null was
therefore indistinguishable from a call that failed — and that single line
was the entire empirical basis for the qt-generator's refusal to allow an
optional return.
Switching to the CallError-carrying overload (logos_api_client.h:98) is not
sufficient on its own: an unknown method name is deliberately NOT reportable
on the wire (logos_protocol.h:274-279 says so outright, and the cdylib
dispatch ends `return nullptr; // unknown method`), so a naive !err.ok()
would have turned every typo into a silent success. The decision is now:
!err.ok() -> METHOD_FAILED + {code,message,origin}
result is a dispatch_failed envelope -> METHOD_FAILED (the provider refused)
null AND method provably not exposed -> METHOD_NOT_FOUND + available_methods
otherwise -> ok, null included
METHOD_NOT_FOUND is not invented — docs/spec.md:918 specified that envelope,
with available_methods, all along; core_service simply never produced it. It
costs one extra round-trip only on a null return.
The logic lives in a new pure unit, core_service/call_envelope.{h,cpp}, with
no Qt and no logos-protocol, which is what makes it unit-testable at all. The
value path is byte-identical: the same nlohmannArgsToQVariantList /
qvariantToNlohmann the json overload used internally.
Behaviour changes a reviewer must agree with: a null return is now `ok`
rather than METHOD_FAILED, and a dispatch_failed envelope returned as data is
now METHOD_FAILED rather than `ok`. No existing test encoded the old
behaviour; no exit code or ok/error verdict flipped in any fixture.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
b31bc8f89f |
feat(logosctl): install a local .lgx by path, and refuse the mixed request (#91)
`package install --file X.lgx` and `--dir D` already installed a package
off disk. A bare path did not: `install ./mod.lgx` treated the path as a
catalog name, fell through to the resolver, and came back with
Cannot resolve './mod.lgx': no candidate matches './mod.lgx'
which reads like the package was rejected rather than never looked for --
and is the single most likely reason to conclude the feature is missing.
Read any argument ending in `.lgx` as a path, the way `package show` has
read it all along. A catalog name cannot carry that suffix, so the other
reading was never useful. `install`/`upgrade` only: `remove` names an
installed package, so a path there stays a name and still reports "is not
installed".
Making that safe surfaced three silent failures in the same function,
all of the same shape -- accept the argument, then quietly do something
other than what it asked:
* `install foo ./bar.lgx` installed the file and dropped `foo`. The
daemon's plan is either/or -- local files bypass the catalog entirely
(package_ops.cpp) -- so a mixed request did half the job and reported
success. Positional paths make that far easier to type by accident, so
it is now refused rather than half-honoured.
* `remove --file x.lgx` parsed the flag, handed it to a daemon branch
that reads `names` and ignores `localFiles`, and reported "Nothing to
do -- already up to date" having removed nothing.
* `--file a.lgx --dir empty/` tested emptiness against the combined list,
so a `--dir` that contributed nothing passed unreported. The count is
now scoped to what the directory itself added.
A missing path is also reported as a missing file now, instead of
reaching the resolver and coming back as a package-not-found.
Six unit tests cover the parsing, each asserting that a refused request
never reaches the daemon. logosctl-local-install.test.yaml covers the
whole loop end to end -- inspect, dry-run, install by path, load, call,
`--dir` reinstall, both refusals, remove -- with no catalog and no
network. That hermetic half is the gap next to logosctl-packages, which
drives the live catalog and cannot run offline. The workflow globs
doctests/*.test.yaml, so it is picked up with no CI change; sections are
marked linux/macos because a nix-built .lgx carries only the build host's
variant.
README: installing from the catalog needs the portable bundle, but a
locally built .lgx carries a `-dev` variant and needs the dev build. The
existing wording claimed the portable bundle for package commands
generally, which is the wrong half of the contract for this path.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
d015a40c5f |
Install deadline, honest failure reasons, and a -v that actually reaches the daemon (#83)
* fix(package): give installs a real deadline, and stop eating the reason
Two defects, found from a crashed install on a real server.
**A 20-second deadline on an operation that downloads a blockchain node.**
`Impl::invoke` never passed a timeout, so every call used the
transport's default `Timeout()` -- 20 seconds. That is right for "is the
daemon up" and useless for "fetch and install blockchain_module". The
client gave up at 20s and reported
Error: applyPackageOperation('install') RPC call failed.
while the daemon carried on and finished the job. The package landed on
disk and the user was told it had failed. Reproduced on the server: the
install completes, the client does not wait for it.
The comment above that call already said "the default RPC deadline is
far too short for that". It sat above a call that passed no deadline at
all.
planPackageOperation now gets 2 minutes (it reads the catalog) and
applyPackageOperation / downloadPackage 30 minutes (they transfer and
install). Generous on purpose: waiting too long costs a slow command,
waiting too little costs telling someone their install failed while it
is still running and about to succeed.
**The failure message threw away the reason.** package_ops returns two
error shapes -- its own step failures carry `failed_step` + `error`,
while anything that fails before the chain starts (an unreachable
module, a dead daemon, a transport error) carries `code` + `message`.
The client rendered only the first, so the second printed as
Error: install failed at step '?':
with nothing after the colon. That is what a real diagnosis looked like
when the daemon died mid-install: it had said why, and we dropped it.
Now whichever shape arrives is reported, the step is omitted rather than
printed as '?' when unknown, and an error carrying no reason at all says
so instead of trailing off.
182 unit tests, 3 new covering both shapes and the empty case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(daemon): let -v reach spdlog so forwarded module logs survive
* fix(daemon): -v was being passed as persistConfig
`Daemon::start` ends in two adjacent bool parameters, both defaulted:
static int start(int argc, char* argv[],
const DaemonConfig& cfg,
const std::string& configSource,
bool persistConfig = false,
bool verbose = false);
logosctl called it with one bool:
Daemon::start(argc, argv, mergedCfg, configSource, g_verbose);
so g_verbose became persistConfig and verbose stayed false for the
entire life of the binary. It compiled, because the defaults made the
short call legal. logoscore's own call site passes both and is correct;
this was introduced when logosctl dropped --persist-config and the
argument was removed without accounting for its position.
Two consequences, both silent. Every `if (verbose)` branch in the daemon
was dead, so -v changed nothing daemon-side -- it only ever reached
main.cpp's Qt message handler, which reads the global directly. And -v
quietly enabled config persistence, which is not a thing logosctl even
offers: its configuration is installed with `daemon config set`.
The call site now names both arguments. The declaration drops its
defaults, so the class of mistake is a build error rather than a silent
slide: two adjacent same-typed defaulted parameters are exactly the shape
where omitting one is undetectable.
This is also the real reason four earlier attempts at "module logs do not
reach the session log" measured as no-ops. The code meant to raise
spdlog's level sits behind `if (verbose)` and never ran.
182 unit + 25 CLI + 18 integration green for logosctl; logoscore
unchanged at 20 + 18.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: %d for QMessageLogContext::line, which is an int
Copilot caught this on the module-loader PR, and the same mistake is
here in both front-ends: the Critical/Fatal branches print
`context.line` with %u, but QMessageLogContext::line is an int. A
signed/unsigned format mismatch is undefined behaviour and can trip
-Wformat.
logoscore's two sites are included. The rendered output is identical for
any real line number, so this is not the behaviour change its frozen
suites exist to catch -- and they stay green at 20 + 18 to show it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore: bump liblogos for the module-host logging fix
Picks up logos-liblogos#173, which carries logos-module-loader-qt#5 --
the host installing its own Qt message handler so a module's
diagnostics are not diverted to journald.
This is the last link. The measurements in this PR were taken with the
loader spliced in via --override-input; with this pin they hold for a
plain `nix build` of the real chain.
Single input moved (plus its own transitive loader pin).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
e48fc7fef3 |
Add logosctl alongside logoscore: one CLI for daemon, modules and packages (#76)
* feat(core_service): add refreshModules and cascade unload by default Two runtime prerequisites for the logosctl merge. refreshModules() wraps logos_core_refresh_modules(), which liblogos documents as "call after installing new modules so they become discoverable". Basecamp calls it on the package_manager install event, which is why installing a module there needs no restart. core_service did not expose it, so a CLI that installs a package had no way to make the daemon see it short of a restart. unloadModule() now takes withDependents and the CLI defaults it to true (--no-dependents opts out). logos_core_unload_module already accepted the flag; core_service hardcoded false, which left dependents running against an unloaded provider. The result now carries dependents_unloaded so the cascade is reported rather than silent. The dispatch entry defaults a missing second argument to true, so a one-argument unloadModule call keeps working. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(flake): bundle package_manager and package_downloader logoscore bundled only capability_module, so it could authenticate but not manage packages — that was lgpd's and lgpm's job, as separate binaries. Bundling the two package modules is what lets one binary do the whole job. Same trio logos-basecamp bundles, assembled the same way (map the install bundler over the module libs), so the CLI and the GUI drive an identical module surface rather than the CLI being a reduced sibling. Only the package manager ships a distinct lib-portable; the other two are variant-agnostic, matching basecamp's split. Verified against a real daemon: all three are discovered with no module configuration, both package modules load, and package_downloader resolves the live default catalog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(daemon): make the config dir a self-contained session The daemon knew about ~/.logoscore only as a place to keep its own state files; packages, trust material and persistence lived elsewhere or nowhere. Now the config dir is the whole world for a session: <configDir>/modules installed core modules (writable) <configDir>/plugins installed UI plugins <configDir>/keyring trusted signing keys <configDir>/cache downloaded .lgx <configDir>/data module persistence so copying the directory carries the session's packages and its trust assumptions with it, and two sessions can disagree about both. <configDir>/modules joins the search path beside the bundled dir. Without it an installed module would sit on disk that the daemon could never see, and install-then-load could not work at all. The bundled package modules are loaded at boot and pointed at these directories -- the same four setters basecamp calls -- because every package command is an RPC into them. All best-effort: a daemon that cannot manage packages is still fully usable for loading and calling modules, so none of it aborts startup. Verified live: a bare daemon creates the tree, loads all three modules, and reports the embedded packages via getInstalledPackages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(package): daemon-side install, upgrade and remove Adds the mutating package operations the CLI never had, orchestrated inside the daemon and exposed as core_service.planPackageOperation / applyPackageOperation, plus the `package`, `catalog` and `key` command groups on top. Why daemon-side: package_manager gates destructive work behind a listener-ack protocol with a 3-second deadline. Driving that from a short-lived client would mean holding an event subscription open, interleaving it with outbound calls, and winning a three-second race across the RPC boundary. In-process the ack cannot lose that race, and every client command stays thin and stateless. plan/apply is split so `--dry-run` and the confirmation prompt see exactly what apply will do -- the same dependency-change table basecamp shows, including which running modules get stopped. Without -y and without a TTY the operation is refused rather than assumed-yes, so a script that forgot --yes fails loudly instead of silently uninstalling. install/upgrade take dependencies, remove takes dependents, both by default. Installing never loads: it puts files on disk, and only modules already running beforehand are restarted afterwards. Verified against the live catalog on a portable build: install openmetrics; install chat_module pulling delivery_module in order; re-install as a no-op; install then load with no daemon restart (refreshModules); and removing delivery_module cascading through chat_module with both stopped first. One trap worth naming: LogosList{vec} does not wrap a std::vector the way it wraps a scalar -- it yields an empty args array, and the module sees a zero-argument call it cannot dispatch. The batch uninstall builds its argument explicitly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(config): replace the flag surface with a YAML session document Configuration was ~20 flags plus two hand-written mini-grammars: a `NAME=PROTOCOL[,k=v...]` parser that existed only to squeeze a nested structure through a flag, and a per-flag defaults<config<CLI merge. Both are gone. main.cpp drops from 943 to 531 lines. Configuration now lives in the session: <configDir>/daemon/config.yaml written by `daemon config set` <configDir>/client/config.yaml written by `client config set` and is never passed alongside an unrelated command, so `daemon start` and every client command take the session exactly as it is on disk. --config-dir is the one surviving flag, because it selects *which* session to act on and so cannot itself live inside one. The split is by audience: files a human edits are YAML, files the daemon and modules own stay JSON (state.json, tokens, the auto token). Converting through nlohmann::json means the existing validated daemonConfigFromJson / clientStateFromJson keep doing the schema work. Two traps fixed while wiring it up, both of the accept-then-ignore kind that leaves an operator with no explanation: - A bare `modules: {core_service: [ ... ]}` sequence was silently skipped (only the `{transports: [...]}` spelling parsed). It is now accepted as shorthand. - Unknown top-level keys are rejected by `config set` and the error names the correct spelling, so `insecureTcp` no longer looks like it worked when the key is `insecure_tcp`. Module search paths remain configurable via the `modules_dirs` key, which is what replaces -m for tests and dev loops. The eight CLI tests that covered deleted flags are rewritten against the new surface: malformed YAML rejected without clobbering the existing config, unknown keys named, set/show round-trip, and absent config treated as defaults rather than an error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(cli): logosctl, with docker-style command groups Renames the binary and reorganises ~20 flat hyphenated commands into groups: daemon, client, module, token, package, catalog, key, plus top-level aliases for the four verbs that cannot be confused with a runtime module (status, call, watch, stats) and the two package verbs with no module meaning (install, search). The hyphenated names survive as internal dispatch tokens but are hidden from --help: `module load X` is rewritten to `load-module X` in argv before CLI11 parses. The rewrite happens in argv rather than via nested CLI11 subcommands because daemonSub->fallthrough() pushes a nested subcommand's unmatched arguments up to the top level, where they are rejected ("The following argument was not expected: show"). `module` is no longer an alias for the verbose call syntax -- it is the group. Use `call`. Also implements --detach, which was specified but missing. It re-execs rather than continuing in the forked child: macOS refuses to let a process that has already initialised CoreFoundation keep running after fork(), and the Qt/liblogos link pulls CoreFoundation in before main. The child redirects stdio to <configDir>/daemon/daemon.log -- without that the shell never sees EOF and `daemon start --detach` appears to hang -- and the parent returns only once state.json exists, so the next command cannot race the boot. Env vars and the default session directory rename to LOGOSCTL_* and ~/.logosctl. User-facing messages now name the group grammar rather than the internal tokens. Verified on the portable build: daemon start --detach returns in ~3s with a working daemon; catalog ls, search, install --dry-run, install, package ls, module ls/load/show, upgrade (no-op), and remove of a loaded module all behave. 18/18 CLI tests, unit tests unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: rewrite for logosctl, sessions and YAML config The README documented a flag surface that no longer exists (--persist-config, --module-transport, --modules-dir, the seven --client-* flags) and had no account of sessions or packages at all. Replaces the daemon/transport/persist-config sections with: what a session directory is and why it is portable, `daemon config set` and the YAML schema, and the package/catalog/keyring commands. Keeps the two hard-won warnings that are still true -- a remote daemon must expose capability_module as well as core_service, and plaintext tcp on a non-loopback host needs an explicit opt-in. Doctests are renamed and rewritten around sessions: the daemon spec no longer passes -m but seeds ./session/modules, and uses `daemon start --detach` instead of backgrounding with & (which returned before the transports bound and raced the first command). Also fixes the stats table: the MODULE column was a fixed 12 characters, so a real name like "test_basic_module" ran straight into the PID with no separator. Verified the rewritten daemon-doctest sequence by hand against a dev build: seed session, start --detach, module ls/load, call, stats, stop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * doctests: use --detach and the session log `daemon start --detach` already returns only once the daemon is accepting commands and sends its output to <session>/daemon/daemon.log, so the `sh -c '... > logs.txt 2>&1 &'` wrapper is not just redundant -- it hid the output the specs then tried to cat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat: ship logosctl alongside logoscore instead of replacing it logosctl is new and unvalidated; logoscore is what people depend on today. Replacing one with the other in a single step meant every consumer had to move at once, on trust. Shipping both means logosctl can be validated in real use first, and logoscore removed afterwards. Both binaries build from this repo over one shared runtime -- daemon, core_service, client, output. They differ only in main.cpp and a Config::Flavor the front-end sets, which selects the config directory, the env var consulted for an override, the config file names, and the format they are written in. The isolation is the point, so it is deliberate and tested: logoscore ~/.logoscore LOGOSCORE_CONFIG_DIR daemon/config.json logosctl ~/.logosctl LOGOSCTL_CONFIG_DIR daemon/config.yaml A logosctl session cannot disturb a logoscore deployment. Reading needs no branch -- YAML is a superset of JSON, so one parser handles both -- only writing differs. logoscore is behaviourally unchanged, which took two specific decisions: - The session directory and the package-module bootstrap are gated on the modern flavor. Auto-loading two extra modules would change what `status` and `list-modules` report, and logoscore's doc-tests assert those exact counts. - The bundled package modules live in modules-pkg/ rather than modules/, because logoscore scans the latter and would otherwise report two modules it never had. Verified: `logoscore --help` is the old flat surface with all four flag families intact; a logoscore daemon reports loaded:1 not_loaded:0 and creates no session directories; both daemons run at once with separate state. Its doc-tests are restored unchanged. logosctl gets its own, including a new logosctl-packages spec covering the capability that motivated the merge -- search, dry-run, install, load, remove -- verified end to end against the live catalog. 122/129 unit tests, 18/18 CLI tests. The 7 OutputTest failures are pre-existing on master. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * build: give logosctl its own flake outputs Building both binaries into one package meant `nix build` and `.#cli` started handing out logosctl too, which is the opposite of keeping the two apart while the new one is validated. Now each output ships exactly one binary: .#cli .#cli-bundle-dir .#cli-appimage -> logoscore .#ctl .#ctl-bundle-dir .#ctl-appimage -> logosctl .# (default) -> logoscore So anything already pointing at the default or at `.#cli` -- including every doc-test across the workspace that does `nix build github:logos-co/logos-logoscore-cli` -- keeps getting the tool it gets today, and logosctl is strictly opt-in. They still compile together, since they share everything but main.cpp; only the packaging is split. modules-pkg/ ships solely in the ctl outputs, because logoscore never scans it. logoscore's desktop entry and icon are restored, and logosctl gets its own. The logosctl doc-tests now build .#ctl / .#ctl-bundle-dir. Verified: every output builds and ships only its own binary; both portable bundles run and report the module set expected of each. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(config): let each session subdirectory be redirected The session directory being self-contained is what makes it portable, and that should stay the default -- but it was also the only option, which made reasonable setups impossible: sharing one keyring across sessions, putting the .lgx cache on a bigger disk, or pointing at a modules tree something else manages. A `dirs:` block now redirects any of them: dirs: keyring: ~/.config/logos/trusted-keys cache: /var/cache/logos modules: /opt/logos/modules plugins: plugins-custom data: /var/lib/logos/data The form of the value decides whether portability survives, which is the part worth knowing: plugins-custom -> <session>/plugins-custom still portable ~/x -> $HOME/x outside the session /var/cache/... -> as given outside the session `~` is handled because it is the natural thing to write in a config file and would otherwise resolve to <session>/~/... , which exists nowhere. Overrides resolve once, when set, so relocating a session afterwards cannot silently drag an absolute path along with it. Only the daemon applies them, and it does so before anything asks Config for a path. persistence_path is folded into dirs.data -- it was already the same setting under an older name -- so the two no longer need choosing between at the point of use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(daemon): rotating log file with configurable size and retention --detach used to dup2 stdout/stderr straight onto daemon/daemon.log, which grew without bound and had no rotation. A long-lived daemon needs better than that. There is now a logs/ directory, like Basecamp's, and a logging block: logging: enabled: true # false -> no log file at all file: daemon.log # inside dirs.logs max_size_mb: 10 # rotate past this; 0 = never rotate max_files: 5 # keep this many in total console: true # mirror to the terminal dirs.logs joins the overridable session directories, so logs can be shipped somewhere a collector already watches. Capture is pipe-based rather than a file redirect, and that is the load-bearing decision: module hosts are separate processes holding inherited descriptors. Redirecting to a file catches their output but makes rotation impossible -- renaming a file out from under a child that has it open just keeps filling the old inode. A pipe puts one reader in charge, so rotation is safe and subprocess output still lands in the log. Same shape as basecamp's LogRedirector, which solved this already. The size cap and retention come from spdlog's rotating sink rather than being hand-rolled; liblogos already logs through spdlog. Lines arriving from the pipe already carry their own timestamp and level, so the sink uses a raw pattern instead of stamping them twice. Two bugs found while testing it: - Draining raced shutdown. stop() cleared the running flag before restoring the descriptors, so a reader holding data would process it, loop, see the flag clear and exit -- dropping whatever was still in the pipe. The last lines before a shutdown are exactly the ones worth keeping. EOF is now the only stop condition. - --detach reported the wrong path. The parent prints before the child has read the config, so it guessed the default and lied to anyone who had redirected dirs.logs or renamed the file. It now reads the same config the child will. Verified live: default, disabled, and redirected-with-custom-filename all behave and are reported accurately. Four unit tests cover capture of both streams, no double-stamping, rotation with retention, and disabled-is-not-an-error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(daemon): timestamp log files, and bound the directory Adopts basecamp's naming -- each start writes its own daemon_<yyyymmdd_HHMMSS>.log -- so a session's output is one file you can point at, instead of every run appending into the same daemon.log. Two things beyond copying basecamp: - `logging.file` survives as a symlink to whichever file is current, so `tail -F logs/daemon.log` follows across restarts and nobody has to work out a stamp. It also means --detach can report a path that is always valid; previously it had to guess one, and guessed wrong for anyone who had redirected dirs.logs. - max_files now bounds the *directory*, pruning oldest-first at each start. spdlog's retention only prunes within one sink's rotation set, and every start opens a new stamped base name, so without this a daemon restarted a hundred times would leave a hundred logs behind. basecamp has exactly that problem. Verified live: three restarts leave three stamped files with the symlink tracking the newest; five restarts with max_files: 2 leave two. Two new tests cover the naming and the symlink resolving to the current session, and the cross-session pruning. The rotation test needed fixing too -- it counted the symlink as a log file, which predated the symlink existing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore: ignore suffixed nix out-links .gitignore listed `result` but not `result-*`, so every out-link from a targeted build -- `nix build '.#ctl' -o result-ctl`, `-o result-tests`, and so on -- was untracked-but-not-ignored, and `git add -A` committed them as symlinks into /nix/store. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ci: keep releasing logoscore, and release logosctl beside it The earlier rename left the release workflow building the `cli-*` outputs -- which are logoscore -- while naming every artifact `logosctl-*`. A release/** push would have shipped logoscore binaries under the wrong name, and stopped releasing logoscore under its own. Both are now built and published as separate, correctly-named assets: logoscore-{x86_64,aarch64}-linux.tar.gz from .#cli-appimage logoscore-aarch64-macos.tar.gz from .#cli-bundle-dir logosctl-{x86_64,aarch64}-linux.tar.gz from .#ctl-appimage logosctl-aarch64-macos.tar.gz from .#ctl-bundle-dir logoscore's asset names are exactly what they were, which matters: release sets fetch this repo and expect a bundle containing `bin/logoscore`. Each tool builds from its own flake outputs, so an asset labelled logoscore contains logoscore and nothing else. Both jobs gained a tool matrix with fail-fast disabled, so a failure in the under-validation logosctl cannot block a logoscore release. The release job now collects artifacts by pattern instead of naming each one, so retiring logoscore later means deleting a matrix entry rather than unpicking a download list. Release notes lead with logoscore as the tool to use, and say the two share no state so installing logosctl cannot disturb an existing setup. The doc-tests workflow globs doctests/*.test.yaml, which now covers both suites, so it is no longer named after one of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: run both tools' suites, in parallel While both binaries ship, both get tested. logoscore had no automated coverage on this branch at all -- only its doc-tests -- so a change to the shared runtime could regress the tool people actually use and nothing would say so. tests/test_cli_logoscore.cpp and tests/test_integration_logoscore.cpp are copies of the suites frozen against logoscore's surface. Copies rather than a parameterised shared suite on purpose: the two surfaces genuinely differ, and this way retiring logoscore is a delete rather than an unpick. checks.tests-logosctl and checks.tests-logoscore are separate derivations, so nix builds them concurrently; checks.tests aggregates both, keeping `nix build .#checks.<sys>.tests` working for CI while now covering both tools. It immediately earned its keep, catching three regressions: - The integration harness still passed -m, which logosctl no longer accepts, so its daemon never started and seven integration tests were failing on this branch. It now writes the modules_dirs config the daemon reads. - `logoscore --version` reported "logosctl version ...". The version banner had been renamed wholesale; each front-end now names itself. Exactly the sort of thing nobody notices until a bug report cites the wrong tool. - The new log sink only mirrored to the console when stdout was a TTY, so `logoscore -D > logs.txt` -- which the doc-tests do -- produced an empty file. Mirroring now follows the configured setting, pipe or terminal alike, and the log file is gated to logosctl so logoscore's output behaviour is untouched. Both suites green: logosctl 138 unit + 18 CLI + 18 integration, logoscore 20 CLI + 18 integration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(package): honour -o, and stop parsing command lines backwards `package download -o DIR` accepted the flag and threw it away -- the argument was parsed into a variable and then explicitly discarded with `(void)outDir;`. The file went to $TMPDIR regardless. The config's `dirs.cache` had the same problem from the other end: the directory was created and documented as holding downloads, and nothing ever wrote to it. The cause was the same for both. package_downloader takes no destination, so the file lands in $TMPDIR on the DAEMON's filesystem -- which is where the move has to happen too. Doing it client-side would work only for a local daemon. So `downloadPackage` joins the daemon-side package operations: it downloads, then moves the result into the requested directory, or into the session's cache/downloads when no -o was given. The client resolves a relative -o against its own working directory first, so a local daemon does what the user typed; against a remote one the path is remote, and a bad one fails loudly rather than quietly writing elsewhere. Writing the first test for it turned up something worse. CLI11's `parse(std::vector<std::string>&)` consumes the vector from the BACK -- only the rvalue overload reverses for you -- so passing natural order parses the command line backwards. `watch` and `issue-token` did reverse first; nothing else did. It goes unnoticed with one positional and flags (order does not matter), and is quietly wrong the moment an option takes a value, because the option pairs with the token to its LEFT: package download pkg -o dir -> name="dir", output="pkg" package install a b --version 1.0 -> names=["1.0","b"], version="a" So `package install`, `search --category`, and `download -o` all misparsed. Every site now goes through one `parseArgs` helper that reverses, which fixes the broken ones, is a no-op for the harmless ones, and removes the trap for the next command. PackageCommand had no unit tests at all, which is why a discarded flag survived review. Four now cover download; the two asserting -o reaches the daemon fail against the old code. 142 unit + 18 CLI + 18 integration green for logosctl, 20 + 18 for logoscore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: one README about the repo, one document per tool The README had grown into a logosctl manual with a banner on top telling logoscore users that everything below did not apply to them, and pointing them at doc-test YAML for their actual documentation. Since logoscore is still the tool to use, its documentation should not be the thing you are told to skip. So: README.md covers what is true of both -- what the repo is, the two binaries and how they differ, the flake outputs, the test targets, dependency resolution, platforms -- and hands off to one document per tool. docs/logoscore.md the usage material, unchanged, as its own document docs/logosctl.md sessions, config, logs, packages, examples Writing logosctl's own document exposed a gap: it had no command reference at all. The rewrite dropped the client-command list, argument typing and exit codes, and left behind a "see Argument typing below" pointing at a section that no longer existed. All three are back, with the command list written against the grammar that is actually implemented (checked against normalizeGroupVerbs and the subcommand dispatch, not from memory), plus the two defaults worth stating up front -- install does not load, remove takes dependents. Also fixes stale copy that survived the earlier rewrite: `load-module` where logosctl says `module load`, and a "multiple module directories" caption over a --config-dir example, from a flag logosctl does not have. Deleting logoscore later is now deleting one file and a table row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(daemon): make TLS configurable again, and say why startup failed Three bugs, all found by running the doc-tests I had just rewritten instead of trusting them. **tcp_ssl could not be configured at all.** `transportFromJson` never read `cert` or `key`. That was harmless while those arrived via `--module-transport ...,cert=...,key=...`, parsed by the CLI mini-grammar -- but that grammar is gone, and the config file is now the only place to set them. So every tcp_ssl listener bound with no certificate: the daemon started, reported itself healthy, accepted connections, and failed every handshake with "no shared cipher (SSL routines)". The client just saw "core_service not reachable". The stripping was deliberate but applied one layer too high: cert and key have no business in state.json, which clients read, but the config file is where an operator *authors* them. `transportToJson` now takes `includeSecrets` -- true writing the config, false writing state.json. A test asserts the round-trip, and another asserts the key path never appears in state.json. **`--detach` swallowed the reason startup failed.** Config validation runs before LogSink opens the log, and the child's stderr went to /dev/null, so a rejected config produced "daemon exited during startup. See <path>/logs/daemon.log" -- naming a file that had never been created. The child's early output now goes to a startup file the parent reads and prints on failure, removed either way. LogSink takes those descriptors over as soon as it starts, so the file only ever holds pre-logging output. **The plaintext-TCP guard advertised a flag that does not exist.** It said "pass --insecure-tcp"; logosctl has no such flag. It now names the config key, `insecure_tcp: true`. Verified end to end against a real daemon: plaintext guard refuses and says why, loopback TCP binds and serves `status`/`module ls` from a separate client session, TLS serves the same over 6443/6444, and dropping the CA while keeping verify_peer still fails closed. 144 unit + 18 CLI + 18 integration green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(flake): give autoPatchelf the libraries both binaries now link Every Linux build failed: auto-patchelf could not satisfy dependency libyaml-cpp.so.0.8 wanted by .../bin/.logoscore-wrapped The packaging derivations listed only Qt in buildInputs, which is what autoPatchelfHook resolves DT_NEEDED entries against. yaml_json.cpp and the log sink are in the shared sources, so *both* binaries link yaml-cpp and spdlog -- including logoscore, which is why its Linux build broke too on a branch that was supposed to leave it alone. macOS does not patchelf, so this was invisible locally and in the macOS CI jobs; only the Linux matrix caught it, and it took down the AppImage builds, the CI job, and every Linux doc-test with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(doctests): bring the logosctl specs up to what logosctl does Nineteen doc-test steps were failing. None of them were runtime bugs in the specs' own right -- they were specs still describing an older logosctl, which is its own kind of failure: a doc-test that lies is worse than no doc-test. transports Still drove `--module-transport` and hand-written client/config.json. The flags had been dropped from the `run:` lines but no config step replaced them, so the daemon never bound TCP at all and every step after it failed. Rewritten around `daemon config set` / `client config set` with YAML documents, for both the plaintext and TLS halves. daemon Read the log at session/daemon/daemon.log; logs moved to session/logs/. The crash-recovery step passed `-m`, which logosctl does not accept, so its daemon never started and the step reported LEAKED against a worker that had never existed. modules-bundle Asserted all three modules in result/modules. The package modules live in modules-pkg/ so that logoscore's modules/ stays byte-identical -- which the spec is now the place that explains. packages Expected the interactive wording ("dry run", "Installed:"). Doc-tests are not a terminal, so every command renders JSON. The install was working the whole time; only the assertions were wrong. They now match the JSON, and the prose says why it is JSON. Rewriting the transports spec is what turned up the TLS and --detach bugs fixed in the previous commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(config): a typo must not abort the daemon, and a key must not lie Three defects in the YAML config path, all found by building a Python client against this CLI and checking its assumptions against the binary rather than the docs. **A config typo aborted the process.** printf 'version: 2\nmodules_dirs: /single/path\n' > bad.yaml logosctl --config-dir ./s daemon config set ./bad.yaml => libc++abi: terminating due to uncaught exception ... [json.exception.type_error.302] type must be array, but is string nlohmann's `json::value(key, default)` THROWS when the key is present with the wrong type, every config read used it, and nothing caught it. So it was not one key -- it was every key in both readers. A scalar where a list belongs is an ordinary mistake and it killed the binary. Now a type-checked reader (src/json_schema.h) records "<dotted.path>: expected <what>, but got <what>" and the document is refused whole, the same shape as the existing unknown-key error: {"code":"INVALID_CONFIG", "message":"modules_dirs: expected a list of strings, but got a string."} Both readers went through it, including two paths that could abort the daemon mid-boot rather than at `config set`. **`config set` validated after writing.** A schema-invalid document was installed and then reported as an error, leaving the session holding a config the daemon would refuse to boot from. Validation now happens entirely in memory first, on both the daemon and client sides -- the client side had no schema validation at all -- and the write is temp-file + rename instead of truncate-in-place. That exposed a fourth: `yaml_json::dump` emitted numeric-looking strings bare, so `port: "6001"` came back as the number 6001. The bytes validated were not the bytes written. **Two keys were accepted, stored, and never applied.** `signature_policy` sat on the allowlist and was written verbatim to config.yaml but was never even parsed. An operator setting `require` got no enforcement and no warning. It is now parsed with a strict allowlist and pushed into package_manager at boot beside setKeyringDirectory -- the module has had setSignaturePolicy all along. Unset issues no RPC, so the module keeps its own default instead of having it restated. The top-level `ssl: {cert, key, ca}` block was parsed into DaemonConfig and read by nobody; only per-listener cert/key reached the transport set. Configuring TLS the obvious way therefore produced listeners with no certificate and "no shared cipher" on every handshake -- the same failure fixed one layer down last commit. It is now a session-wide default that per-listener values override. Also: docs advertised `module load --no-deps`, which does not exist -- `module load` takes only a positional name and always resolves dependencies. Corrected, along with the rest of the command reference, verified against the binary. logoscore is untouched: 20 CLI + 18 integration, exactly as before. logosctl 171 unit (was 144) + 25 CLI (was 18) + 18 integration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(detach): re-exec the launcher, not the ELF it hides `daemon start --detach` was dead on Linux portable builds. The daemon exited immediately with status 127, no output, and no log file -- so the only diagnostic was "daemon exited during startup. See <path>", naming a file that had never been created. strace, on a real Linux box, said it in one line: execve(".../bin/.logosctl.elf", [...]) = -1 ENOENT exit_group(127) A portable bundle installs the CLI as a launcher script beside a hidden companion: bin/logosctl the launcher, a shell script bin/.logosctl.elf the real ELF The launcher exists because that ELF cannot be started on its own: its PT_INTERP names a dynamic loader that is not on the host, so the launcher runs it through a known-good ld.so instead. The ENOENT is the kernel reporting the missing *interpreter* -- the ELF is right there. --detach re-execs itself (it has to: macOS forbids running a forked process that has initialized CoreFoundation), and it re-exec'd executablePath(), which is that ELF. My first attempt preferred argv[0], reasoning that it is what the caller actually typed. That was wrong, and the trace showed it failing identically: the launcher execs ld.so with the ELF, ld.so drops itself from argv, and the program sees the ELF as argv[0] too. Neither source of truth names the launcher. So the mapping is applied to whatever candidate we end up with, using the convention the launcher script itself documents -- the install dir is the one holding the hidden companion `.$BASE.elf`. `bin/.logosctl.elf` maps back to `bin/logosctl`. argv[0] is still preferred over executablePath() (it is what was invoked, and it is right when a bare name resolves through PATH), and it is absolutised, since the daemon may run from a different directory. Only this combination was ever broken: portable AND Linux AND --detach. macOS bundles a real binary with qt.conf and no launcher, Linux dev builds are ordinary ELFs, and the foreground -D path never re-execs. The one doc-test that uses the portable bundle is the packages spec, and cachix served a permanent 522 for one of its store paths from the day it was written -- so its 14 cascading failures read as infrastructure until the cache recovered and the real failure surfaced underneath. Verified on Linux against the same bundle that failed: daemon starts detached, all three bundled modules load, `daemon stop` returns ok. Also here, and what made the diagnosis possible: --detach now prints the TAIL of the daemon log rather than its path. The startup file only holds output from before LogSink takes the descriptors, so a daemon that dies after logging is up left it empty and the reason unread. That there was no log at all is what pointed at exec. 179 unit tests (8 new, covering the launcher mapping and its edges: no sibling, an ordinary foo.elf, a non-executable candidate, absent argv[0]). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * build: bundle logosctl/logoscore as headless Qt programs * build: bump nix-bundle-dir and nix-bundle-appimage to main Picks up the merged trampoline drop: per-arch psABI PT_INTERP, DT_RPATH, qtCliApp for headless Qt, and the AppImage consumer that already tracks the same pin. nix-bundle-dir 4fd87d1 (PR tip) → cb9afc8; appimage 8fcc56b → 04a3cf8. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
a1437275ea |
fix(call): integer arguments are 64-bit, in both signednesses (#74)
* fix(call): integer arguments are 64-bit, in both signednesses
`logoscore call m echoUint 9007199254740993` came back 9007199254740992.
Argument coercion used std::stoi — 32 bits. Every integer outside int32 threw
out_of_range, was swallowed by the catch, and fell through to std::stod, so it
crossed the wire as a DOUBLE. Below 2^53 a double is exact and the value looks
right, which is why this survived: the largest integer any existing test passes
is 42, and the largest uint is 7. Above 2^53 the value is silently rounded.
This is not a CLI-only path. logoscore-py shells out to this binary, so the
whole Python suite, the docker codec matrix and every doctest that calls a
method with a large integer were affected too — LIDL `int`/`uint` are int64_t /
uint64_t everywhere else in the system.
stoll covers int64, including INT64_MIN;
stoull covers the band above int64max, tried ONLY for a non-negative literal
because stoull("-1") wraps to 18446744073709551615 rather than failing;
beyond uint64 a numeric literal still goes as a double, unchanged.
Human-mode output had the mirror bug: a `uint` return above int64max was read
back with get<int64_t>(), printing uint64max as -1. JSON mode always dumped the
raw number and was fine.
Verified against a live provider — the full range now round-trips through
CLI -> daemon -> module and back, which also shows the wire and the providers
were always correct and the CLI was the only lossy link:
echoUint 18446744073709551615 -> 18446744073709551615
echoInt -9223372036854775808 -> -9223372036854775808
echoInt 9007199254740993 -> 9007199254740993
Three unit tests pin the range, the unsigned band, and that -1 stays signed.
The conformance matrix (logos-test-modules/conformance) reds 19 cells against
the pre-fix binary and is green against this one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore: bump logos-protocol and logos-cpp-sdk
logos-protocol 6401e30 -> 362b03f one canonical LIDL <-> JSON codec (#29)
logos-cpp-sdk c3fa1b5 -> 3d322bd 64-bit int/uint, records, cdylib
composites (#111, #113)
The protocol bump fixes bytes-at-depth and 64-bit integers nested in a
container; it also makes the Rust provider validate its declared types
strictly, so an argument the host used to coerce (-1 for a uint, 3.7 for an
int, a scalar for [any]) is now rejected as dispatch_failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore: bump logos-liblogos to the fixed protocol pin
liblogos is the module host, and consumers deliberately do NOT follows its
protocol — the comment above this flake's inputs says so: 'liblogos's own SDK
pin still drives transitive deps'. So bumping the top-level protocol here did
NOT reach the host path; liblogos had to land its own pin first (#167, after
#166 merged without actually landing it).
logos-liblogos be221c5 -> 5c8b9f0, whose protocol is now 362b03f
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
733ca452eb |
feat(call): pass lists and maps with json: / str: argument prefixes (#67)
* feat(call): pass lists and maps with json: / str: argument prefixes `logoscore call` coerced each positional arg to a scalar (bool/int/double/ string) or read a file via @file, so there was no way to pass a list, map, or any nested JSON value — callers had to route through a bespoke provider method (e.g. the fullapi proxy's probeArrays). Add two explicit, opt-in prefixes, following the two-form convention used by jq (--arg / --argjson) and HTTPie (= / :=) rather than sniffing whether a value "looks like" JSON: - json:<value> parses <value> as JSON (list / map / nested value) - json:@<file> parses the file's contents as JSON - str:<text> forces a literal string, no parsing/coercion @file stays raw-string (so JSON-config-as-a-string params are unaffected) and scalar coercion is unchanged. str: is the symmetric escape that makes every string expressible — including a literal starting with json:/str:/@, a number-like string such as "42", or an empty string (str:). Docs: README "Argument typing" table. Test: the fullapi-chain doctest gains a direct list/map/literal step against both the C++ and Rust providers, and the now-stale "logoscore can't pass a list literal" note on probeArrays is fixed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test: cover json:/str: args, and distinguish empty vs unreadable @file Addresses review feedback on the json:/str: argument prefixes: - Add 10 CallCommand unit tests in test_commands.cpp: json:[...] → list (integers preserved, not degraded to double), json:{...} → map, json: nested/scalar values, json:@file reads+parses, malformed json: → INVALID_ARGS (RPC never dialed), str: forces a literal string past json:/number/bool/@ coercion, str: expressing an empty string, plus the empty-file and missing-file @file cases. - resolveFileParam now returns std::optional<std::string>, nullopt ONLY when an @file can't be opened. A readable-but-empty file yields an empty string instead of erroring, so @emptyfile sends "" (matching the documented raw-contents behaviour) rather than "Failed to read file". This also closes a small pre-existing gap: an empty string is now expressible via @emptyfile (and via str:). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: show how to pass a bstr argument via the {"_bytes":...} encoding A bstr has no native JSON representation, so bytes travel as the canonical tagged object {"_bytes":"<base64url, unpadded>"}. It needs no CLI change — it's just a json: object — so document it in the README argument-typing section and add a fullapi-chain doctest step that round-trips base64url("hello") through the C++ provider's echoBytes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
2496b25d7c |
fix: require full-string match when coercing numeric call arguments (#40)
* fix: require full-string match when coercing numeric call arguments std::stoi/std::stod parse a leading prefix and do not require the whole string to be consumed, so "1.25" was classified as int 1 (and "2.75" as 2). The old QString::toInt(&ok)/toDouble(&ok) rejected such partial matches. Verify the parse position reached the end of the string before accepting the value as int/double. Fixes addDoubles(1.25, 2.75) returning 3 instead of 4 (the logos-logoscore-py integration test). Applied in both the daemon `call` path (call_command.cpp) and the inline `-c` path (call_executor.cpp). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: cover numeric arg coercion; trim whitespace before full-match check Addresses review feedback on the arg-coercion fix: - Add CommandTest cases asserting `call` coerces arguments to native JSON types — decimals → double (regression guard so "1.25" is not truncated to int 1), integers → int, mixed string/double/bool/int, and the whitespace/`@file`-newline case. - The `pos == size()` full-consumption check made coercion sensitive to surrounding whitespace, so a numeric `@file` param ending in "\n" (e.g. "123\n") regressed from number → string. Trim a copy before the check in both the daemon `call` and inline `-c` paths; the parsed numeric value is still pushed, strings keep their original content, and partial parses like "1.25" are still rejected as int. Adds strutil::trim. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c410a01091 |
replace QJson* with nlohmann/json
replace QJson* with nlohmann/json
update flake
ci fix
update flake
update flake
fix include path to use SDK's include/cpp where logos_api_client.h actually lives
The combined SDK symlinkJoin package places headers at include/cpp/ (from
the headers sub-package), not at include/ root. Using include/cpp ensures
the compiler finds the SDK's logos_api_client.h (with nlohmann::json overloads)
before the stale copy shipped inside logos-liblogos's include directory.
Co-authored-by: Cursor <cursoragent@cursor.com>
fix watch command args order for CLI11 parse
CLI11's parse(vector<string>) processes from the back of the vector, so
args must be reversed before calling parse(). The
|
||
|
|
9ee8fda370 |
replace QString
replace QString ci fix ci fix ci fix |
||
|
|
93ec7fa568 |
support non-local remote transports (#22)
* support non-local remote transports * fix LogosResult * update READMEs * use explicit transport only for core_service * fix tests * fix argument parsing * pr comments * allow transport set configuration on any module * pr comments * simplify flake.nix * split config and state files * pr comments * pr comments * pr comments * fixes * fix flake.nix |
||
|
|
c24e58eb8d | fix positional argument parsing for watch command (#21) | ||
|
|
17d181616a | initial code for daemon mode |