Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 30 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ concurrency:

env:
CMAKE_BUILD_PARALLEL_LEVEL: 8
LD_LIBRARY_PATH: ${{ github.workspace }}/_stage/aisuite/lib:${{ github.workspace }}/_stage/snodec/lib
LDFLAGS: -Wl,-rpath-link,${{ github.workspace }}/_stage/aisuite/lib -Wl,-rpath-link,${{ github.workspace }}/_stage/snodec/lib

jobs:
gcc-15-debug:
runs-on: ubuntu-24.04
container: gcc:15.3.0-trixie
container:
image: gcc:15.3.0-trixie
options: --cap-add=NET_ADMIN --sysctl net.ipv6.conf.all.disable_ipv6=0
timeout-minutes: 45

steps:
Expand All @@ -32,25 +32,26 @@ jobs:
${{ github.event_name == 'pull_request' &&
github.event.pull_request.head.sha || github.sha }}

- name: Check out SNode.C master
- name: Check out pinned SNode.C
uses: actions/checkout@v5
with:
repository: SNodeC/snode.c
ref: master
ref: 212bd4f8a16637405ab850a52adb9da8555dca3d
path: _deps/snodec

- name: Check out canonical AISuite Codex
- name: Check out paired AISuite Codex
uses: actions/checkout@v5
with:
repository: SNodeC/AISuite
ref: master
ref: 60c81c7f1fb7938e03725cdea4a87e617efae57e
path: _deps/aisuite

- name: Install build dependencies
run: |
apt-get update
apt-get install -y \
cmake \
iproute2 \
libbluetooth-dev \
libmagic-dev \
libssl-dev \
Expand All @@ -61,6 +62,15 @@ jobs:
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git config --global --add safe.directory "$GITHUB_WORKSPACE/_deps/snodec"
git config --global --add safe.directory "$GITHUB_WORKSPACE/_deps/aisuite"
getent group snodec >/dev/null || groupadd --system snodec

- name: Enable IPv6 transport coverage
run: |
ip link add codexui-ci-v6 type dummy
ip link set codexui-ci-v6 up
ip -6 address add fd00::1/128 dev codexui-ci-v6
ip -6 address show dev codexui-ci-v6
getent ahostsv6 ::1

- name: Verify CodexUI source revision
shell: bash
Expand All @@ -82,15 +92,21 @@ jobs:
cmake --build _build/snodec --target all
cmake --install _build/snodec

- name: Build and install canonical AISuite Codex
- name: Build, test, and install paired AISuite Codex
run: |
cmake -S _deps/aisuite -B _build/aisuite -G Ninja \
-DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_INSTALL_PREFIX="$GITHUB_WORKSPACE/_stage/aisuite" \
-DCMAKE_PREFIX_PATH="$GITHUB_WORKSPACE/_stage/snodec" \
-DAISUITE_BUILD_CODEX_TESTS=OFF \
-DAISUITE_BUILD_CODEX_TESTS=ON \
-DAISUITE_BUILD_APPS=ON
cmake --build _build/aisuite --target all --parallel 2
install -d -m 0770 -g snodec \
/etc/snode.c \
/var/log/snode.c \
/var/run/snode.c
LD_LIBRARY_PATH="$GITHUB_WORKSPACE/_stage/snodec/lib" \
ctest --test-dir _build/aisuite --output-on-failure
cmake --install _build/aisuite

- name: Configure CodexUI
Expand All @@ -102,6 +118,11 @@ jobs:
- name: Build CodexUI
run: cmake --build build --target all

- name: Test CodexUI
run: |
LD_LIBRARY_PATH="$GITHUB_WORKSPACE/_stage/aisuite/lib:$GITHUB_WORKSPACE/_stage/snodec/lib" \
ctest --test-dir build --output-on-failure

- name: Check changed lines
shell: bash
env:
Expand Down
22 changes: 19 additions & 3 deletions docs/codex-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,17 @@ generated experimental feature types and typed list/enablement operations
available through the frontend proxy SDK. CodexUI does not perform a second
provider initialization.

Without endpoint configuration, CodexUI selects AISuite's shared per-user
runtime path (`XDG_RUNTIME_DIR` when private, otherwise
`/tmp/codex-bridge-<uid>/codex-bridge.sock`), so it discovers a default
`codex-bridge` instance without a configuration file.

Bridge provider lifecycle is normalized as `connection.provider` with an
independent provider generation. Disconnect or generation change completes all
outstanding UI operations exactly once, clears provider-scoped presentation
state, and rehydrates the selected thread after the new provider reports
`ready`. Late results from a retired generation are ignored.

## 4. Inter-Thread Socketpair

One unnamed full-duplex Unix socketpair is the only cross-thread transport:
Expand All @@ -132,6 +143,10 @@ The implementation uses:
- bounded socket and application write queues;
- exclusive endpoint ownership and deterministic close behavior.

