Skip to content
Open
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
17 changes: 17 additions & 0 deletions .github/workflows/container.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,23 @@ jobs:
platforms: linux/amd64
provenance: mode=max
sbom: true
- uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5
id: logger-metadata
with:
images: ghcr.io/${{ github.repository_owner }}/multiagent-logger
tags: |
type=raw,value=main,enable={{is_default_branch}}
type=sha,prefix=sha-
- uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
context: .
file: docker/logger/Dockerfile
push: true
tags: ${{ steps.logger-metadata.outputs.tags }}
labels: ${{ steps.logger-metadata.outputs.labels }}
platforms: linux/amd64
provenance: mode=max
sbom: true

publish-wiki:
runs-on: ubuntu-latest
Expand Down
7 changes: 6 additions & 1 deletion control-server/src/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -931,10 +931,15 @@ function traceExportStatus() {
lastAttemptAt: value.lastAttemptAt,
lastSuccessAt: value.lastSuccessAt || null,
fileCount: Number(value.fileCount || 0),
uploadedCount: Number(value.uploadedCount || 0),
loggerOk: value.loggerOk === true,
loggerPending: Number(value.loggerPending || 0),
loggerLastSuccessAt: value.loggerLastSuccessAt || null,
loggerError: value.loggerError || null,
ageSeconds: Number.isFinite(ageSeconds) ? Math.floor(ageSeconds) : null,
};
} catch {
return { configured: true, ready: false, ok: false, lastAttemptAt: null, lastSuccessAt: null, fileCount: 0, ageSeconds: null };
return { configured: true, ready: false, ok: false, lastAttemptAt: null, lastSuccessAt: null, fileCount: 0, uploadedCount: 0, loggerOk: false, loggerPending: 0, loggerLastSuccessAt: null, loggerError: null, ageSeconds: null };
}
}

Expand Down
3 changes: 2 additions & 1 deletion docker/runtime/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ RUN cd control-server && npm ci --omit=dev
COPY . .
COPY --from=multiagent-builder /src/target/release/multiagent /opt/multiagent/bin/multiagent
RUN ln -s /opt/multiagent/wiki-service/bin/wiki-query.mjs /usr/local/bin/wiki-query \
&& ln -s /opt/multiagent/logger/trace_commitment.py /usr/local/bin/trace-commitment \
&& install -m 0755 docker/runtime/container-entrypoint.sh /opt/multiagent/bin/container-entrypoint.sh \
&& install -m 0755 control-server/bin/prepare-repository.mjs /opt/multiagent/bin/prepare-repository.mjs \
&& chmod +x launch.sh control-server/bin/*.mjs \
&& chmod +x launch.sh control-server/bin/*.mjs logger/trace_commitment.py \
&& groupadd --gid 10000 multiagent-control \
&& groupadd --gid 10001 multiagent-role \
&& groupadd --gid 10004 multiagent-credentials \
Expand Down
7 changes: 5 additions & 2 deletions docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,11 @@ applicable, and relevant evidence are complete.
restart tests for newline-aligned tail truncation and missing checkpoints.
This does not require producer-assigned sequence numbers; the Logger remains
the sole sequencer.
- [ ] Integrate producer outboxes or a deployment-owned durable queue so Logger
delivery retries independently and backlog alerts are testable.
- [ ] Deploy and prove the metadata-only S3 trace commitment outbox so delivery
retries independently across exporter restarts, backlog is observable, and a
production Logger outage/recovery drains without duplicate ledger entries.
Additional structural-event producers must adopt the same durable delivery
property before they are enabled in production.
- [ ] Add deployment-owned Loki/OpenTelemetry projections if operational demand
justifies them; these must remain derived from the authoritative ledger.

Expand Down
6 changes: 6 additions & 0 deletions gitops/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ The application-owned Logger deployment contract is:
- give producers a durable retry path or outbox and alert on delivery backlog;
- keep the existing trace sidecar and S3 data path, then submit a bounded
`trace.artifact_exported` commitment after a successful upload;
- keep the existing exporter responsible for trace staging, delta detection,
retry, and S3 sync. After a successful sync, run `trace-commitment` with the
stable staged tree and a trace-commitment-only token. It writes the commitment
manifest, event outbox, and delivered marker as separate metadata objects in
the same existing S3 location, and reports `loggerPending`, `loggerOk`, and
delivery timestamps without changing S3 export `ok` or workflow progression;
- configure at most one active Logger replica for a ledger volume. A
standby must not write until deployment fencing has transferred ownership.

Expand Down
19 changes: 19 additions & 0 deletions logger/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,25 @@ cargo run -p multiagent-logger -- submit-trace-commitment \
--media-type application/gzip
```

The helper emits `trace.artifact_exported`. Its payload contains only the
artifact digest, byte size, media type, and storage reference; it never sends
the trace body. Use a deterministic event ID and retain the event in a durable
outbox until the Logger returns `204`, because acknowledgement remains
idempotent transport evidence rather than workflow authority.

For the production sidecar, `trace_commitment.py` complements that low-level
CLI with durable S3 outbox delivery. The existing trace exporter remains
responsible for staging, delta detection, retry, and uploading trace bodies.
After a successful sync, `trace-commitment` hashes the stable staged tree,
writes a deterministic commitment manifest as a separate metadata object in
the same S3 location, and retries a bounded `trace.artifact_exported` event.

Required environment is `TRACE_COMMITMENT_SOURCE`, `TRACE_EXPORT_DESTINATION`,
`TRACE_SESSION_ID`, `LOGGER_URL`, and `LOGGER_TOKEN_FILE`.
`TRACE_COMMITMENT_WORK_DIR` and `TRACE_EXPORT_STATUS_FILE` are optional. Logger
delivery updates `loggerOk`, `loggerPending`, and Logger timestamps/errors in
the existing status file without changing its S3 export `ok` field.

## API and configuration

The API exposes event append, log heads/entries/checkpoints, the public key,
Expand Down
2 changes: 1 addition & 1 deletion logger/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ async fn submit(trace: bool, args: Vec<String>) -> Result<(), String> {
Event {
event_id: event_id.ok_or("--event-id is required")?,
session_id: session.ok_or("--session-id is required")?,
event_type: "trace.commitment".into(),
event_type: "trace.artifact_exported".into(),
payload_digest: digest.clone(),
artifact_references: vec![ArtifactReference {
uri: storage_reference.ok_or("--storage-reference is required")?,
Expand Down
96 changes: 87 additions & 9 deletions logger/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -429,14 +429,21 @@ mod tests {
.unwrap();
let key_file = directory.path().join("signing-key.pem");
fs::write(&key_file, key.as_bytes()).unwrap();
let token = "test-token-0123456789abcdef";
let trace_token = "trace-token-0123456789abcdef";
let reader_token = "reader-token-0123456789abcdef";
let clients_file = directory.path().join("clients.json");
fs::write(
&clients_file,
serde_json::to_vec(&json!({"clients":[{
"id":"test-client",
"tokenSha256":format!("sha256:{:x}", Sha256::digest(token)),
"permissions":["append","read","verify"],
"id":"trace-producer",
"tokenSha256":format!("sha256:{:x}", Sha256::digest(trace_token)),
"permissions":["append"],
"eventTypes":["trace.artifact_exported"],
"sessions":["session-*"]
},{
"id":"audit-reader",
"tokenSha256":format!("sha256:{:x}", Sha256::digest(reader_token)),
"permissions":["read","verify"],
"eventTypes":["*"],
"sessions":["session-*"]
}]}))
Expand Down Expand Up @@ -464,11 +471,16 @@ mod tests {
async fn append_and_read_require_scoped_auth_and_return_no_receipt() {
let (_directory, app) = application();
let event = json!({
"eventId":"event-1",
"eventId":"trace-export-1111111111111111111111111111111111111111111111111111111111111111",
"sessionId":"session-1",
"eventType":"reviewer.verdict",
"eventType":"trace.artifact_exported",
"payloadDigest":format!("sha256:{}", "1".repeat(64)),
"artifactReferences":[]
"artifactReferences":[{
"uri":"s3://audit/production/artifacts/session-1/trace.jsonl",
"digest":format!("sha256:{}", "1".repeat(64)),
"size":42,
"mediaType":"application/jsonl"
}]
});
let unauthorized = app
.clone()
Expand All @@ -486,7 +498,7 @@ mod tests {
.clone()
.oneshot(
Request::post("/v1/events")
.header("authorization", "Bearer test-token-0123456789abcdef")
.header("authorization", "Bearer trace-token-0123456789abcdef")
.header("content-type", "application/json")
.body(Body::from(event.to_string()))
.unwrap(),
Expand All @@ -503,10 +515,36 @@ mod tests {
0
);

let duplicate = app
.clone()
.oneshot(
Request::post("/v1/events")
.header("authorization", "Bearer trace-token-0123456789abcdef")
.header("content-type", "application/json")
.body(Body::from(event.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(duplicate.status(), StatusCode::NO_CONTENT);

let producer_cannot_read = app
.clone()
.oneshot(
Request::get("/v1/logs/session-1/head")
.header("authorization", "Bearer trace-token-0123456789abcdef")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(producer_cannot_read.status(), StatusCode::FORBIDDEN);

let head = app
.clone()
.oneshot(
Request::get("/v1/logs/session-1/head")
.header("authorization", "Bearer test-token-0123456789abcdef")
.header("authorization", "Bearer reader-token-0123456789abcdef")
.body(Body::empty())
.unwrap(),
)
Expand All @@ -518,5 +556,45 @@ mod tests {
serde_json::from_slice::<Value>(&body).unwrap()["sequence"],
1
);

let checkpoints = app
.clone()
.oneshot(
Request::get("/v1/logs/session-1/checkpoints")
.header("authorization", "Bearer reader-token-0123456789abcdef")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(checkpoints.status(), StatusCode::OK);
let body = axum::body::to_bytes(checkpoints.into_body(), 16_384)
.await
.unwrap();
let decoded: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(decoded["checkpoints"].as_array().unwrap().len(), 1);
assert_eq!(
decoded["checkpoints"][0]["loggerSignature"]["algorithm"],
"Ed25519"
);

let verified = app
.oneshot(
Request::post("/v1/verify")
.header("authorization", "Bearer reader-token-0123456789abcdef")
.header("content-type", "application/json")
.body(Body::from(r#"{"logId":"session-1"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(verified.status(), StatusCode::OK);
let body = axum::body::to_bytes(verified.into_body(), 4096)
.await
.unwrap();
let decoded: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(decoded["ok"], true);
assert_eq!(decoded["checkedEntries"], 1);
assert_eq!(decoded["checkedCheckpoints"], 1);
}
}
Loading
Loading