The Qt endpoint retains queued output as independently owned chunks, releases
each consumed chunk immediately, and limits read and write work per notifier
activation. Both endpoints treat framing or dispatch failure as terminal.

The socket buffers are both the bounded queues and the readiness mechanism. No
parallel in-memory queue, condition variable, eventfd, or other wakeup
descriptor is added.
Expand Down Expand Up @@ -215,7 +230,7 @@ The v1 command catalog used by the application is:

| Action | Result | Meaning |
| --- | --- | --- |
| `runtime.shutdown` | no | Orderly SNode.C runtime shutdown |
| `runtime.shutdown` | yes | Acknowledge and drain, then stop the SNode.C runtime |
| `connection.connect` | no | Connect the selected configured frontend transport |
| `connection.disconnect` | no | Explicitly disconnect the selected frontend transport |
| `connection.reconnect` | no | Explicit bridge transport reconnect |
Expand Down Expand Up @@ -270,8 +285,9 @@ Results preserve their originating `action` and `correlationId`. The currently
reduced result payloads are:

- `threads.list`: `threads`, `nextCursor`, and `backwardsCursor`, with `merge`;
- `thread.read`: returned `thread`, with `merge` because the current app-server
read projection can omit live-only Plan, Agent, command, and Changes detail;
- `thread.read`: returned `thread`, with `replace` when no newer presentation
event arrived after the read began, otherwise `merge` so a late snapshot
cannot erase newer live Plan, Agent, command, or Changes detail;
- `thread.create`, `thread.resume`, and `thread.fork`: returned `thread`, with
`merge`;
- `thread.rename`, `thread.archive`, `thread.unarchive`, and `thread.delete`:
Expand Down
3 changes: 3 additions & 0 deletions docs/ui-behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ content above or below it changed size. Protocol updates that do not change a
card's visible projection do not rebuild that card. Multiple visible card
changes from one refresh are applied as one paint-suppressed layout transaction
with one anchor restoration, including streaming Command execution updates.
Incoming deltas are coalesced to at most one reconcile per display interval;
growing text and Command execution output are appended in place instead of
being recopied and rebuilt for every delta.
New authoritative cards are inserted at their server-ordered position without
reconstructing retained cards. While following is paused, the effective history
window expands with incoming cards so its visible anchor is not evicted; the
Expand Down
91 changes: 76 additions & 15 deletions src/codex/ClientRuntime.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
#include <ai/openai/codex/frontend/client/WebSocketClient.h>
#endif
#include <ai/openai/codex/protocol/JsonLineFramer.h>
#include <ai/openai/codex/protocol/RuntimePaths.h>
#include <core/EventReceiver.h>
#include <core/SNodeC.h>
#include <core/socket/State.h>
#include <core/timer/Timer.h>
#include <net/in/stream/legacy/SocketClient.h>
#include <net/in6/stream/legacy/SocketClient.h>
#include <net/un/stream/legacy/SocketClient.h>
Expand All @@ -36,6 +38,7 @@
#include <algorithm>
#include <array>
#include <cerrno>
#include <chrono>
#include <cstddef>
#include <functional>
#include <memory>
Expand Down Expand Up @@ -66,13 +69,15 @@ void dispatchRequest(codex::frontend::CodexBridge &sdk,
const nlohmann::json &parameters, std::string action,
std::string correlationId,
ProtocolNormalizer &normalizer) {
const std::uint64_t startedAtSequence = normalizer.sequence();
sdk.request<Operation>(
typename Operation::Params{parameters},
[action = std::move(action), correlationId = std::move(correlationId),
context = parameters,
context = parameters, startedAtSequence,
&normalizer](typename Operation::Response &response) mutable {
normalizer.operationResult(std::move(action), std::move(correlationId),
std::move(context), response.getRaw());
std::move(context), response.getRaw(),
startedAtSequence);
});
}

Expand Down Expand Up @@ -109,6 +114,10 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration,

std::function<void()> requestReconnect;
std::function<void()> requestShutdown;
normalizer.setDeliveryFailureHandler([&requestShutdown] {
if (requestShutdown)
requestShutdown();
});

std::string expectedDisconnectReason;
bool desiredConnected = connectBridge;
Expand Down Expand Up @@ -165,7 +174,8 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration,
net::un::stream::legacy::SocketClient<StreamFactory,
client::ClientConnection &, std::size_t>
unixClient("codex-ui-unix", connection, std::size_t(maximumFrameBytes));
unixClient.getConfig()->Remote::setSunPath("/tmp/codex-bridge.sock");
unixClient.getConfig()->Remote::setSunPath(
codex::protocol::defaultFrontendSocketPath());
configureStreamClient(unixClient, false);

net::in::stream::legacy::SocketClient<StreamFactory,
Expand Down Expand Up @@ -260,10 +270,14 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration,
std::string selectedTransportLabel;
bool transitionPending = false;
bool shutdownRequested = false;
bool shutdownDraining = false;
bool eventLoopRunning = false;
std::function<void()> continueTransition;
std::function<bool()> terminatingFlowTerminated;
std::function<void()> pendingSelection;
std::chrono::steady_clock::time_point transitionDeadline;
std::chrono::steady_clock::time_point shutdownDrainDeadline;
std::function<void()> finishShutdownAfterDrain;

const auto selectClient = [&](auto &configuredClient, std::string transport,
std::string label) {
Expand Down Expand Up @@ -366,7 +380,13 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration,
return;
if ((terminatingFlowTerminated && !terminatingFlowTerminated()) ||
connection.attached()) {
core::EventReceiver::atNextTick(continueTransition);
if (std::chrono::steady_clock::now() >= transitionDeadline) {
normalizer.transportEvent("failure", "connection transition timed out");
requestShutdown();
return;
}
static_cast<void>(core::timer::Timer::singleshotTimer(
continueTransition, utils::Timeval({0, 10000})));
return;
}
transitionPending = false;
Expand Down Expand Up @@ -403,11 +423,14 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration,
return;
}
transitionPending = true;
transitionDeadline =
std::chrono::steady_clock::now() + std::chrono::seconds(5);
expectedDisconnectReason =
connection.attached() ? std::move(disconnectReason) : std::string{};
connection.disconnect("CodexUI connection transition");
terminateSelected();
core::EventReceiver::atNextTick(continueTransition);
static_cast<void>(core::timer::Timer::singleshotTimer(
continueTransition, utils::Timeval({0, 10000})));
};

requestReconnect = [&] { beginTransition(true, {}, "local-user-reconnect"); };
Expand Down Expand Up @@ -437,10 +460,24 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration,
connection.shutdown();
if (terminateSelected)
terminateSelected();
if (ipcEndpoint)
ipcEndpoint->close();
if (eventLoopRunning)
core::SNodeC::stop();
};

finishShutdownAfterDrain = [&] {
if (shutdownRequested)
return;
if (ipcEndpoint && ipcEndpoint->queuedBytes() != 0 &&
std::chrono::steady_clock::now() < shutdownDrainDeadline) {
static_cast<void>(core::timer::Timer::singleshotTimer(
finishShutdownAfterDrain, utils::Timeval({0, 10000})));
return;
}
requestShutdown();
};

const auto dispatchCommand = [&](nlohmann::json command) {
if (!presentation::isPresentationFrame(command) ||
presentation::stringMember(command, "kind") != "command") {
Expand All @@ -455,8 +492,21 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration,
const nlohmann::json parameters =
presentation::member(command, "data", nlohmann::json::object());

if (!parameters.is_object()) {
normalizer.operationRejected(action, correlationId, -32602,
"presentation command data must be an object");
return;
}

if (action == "runtime.shutdown") {
requestShutdown();
if (!shutdownDraining) {
shutdownDraining = true;
normalizer.localOperationResult(action, correlationId, true,
nlohmann::json::object());
shutdownDrainDeadline =
std::chrono::steady_clock::now() + std::chrono::milliseconds(500);
finishShutdownAfterDrain();
}
return;
}
if (action == "connection.reconnect") {
Expand Down Expand Up @@ -648,8 +698,8 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration,
if (parameters.contains("error"))
response["error"] = parameters["error"];
else
response["result"] =
parameters.value("result", nlohmann::json::object());
response["result"] = presentation::member(
parameters, "result", nlohmann::json::object());
if (requestId.is_null() || !sdk.sendRawJson(response))
normalizer.transportEvent("failure",
"server-request response was rejected");
Expand Down Expand Up @@ -854,14 +904,25 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration,
};

ipcEndpoint->setOnData([&](const char *data, std::size_t size) {
const bool accepted = ipcFramer.consume(
std::string_view(data, size), dispatchCommand,
[&normalizer, &requestShutdown](std::string message) {
normalizer.transportEvent("failure", std::move(message));
requestShutdown();
});
if (!accepted)
try {
const bool accepted = ipcFramer.consume(
std::string_view(data, size), dispatchCommand,
[&normalizer, &requestShutdown](std::string message) {
normalizer.transportEvent("failure", std::move(message));
requestShutdown();
});
if (!accepted)
requestShutdown();
} catch (const std::exception &exception) {
normalizer.transportEvent(
"failure", std::string("presentation command dispatch failed: ") +
exception.what());
requestShutdown();
} catch (...) {
normalizer.transportEvent("failure",
"presentation command dispatch failed");
requestShutdown();
}
});
ipcEndpoint->setOnError([&normalizer, &requestShutdown](int errorNumber) {
normalizer.transportEvent("failure", std::string("socketpair failure: ") +
Expand Down
Loading