diff --git a/console/web/README.md b/console/web/README.md index 63ffee550..5d02c742c 100644 --- a/console/web/README.md +++ b/console/web/README.md @@ -65,7 +65,7 @@ Then open the printed `Local:` URL (Vite picks the first free port from Transcripts hydrate from `session::messages` and stream live from `session::message-added` / `session::message-updated` snapshots — localStorage keeps only UI affordances (active id, last model). - Double-click a row to rename inline (writes through `session::set_meta`); + Double-click a row to rename inline (writes through `session::set-meta`); hover to reveal the delete affordance (`session::delete`). - **Light / dark theme** toggle, persisted under `iii-theme` and applied pre-paint to avoid a flash. diff --git a/console/web/src/hooks/use-conversations.ts b/console/web/src/hooks/use-conversations.ts index 686d2f7b1..11c6e0508 100644 --- a/console/web/src/hooks/use-conversations.ts +++ b/console/web/src/hooks/use-conversations.ts @@ -11,7 +11,7 @@ * - "new chat" is a LOCAL DRAFT (`draft: true`, id `console-`); the * session is materialised by `ensureSession` on the first send so empty * chats never litter the store. - * - rename / model / mode changes write through `session::set_meta`. The + * - rename / model / mode changes write through `session::set-meta`. The * console owns the metadata convention `{ surface, model, mode, * title_manual }`; metadata replaces WHOLESALE, so the full object is * always sent. diff --git a/console/web/src/lib/sessions/api.ts b/console/web/src/lib/sessions/api.ts index 8e7b817f4..a825084e1 100644 --- a/console/web/src/lib/sessions/api.ts +++ b/console/web/src/lib/sessions/api.ts @@ -56,7 +56,7 @@ export async function setSessionMeta(input: { metadata?: Record }): Promise<{ meta: SessionMeta }> { const client = await getIiiClient() - return client.call('session::set_meta', input) + return client.call('session::set-meta', input) } export async function deleteSession( @@ -72,7 +72,7 @@ export async function setSessionStatus( reason?: string, ): Promise { const client = await getIiiClient() - await client.call('session::set_status', { + await client.call('session::set-status', { session_id: sessionId, status, ...(reason ? { reason } : {}), diff --git a/console/web/src/pages/Configuration/tabs/WorkersTab/schema-form/OneOfField.tsx b/console/web/src/pages/Configuration/tabs/WorkersTab/schema-form/OneOfField.tsx index 3ab0c09b9..f1548b545 100644 --- a/console/web/src/pages/Configuration/tabs/WorkersTab/schema-form/OneOfField.tsx +++ b/console/web/src/pages/Configuration/tabs/WorkersTab/schema-form/OneOfField.tsx @@ -5,7 +5,13 @@ import { wt } from '../typography' import { EnumField } from './EnumField' import { FieldDispatch, type FieldProps } from './FieldDispatch' import { errorForField, FieldShell } from './FieldShell' -import { resolveSchema, schemaDefault, schemaTypes } from './ref-resolver' +import { resolveSchema } from './ref-resolver' +import { + isSingleStringEnumVariant, + matchVariantIndex, + variantDefault, + variantLabel, +} from './variant-match' /** * `oneOf` / `anyOf` dispatcher. @@ -20,9 +26,11 @@ import { resolveSchema, schemaDefault, schemaTypes } from './ref-resolver' * * 2. **Heterogeneous variants** — different types or shapes. We render * a variant `Select` followed by a recursive `FieldDispatch` for the - * chosen sub-schema. Best-effort match-on-load picks the variant - * whose `type` matches the current value; ties fall back to the - * first variant so the operator can always change it. + * chosen sub-schema. Match-on-load prefers a discriminated tag (an + * adjacently/internally tagged enum's single-value `name`/`type` + * property — e.g. an adapter `{ name: "bridge" }`), then falls back to + * structural `type` matching, then to the first variant so the + * operator can always change it. See [`./variant-match`]. */ export function OneOfField(props: FieldProps) { const { label, schema, value, onChange, required, rootSchema } = props @@ -67,7 +75,7 @@ export function OneOfField(props: FieldProps) { function handleVariantChange(nextKey: string) { const nextIdx = Number.parseInt(nextKey, 10) if (!Number.isFinite(nextIdx)) return - onChange(schemaDefault(variants[nextIdx])) + onChange(variantDefault(variants[nextIdx], rootSchema)) } return ( @@ -98,84 +106,3 @@ export function OneOfField(props: FieldProps) { ) } - -function isSingleStringEnumVariant(variant: JsonSchema): boolean { - if (!Array.isArray(variant.enum) || variant.enum.length !== 1) return false - const types = schemaTypes(variant) - // Accept either explicit `type: string` or an unspecified type with a - // string-typed enum value (some schemars outputs omit `type`). - if (types.length > 0 && !types.includes('string')) return false - return typeof (variant.enum as unknown[])[0] === 'string' -} - -function variantLabel(variant: JsonSchema, idx: number): string { - if (typeof variant.title === 'string') return variant.title - if (Array.isArray(variant.enum) && variant.enum.length === 1) { - const single = (variant.enum as unknown[])[0] - if (typeof single === 'string') return single - } - const types = schemaTypes(variant) - if (types.length > 0) return types.join(' | ') - return `variant ${idx + 1}` -} - -/** - * Best-effort match of the current value to one of the variant schemas. - * Returns the index of the first matching variant, or 0 when no variant - * fits (so the dispatcher always has something to render). - */ -function matchVariantIndex( - variants: JsonSchema[], - value: JsonValue | undefined, -): number { - for (let i = 0; i < variants.length; i++) { - if (valueMatchesSchema(value, variants[i])) return i - } - return 0 -} - -function valueMatchesSchema( - value: JsonValue | undefined, - schema: JsonSchema, -): boolean { - if (Array.isArray(schema.enum)) { - return (schema.enum as JsonValue[]).some((v) => deepEqual(v, value)) - } - const types = schemaTypes(schema) - if (types.length === 0) return false - const actual = jsonType(value) - return types.includes(actual) -} - -function jsonType(value: JsonValue | undefined): string { - if (value === null || value === undefined) return 'null' - if (Array.isArray(value)) return 'array' - if (Number.isInteger(value as number)) return 'integer' - return typeof value -} - -function deepEqual( - a: JsonValue | undefined, - b: JsonValue | undefined, -): boolean { - if (a === b) return true - if (a === null || b === null) return false - if (typeof a !== typeof b) return false - if (Array.isArray(a) !== Array.isArray(b)) return false - if (Array.isArray(a) && Array.isArray(b)) { - if (a.length !== b.length) return false - return a.every((x, i) => deepEqual(x, b[i])) - } - if (typeof a === 'object' && typeof b === 'object') { - const ak = Object.keys(a as object) - const bk = Object.keys(b as object) - if (ak.length !== bk.length) return false - return ak.every((k) => - deepEqual( - (a as Record)[k], - (b as Record)[k], - ), - ) - } - return false -} diff --git a/console/web/src/pages/Configuration/tabs/WorkersTab/schema-form/SchemaForm.stories.tsx b/console/web/src/pages/Configuration/tabs/WorkersTab/schema-form/SchemaForm.stories.tsx index 55d5c12c4..5abcba46e 100644 --- a/console/web/src/pages/Configuration/tabs/WorkersTab/schema-form/SchemaForm.stories.tsx +++ b/console/web/src/pages/Configuration/tabs/WorkersTab/schema-form/SchemaForm.stories.tsx @@ -82,6 +82,7 @@ export const EnumOptional = storyFor('enum-optional') export const EnumNumeric = storyFor('enum-numeric') export const OneOfCollapsed = storyFor('oneof-collapsed') export const OneOfHeterogeneous = storyFor('oneof-hetero') +export const OneOfAdapter = storyFor('adapter-oneof') export const Nullable = storyFor('nullable') export const ArrayPrimitives = storyFor('array-primitives') export const ArrayObjects = storyFor('array-objects') diff --git a/console/web/src/pages/Configuration/tabs/WorkersTab/schema-form/variant-match.test.ts b/console/web/src/pages/Configuration/tabs/WorkersTab/schema-form/variant-match.test.ts new file mode 100644 index 000000000..8959aab09 --- /dev/null +++ b/console/web/src/pages/Configuration/tabs/WorkersTab/schema-form/variant-match.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' +import type { JsonSchema, JsonValue } from '../api' +import { + isSingleStringEnumVariant, + matchVariantIndex, + variantDefault, + variantLabel, +} from './variant-match' + +/** + * Mirrors the schemars output for an adjacently tagged enum + * (`#[serde(tag = "name", content = "config")]`), as registered by + * session-manager's `StorageAdapter`. `config` is a `$ref` so the tests + * also exercise root-relative resolution. + */ +const ROOT: JsonSchema = { + type: 'object', + definitions: { + FsBackendConfig: { + type: 'object', + properties: { + data_dir: { type: 'string', default: '~/.iii/data/session-manager' }, + }, + }, + BridgeBackendConfig: { + type: 'object', + properties: { + url: { type: 'string' }, + timeout_ms: { type: 'integer', default: 5000 }, + }, + required: ['url'], + }, + }, +} + +const ADAPTER_VARIANTS: JsonSchema[] = [ + { + type: 'object', + properties: { + name: { type: 'string', enum: ['fs'] }, + config: { $ref: '#/definitions/FsBackendConfig' }, + }, + required: ['config', 'name'], + }, + { + type: 'object', + properties: { + name: { type: 'string', enum: ['bridge'] }, + config: { $ref: '#/definitions/BridgeBackendConfig' }, + }, + required: ['config', 'name'], + }, +] + +describe('matchVariantIndex — discriminated (tagged) variants', () => { + it('selects the variant whose tag matches the value', () => { + const fs: JsonValue = { name: 'fs', config: { data_dir: '/tmp' } } + const bridge: JsonValue = { name: 'bridge', config: { url: 'ws://m' } } + expect(matchVariantIndex(ADAPTER_VARIANTS, fs)).toBe(0) + expect(matchVariantIndex(ADAPTER_VARIANTS, bridge)).toBe(1) + }) + + it('does not collapse two object variants onto the first', () => { + // Both variants are `type: object`; without tag matching a bridge value + // would wrongly resolve to the fs variant (index 0). + const bridge: JsonValue = { name: 'bridge', config: {} } + expect(matchVariantIndex(ADAPTER_VARIANTS, bridge)).toBe(1) + }) + + it('falls back to the first variant when no tag matches', () => { + expect(matchVariantIndex(ADAPTER_VARIANTS, { name: 'unknown' })).toBe(0) + expect(matchVariantIndex(ADAPTER_VARIANTS, undefined)).toBe(0) + }) +}) + +describe('matchVariantIndex — heterogeneous (structural) variants', () => { + const variants: JsonSchema[] = [ + { type: 'integer' }, + { type: 'string' }, + { type: 'object', properties: { value: { type: 'integer' } } }, + ] + + it('matches by JSON type when there is no tag', () => { + expect(matchVariantIndex(variants, 30)).toBe(0) + expect(matchVariantIndex(variants, 'PT30S')).toBe(1) + expect(matchVariantIndex(variants, { value: 1 })).toBe(2) + }) +}) + +describe('variantDefault', () => { + it('builds a discriminated default with nested config defaults', () => { + expect(variantDefault(ADAPTER_VARIANTS[0], ROOT)).toEqual({ + name: 'fs', + config: { data_dir: '~/.iii/data/session-manager' }, + }) + expect(variantDefault(ADAPTER_VARIANTS[1], ROOT)).toEqual({ + name: 'bridge', + config: { url: '', timeout_ms: 5000 }, + }) + }) + + it('round-trips: a switched-to variant re-selects itself', () => { + const bridgeDefault = variantDefault(ADAPTER_VARIANTS[1], ROOT) + expect(matchVariantIndex(ADAPTER_VARIANTS, bridgeDefault)).toBe(1) + }) + + it('defers to the shallow default for primitives', () => { + expect(variantDefault({ type: 'string' }, ROOT)).toBe('') + expect(variantDefault({ type: 'integer', default: 7 }, ROOT)).toBe(7) + }) +}) + +describe('variantLabel', () => { + it('labels tagged object variants by their tag value', () => { + expect(variantLabel(ADAPTER_VARIANTS[0], 0)).toBe('fs') + expect(variantLabel(ADAPTER_VARIANTS[1], 1)).toBe('bridge') + }) + + it('prefers an explicit title, then enum, then type', () => { + expect(variantLabel({ title: 'seconds', type: 'integer' }, 0)).toBe( + 'seconds', + ) + expect(variantLabel({ type: 'string', enum: ['otlp'] }, 0)).toBe('otlp') + expect(variantLabel({ type: 'integer' }, 0)).toBe('integer') + }) +}) + +describe('isSingleStringEnumVariant', () => { + it('accepts single string enums and rejects others', () => { + expect(isSingleStringEnumVariant({ type: 'string', enum: ['a'] })).toBe( + true, + ) + expect(isSingleStringEnumVariant({ enum: ['a'] })).toBe(true) + expect( + isSingleStringEnumVariant({ type: 'string', enum: ['a', 'b'] }), + ).toBe(false) + expect(isSingleStringEnumVariant({ type: 'integer', enum: [1] })).toBe( + false, + ) + expect(isSingleStringEnumVariant({ type: 'object' })).toBe(false) + }) +}) diff --git a/console/web/src/pages/Configuration/tabs/WorkersTab/schema-form/variant-match.ts b/console/web/src/pages/Configuration/tabs/WorkersTab/schema-form/variant-match.ts new file mode 100644 index 000000000..76077f893 --- /dev/null +++ b/console/web/src/pages/Configuration/tabs/WorkersTab/schema-form/variant-match.ts @@ -0,0 +1,196 @@ +/** + * Pure helpers for `oneOf` / `anyOf` variant handling, split out of + * `OneOfField` so the matching, labelling, and default-on-switch logic can + * be unit-tested without rendering React. + * + * The interesting case is schemars' adjacently/internally tagged enums + * (`#[serde(tag = "name", content = "config")]`), which serialize to a + * `oneOf` of objects that each carry a single-value enum "tag" property + * (e.g. `name: { enum: ["bridge"] }`). Matching, labelling, and switching + * all key off that tag so a stored `{ name: "bridge", … }` round-trips to + * the right variant. + */ + +import type { JsonSchema, JsonValue } from '../api' +import { resolveSchema, schemaDefault, schemaTypes } from './ref-resolver' + +/** + * True when a variant is a single-value string enum (schemars's + * representation of a unit Rust enum variant under `serde(rename_all)`). + * `OneOfField` collapses a union of these into one flat `enum` dropdown. + */ +export function isSingleStringEnumVariant(variant: JsonSchema): boolean { + if (!Array.isArray(variant.enum) || variant.enum.length !== 1) return false + const types = schemaTypes(variant) + // Accept either explicit `type: string` or an unspecified type with a + // string-typed enum value (some schemars outputs omit `type`). + if (types.length > 0 && !types.includes('string')) return false + return typeof (variant.enum as unknown[])[0] === 'string' +} + +/** + * Human label for a variant in the picker. Prefers an explicit `title`, + * then a single-value enum, then a tagged object's tag value (e.g. an + * adapter `name` of "fs" / "bridge") instead of the useless "object", then + * the JSON type(s). + */ +export function variantLabel(variant: JsonSchema, idx: number): string { + if (typeof variant.title === 'string') return variant.title + if (Array.isArray(variant.enum) && variant.enum.length === 1) { + const single = (variant.enum as unknown[])[0] + if (typeof single === 'string') return single + } + const tag = objectTagValue(variant) + if (typeof tag === 'string') return tag + const types = schemaTypes(variant) + if (types.length > 0) return types.join(' | ') + return `variant ${idx + 1}` +} + +/** + * Best-effort match of the current value to one of the variant schemas. + * Returns the index of the first matching variant, or 0 when no variant + * fits (so the dispatcher always has something to render). + * + * Discriminated variants win first: when the value sets a variant's + * single-value enum tag (e.g. `{ name: "bridge" }`) we select that variant, + * so two object variants don't both collapse onto the first. Otherwise we + * fall back to structural (type-based) matching for genuinely heterogeneous + * unions. + */ +export function matchVariantIndex( + variants: JsonSchema[], + value: JsonValue | undefined, +): number { + const tagged = variants.findIndex((v) => valueMatchesTags(value, v)) + if (tagged !== -1) return tagged + for (let i = 0; i < variants.length; i++) { + if (valueMatchesSchema(value, variants[i])) return i + } + return 0 +} + +/** + * Default value for a variant the operator just switched to. Unlike the + * shallow [`schemaDefault`], this recurses into object properties so a + * discriminated variant comes back fully formed — single-value-enum tags + * get their constant (so `matchVariantIndex` re-selects this variant) and + * nested `$ref` content (e.g. an adapter `config`) gets its own defaults. + */ +export function variantDefault( + schema: JsonSchema, + root: JsonSchema, +): JsonValue { + const resolved = resolveSchema(schema, { root }) + if (resolved.default !== undefined) return resolved.default as JsonValue + const single = singleEnumValue(resolved) + if (single !== undefined) return single + if ( + schemaTypes(resolved).includes('object') && + isPlainObject(resolved.properties) + ) { + const out: Record = {} + for (const [key, propSchema] of Object.entries(resolved.properties)) { + if (!isPlainObject(propSchema)) continue + out[key] = variantDefault(propSchema as JsonSchema, root) + } + return out + } + return schemaDefault(resolved) +} + +/** + * The single-value enum of a schema, if it has one (schemars renders a + * unit-like tag as `{ enum: ["fs"] }`). Returns `undefined` otherwise. + */ +function singleEnumValue(schema: JsonSchema): JsonValue | undefined { + if (Array.isArray(schema.enum) && schema.enum.length === 1) { + return schema.enum[0] as JsonValue + } + return undefined +} + +/** The first single-value-enum tag of an object schema's properties. */ +function objectTagValue(schema: JsonSchema): JsonValue | undefined { + if (!isPlainObject(schema.properties)) return undefined + for (const propSchema of Object.values(schema.properties)) { + if (!isPlainObject(propSchema)) continue + const tag = singleEnumValue(propSchema as JsonSchema) + if (tag !== undefined) return tag + } + return undefined +} + +/** + * True when every single-value-enum tag property of an object variant is + * present and equal in `value`. Returns false when the variant carries no + * tags (so the caller falls back to structural matching) or the value is + * not an object. + */ +function valueMatchesTags( + value: JsonValue | undefined, + schema: JsonSchema, +): boolean { + if (!isPlainObject(value) || !isPlainObject(schema.properties)) return false + let tagCount = 0 + for (const [key, propSchema] of Object.entries(schema.properties)) { + if (!isPlainObject(propSchema)) continue + const tag = singleEnumValue(propSchema as JsonSchema) + if (tag === undefined) continue + tagCount++ + if (!deepEqual((value as Record)[key], tag)) { + return false + } + } + return tagCount > 0 +} + +function valueMatchesSchema( + value: JsonValue | undefined, + schema: JsonSchema, +): boolean { + if (Array.isArray(schema.enum)) { + return (schema.enum as JsonValue[]).some((v) => deepEqual(v, value)) + } + const types = schemaTypes(schema) + if (types.length === 0) return false + const actual = jsonType(value) + return types.includes(actual) +} + +function jsonType(value: JsonValue | undefined): string { + if (value === null || value === undefined) return 'null' + if (Array.isArray(value)) return 'array' + if (Number.isInteger(value as number)) return 'integer' + return typeof value +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function deepEqual( + a: JsonValue | undefined, + b: JsonValue | undefined, +): boolean { + if (a === b) return true + if (a === null || b === null) return false + if (typeof a !== typeof b) return false + if (Array.isArray(a) !== Array.isArray(b)) return false + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false + return a.every((x, i) => deepEqual(x, b[i])) + } + if (typeof a === 'object' && typeof b === 'object') { + const ak = Object.keys(a as object) + const bk = Object.keys(b as object) + if (ak.length !== bk.length) return false + return ak.every((k) => + deepEqual( + (a as Record)[k], + (b as Record)[k], + ), + ) + } + return false +} diff --git a/console/web/src/stories/fixtures/worker-fixtures.ts b/console/web/src/stories/fixtures/worker-fixtures.ts index fc9095b47..2fcc86ecc 100644 --- a/console/web/src/stories/fixtures/worker-fixtures.ts +++ b/console/web/src/stories/fixtures/worker-fixtures.ts @@ -676,6 +676,108 @@ export const SCHEMA_EXAMPLES: SchemaExample[] = [ initial: { min: 10, max: 5 }, errors: new Map([['/max', 'max must be greater than or equal to min']]), }, + { + // Mirrors session-manager's registered schema: an adjacently tagged + // `StorageAdapter` enum (`name` + `config`) that schemars emits as a + // `oneOf`. The console renders a variant picker plus only the selected + // adapter's `config` fields; switching resets `config` to that adapter's + // defaults. + id: 'adapter-oneof', + label: 'oneOf · adapter (name + config)', + wide: true, + schema: { + type: 'object', + title: 'session-manager', + description: + 'adjacently tagged adapter (name + config). switch fs ↔ bridge to see only its fields.', + definitions: { + FsBackendConfig: { + type: 'object', + additionalProperties: false, + description: 'settings for the fs adapter.', + properties: { + data_dir: { + type: 'string', + default: '~/.iii/data/session-manager', + title: 'data dir', + description: 'one .jsonl per session.', + }, + }, + }, + BridgeBackendConfig: { + type: 'object', + additionalProperties: false, + description: 'settings for the bridge adapter.', + properties: { + url: { + type: 'string', + title: 'url', + description: 'websocket url of the main instance.', + }, + timeout_ms: { + type: 'integer', + default: 5000, + title: 'timeout ms', + description: 'per store/publish call timeout (ms).', + }, + }, + required: ['url'], + }, + StorageAdapter: { + description: 'storage adapter selection.', + oneOf: [ + { + type: 'object', + description: 'one append-only JSONL file per session.', + properties: { + name: { type: 'string', enum: ['fs'] }, + config: { $ref: '#/definitions/FsBackendConfig' }, + }, + required: ['config', 'name'], + }, + { + type: 'object', + description: 'defer storage to a main session-manager.', + properties: { + name: { type: 'string', enum: ['bridge'] }, + config: { $ref: '#/definitions/BridgeBackendConfig' }, + }, + required: ['config', 'name'], + }, + ], + }, + }, + properties: { + adapter: { + allOf: [{ $ref: '#/definitions/StorageAdapter' }], + default: { + name: 'fs', + config: { data_dir: '~/.iii/data/session-manager' }, + }, + title: 'adapter', + description: 'storage adapter (fs | bridge) plus its settings.', + }, + default_list_limit: { + type: 'integer', + default: 50, + title: 'default list limit', + }, + max_list_limit: { + type: 'integer', + default: 500, + title: 'max list limit', + }, + }, + }, + initial: { + adapter: { + name: 'fs', + config: { data_dir: '~/.iii/data/session-manager' }, + }, + default_list_limit: 50, + max_list_limit: 500, + }, + }, ] /* ------------------------------------------------------------------ */ diff --git a/docs/sops/README.md b/docs/sops/README.md index 9ddcfa6aa..3d1a2cff9 100644 --- a/docs/sops/README.md +++ b/docs/sops/README.md @@ -7,6 +7,7 @@ YAML, **the workflow wins** — update these docs. |---|---| | [`new-worker.md`](new-worker.md) | First read when adding any worker — naming, repo wiring, CI, release checklist | | [`binary-worker.md`](binary-worker.md) | Scaffolding a Rust `deploy: binary` daemon (layout, functions, triggers, tests) | +| [`configuration.md`](configuration.md) | Integrating a worker with the `configuration` worker (schema-validated, hot-reloadable, shared config) | | [`release.md`](release.md) | Cutting a version, re-running a failed release, troubleshooting publish | ## Typical flow diff --git a/docs/sops/binary-worker.md b/docs/sops/binary-worker.md index 7c024c54e..cfc36c142 100644 --- a/docs/sops/binary-worker.md +++ b/docs/sops/binary-worker.md @@ -268,6 +268,19 @@ You may **additionally** cover `--manifest` by spawning the binary from `tests/manifest.rs` (pattern A, section 9); that is optional if unit tests already enforce the JSON contract. +### Reactive config via the `configuration` worker + +The static `config.yaml` + `load_config` pattern above is the baseline. When a +worker needs a **schema-validated, observable, hot-reloadable** config that +other workers and the operator console can read and edit on a live bus, +migrate its block to the built-in **`configuration` worker** instead: register +a JSON Schema, fetch the authoritative value at boot, and hot-reload on change. +`config.yaml` then becomes a SEED, not the source of truth, and `src/config.rs` +gains `json_schema()` / `to_json` / `from_json` / `boot_signature()` alongside +`load_config`. See [`configuration.md`](configuration.md) for the full recipe; +`session-manager`, `context-manager`, `shell`, `storage`, `database`, and +`coder` follow it. + ## 6. `src/main.rs`: the entry point The entry point must: diff --git a/docs/sops/configuration.md b/docs/sops/configuration.md new file mode 100644 index 000000000..b1a396a0b --- /dev/null +++ b/docs/sops/configuration.md @@ -0,0 +1,182 @@ +# Integrating a worker with the `configuration` worker + +How to move a worker's runtime config out of a static `config.yaml` and onto +the built-in **`configuration`** worker: a schema-validated, reactive registry +that other workers and the operator console can read, validate, and edit on a +live bus. + +This is the **advanced** alternative to the baseline static-config pattern in +[`binary-worker.md`](binary-worker.md) §5. Reach for it when config must be +observable, hot-reloadable, or shared. Reference implementations: +`session-manager`, `context-manager`, `shell`, `storage`, `database`, `coder`. + +## 1. What the `configuration` worker is + +A server-side registry of named entries. Every entry has an id (e.g. +`session-manager`, `llm-router`), a human-readable name and description, a JSON +Schema describing the value shape, and a JSON value validated against that +schema. Workers call `configuration::register` once at startup to declare their +schema and `configuration::set` to publish values; consumers call +`configuration::get` / `configuration::list` to read, and bind a +`configuration` trigger to react to changes without polling. + +The default `fs` adapter persists one YAML file per id under +`./data/configuration` and watches the directory, so manual edits surface as +`configuration:updated` events the same way SDK calls do. The worker is enabled +by default in the engine. + +### When to use + +- A worker is migrating its block out of a static `config.yaml` and wants a + typed, observable surface other workers can read and validate against. +- Two workers need to agree on the same values without one polling the other. +- An operator should edit one place (a YAML file or the console) and have the + change propagate to every subscriber without a restart. + +### Boundaries + +- Not a general-purpose key/value store — every entry needs a registered JSON + Schema. Use `iii-state` for free-form values. +- No partial updates: `set` always replaces the whole value. Build the new + value client-side and ship it in one call. +- Schemas are not version-checked across re-registrations — re-registering with + an incompatible schema replaces it. Coordinate migrations out-of-band. + +## 2. Function surface + +All ids are kebab-case (`::`), per [`binary-worker.md`](binary-worker.md) §7: + +- `configuration::register` — declare an id with name, description, JSON + Schema, and an optional `initial_value`; idempotent re-registration replaces + the schema/metadata but preserves any stored value. +- `configuration::set` — replace the value for a registered id; validates + against the schema and emits `configuration:updated`. +- `configuration::get` — read one entry by id; expands `${VAR:default}` + against live env unless `raw: true`. +- `configuration::list` — enumerate registered ids with name/description/schema + (never the value). +- `configuration::schema` — read schema/name/description for one id. + +`register` and `set` are the only mutators; reads are cache-backed and expand +`${VAR:default}` against the live process env on every call, so env changes +propagate without a restart. + +## 3. The integration recipe (Rust binary) + +### a. `src/config.rs` — make the config schema-able and splittable + +Keep the `WorkerConfig` struct + `serde(default)` + `default_*()` + +`impl Default` from [`binary-worker.md`](binary-worker.md) §5, then add: + +- Derive **`Serialize` + `JsonSchema`** (alongside `Deserialize`); keep + `#[serde(deny_unknown_fields)]`. +- `from_yaml(&str)` / `from_file(&str)` — env-expand `${NAME}` against the + process env, then parse. This is the SEED path only. +- `from_json(&Value)` — parse a value already env-expanded by the worker (do + **not** re-expand). +- `to_json(&self) -> Value` and `json_schema() -> Value` (a + `schemars::schema_for!` with the shipped defaults attached as `example`). +- `boot_signature(&self) -> BootSignature` — the fields consumed **once at + boot** (adapters built then and never rebuilt). Everything else is a per-call + tuning knob that can hot-reload. If every field is boot-time, the signature is + the whole config and all changes are restart-required. + +### b. `src/configuration.rs` — the integration module + +Mirror [`context-manager/src/configuration.rs`](../../context-manager/src/configuration.rs). +Provide: + +- `pub type ConfigCell = Arc>>` — the hot-swappable + snapshot shared with handlers. +- `CONFIG_ID = ""`, `CONFIG_FN_ID = "::on-config-change"`, and + retry/backoff constants. +- `register_config(iii, seed)` — register `json_schema()`; install `seed` as + `initial_value` when present, else seed the built-in default only when no + value is stored yet (safe to call every boot). +- `fetch_config(iii)` — read the authoritative, env-expanded value + (`NOT_FOUND` ⇒ built-in default). +- `apply_config(cell, cfg)` / `reloadable(cfg, boot_sig)` — swap the snapshot, + refusing any change to the boot signature (restart required). +- `register_config_trigger(iii, cell, boot_sig)` — register the + `::on-config-change` handler and bind a `configuration` trigger + filtered to this id (see §4). The handler **re-fetches** via + `configuration::get` and ignores the trigger payload, so a direct call can + never inject config. + +### c. `src/main.rs` — boot order + +```text +1. parse CLI (--config is now only a SEED) +2. connect to the engine +3. register_config(seed) + fetch_config() # required boot dependency +4. resolve adapters from the fetched config (capture boot_signature first) +5. build the ConfigCell; register functions +6. register_config_trigger(cell, boot_sig) # LAST — closes over the cell +``` + +`configuration` is a **required boot dependency**: a failed register/fetch +aborts startup. Build the `ConfigCell` once and share it between the service +and the trigger so a live `set` of a tuning knob is picked up per call. Bind the +trigger **last** so its handler closes over the fully-built cell. + +### d. `config.yaml` — now a SEED + +Add a header making clear it is not the source of truth: it only populates +`initial_value` on the first `configuration::register` (when nothing is stored +for the id). After that the stored value is authoritative; edit it with +`configuration::set id=` or by editing the persisted file. Mirror +[`coder/config.yaml`](../../coder/config.yaml). + +### e. `iii-permissions.yaml` — deny the reload hook + +`::on-config-change` must never be agent-callable; add +`'!::on-config-change'` next to the existing +`'!storage::on-config-change'` / `'!database::on-config-change'` denies. It is +defense-in-depth: the handler already re-fetches from `configuration::get`, so a +direct call cannot inject config. + +## 4. Reactive triggers + +Bind a `configuration` trigger when a function should run on every +register/set/delete — including external `fs` edits and bridge-forwarded events: + +```rust +iii.register_trigger(RegisterTriggerInput { + trigger_type: "configuration".to_string(), + function_id: "::on-config-change".to_string(), + config: json!({ + "configuration_id": "", // omit to receive every id + "event_types": ["configuration:updated"], // subset of registered|updated|deleted + }), + metadata: None, +})?; +``` + +Reads never fire triggers. If you only need the new value inside the same +function that wrote it, `configuration::set` already returns +`old_value` / `new_value` — bind a trigger only when a *different* component +should react. + +## 5. Hot-reload vs restart-required + +The **boot signature** (§3a) is the contract: on `configuration:updated`, +`on-config-change` re-fetches and, when the boot signature is unchanged, swaps +the snapshot so handlers read new tuning knobs per call. A boot-signature change +is **refused** (logged "restart required", the previous snapshot kept) — those +adapters are built once at boot. Always keep the previous snapshot on any +failure path; never serve a half-applied config. + +## 6. Checklist + +- [ ] `WorkerConfig` derives `Serialize` + `JsonSchema`; has + `from_yaml`/`from_file`/`from_json`/`to_json`/`json_schema`/`boot_signature`. +- [ ] `src/configuration.rs` mirrors the reference: register / fetch / trigger / + reloadable + a `ConfigCell`. +- [ ] `main.rs`: connect → register+fetch (fatal on failure) → build adapters → + register functions → bind the `configuration` trigger last. +- [ ] `config.yaml` carries a SEED header; it is no longer the source of truth. +- [ ] `'!::on-config-change'` denied in `iii-permissions.yaml`. +- [ ] README "Configuration" section documents the id, the hot-reload vs + restart-required split, and the seed semantics. +- [ ] Unit tests cover `boot_signature` (tuning-only vs restart-required) and a + JSON round-trip; they run engine-free in CI. diff --git a/docs/sops/new-worker.md b/docs/sops/new-worker.md index 17d72503e..cdefa3988 100644 --- a/docs/sops/new-worker.md +++ b/docs/sops/new-worker.md @@ -30,7 +30,7 @@ Every worker needs a top-level folder with: |---|---|---| | `iii.worker.yaml` | yes | Registry + CI metadata | | Version manifest | yes | `Cargo.toml`, `package.json`, or `pyproject.toml` per `manifest:` | -| `config.yaml` | yes | Operator defaults (or equivalent for container workers) | +| `config.yaml` | yes | Operator defaults (or equivalent for container workers). For schema-validated, hot-reloadable, or shared config, migrate to the [`configuration`](configuration.md) worker — `config.yaml` becomes a seed | | `tests/` (non-empty) | yes | See §5 | | `README.md` | yes | Per [`worker-readme.md`](../../worker-readme.md) | diff --git a/harness/docs/architecture.md b/harness/docs/architecture.md index 5d1182d7e..26fbbe96d 100644 --- a/harness/docs/architecture.md +++ b/harness/docs/architecture.md @@ -17,7 +17,7 @@ talks to its own workers. Conversation transcripts live in the external (`session::*` functions + six trigger types); the harness is the **driver**: it ensures sessions, appends user/assistant/function_result messages with deterministic idempotent entry ids, streams assistant content via -`session::update_message`, writes compaction records as +`session::update-message`, writes compaction records as `custom_type: "compaction"` entries, and flips session status around runs (`working` → `done`/`error`). diff --git a/harness/docs/workers/turn-orchestrator.md b/harness/docs/workers/turn-orchestrator.md index 6888f0d15..58cd1ba23 100644 --- a/harness/docs/workers/turn-orchestrator.md +++ b/harness/docs/workers/turn-orchestrator.md @@ -66,7 +66,7 @@ The 7 states from [state.ts](harness/src/turn-orchestrator/state.ts): | State | Handler file | Role | |---|---|---| | `provisioning` | [provisioning/process.ts](harness/src/turn-orchestrator/provisioning/process.ts) | Build the system prompt (self-sufficient engine-only preamble), write enriched `run_request` (with `function_schemas: [agentTriggerTool()]`), → `assistant_streaming`. | -| `assistant_streaming` | [assistant-streaming/process.ts](harness/src/turn-orchestrator/assistant-streaming/process.ts) | Increment `turn_count`; append the turn's empty assistant entry to session-manager (deterministic `entry_id`, idempotent on re-entry); create channel; trigger provider stream; replace the entry's content via `session::update_message` per coalesced delta batch (each firing `session::message-updated` — the live token surface); on completion call `finalizeAssistantTurn` which lands the final content with a strict update, emits `message_complete` on `agent::events` (stop_reason notice), then routes → `function_execute` (has calls) / `finishing` (no calls) via `finishSession` (error/aborted). | +| `assistant_streaming` | [assistant-streaming/process.ts](harness/src/turn-orchestrator/assistant-streaming/process.ts) | Increment `turn_count`; append the turn's empty assistant entry to session-manager (deterministic `entry_id`, idempotent on re-entry); create channel; trigger provider stream; replace the entry's content via `session::update-message` per coalesced delta batch (each firing `session::message-updated` — the live token surface); on completion call `finalizeAssistantTurn` which lands the final content with a strict update, emits `message_complete` on `agent::events` (stop_reason notice), then routes → `function_execute` (has calls) / `finishing` (no calls) via `finishSession` (error/aborted). | | `function_execute` | [function-execute/process.ts](harness/src/turn-orchestrator/function-execute/process.ts) | Build batch from `rec.last_assistant` (or reuse existing `rec.work`); for each call: emit `function_execution_start`, skip if already executed or awaiting approval, trigger via `triggerWithHook`; if `pending` → append to `awaiting_approval` and continue other calls; park to `function_awaiting_approval` when any call awaits; otherwise commit result (silent `writeRecord` checkpoint) + emit `function_execution_end`; after batch: fold results into messages + emit `turn_end` → `steering_check` / `stopped` via `finishSession`. | | `function_awaiting_approval` | [function-awaiting-approval/process.ts](harness/src/turn-orchestrator/function-awaiting-approval/process.ts) | On each wake: for each `awaiting_approval[]` entry with a `function_resolutions` row, settle immediately (`execute` → pre-approved trigger; `deliver` → delivered content verbatim); delete consumed rows; remove resolved entries; stay parked while any remain; when none remain → `finalizeBatch` if complete else `function_execute`. | | `steering_check` | [steering-check/process.ts](harness/src/turn-orchestrator/steering-check/process.ts) | `function_results` present → `assistant_streaming` (unless `max_turns` reached); else emit `turn_end` once → `stopped` via `finishSession`. `max_turns` path emits a synthetic `message_complete` + `turn_end`. | diff --git a/harness/src/runtime/session.ts b/harness/src/runtime/session.ts index 72cee9d1d..09a455d34 100644 --- a/harness/src/runtime/session.ts +++ b/harness/src/runtime/session.ts @@ -119,12 +119,12 @@ export async function sessionSetStatus( ): Promise { try { await iii.trigger({ - function_id: 'session::set_status', + function_id: 'session::set-status', payload: { session_id, status, ...(reason ? { reason } : {}) }, timeoutMs: DEFAULT_TIMEOUT_MS, }); } catch (err) { - logger.warn('session::set_status failed', { session_id, status, err: String(err) }); + logger.warn('session::set-status failed', { session_id, status, err: String(err) }); } } @@ -186,7 +186,7 @@ export async function sessionUpdateMessage( }, ): Promise<{ updated: boolean; revision: number }> { const resp = await iii.trigger({ - function_id: 'session::update_message', + function_id: 'session::update-message', payload: input, timeoutMs: DEFAULT_TIMEOUT_MS, }); diff --git a/harness/src/turn-orchestrator/assistant-streaming/ports.ts b/harness/src/turn-orchestrator/assistant-streaming/ports.ts index f4774212b..39ca9f4b9 100644 --- a/harness/src/turn-orchestrator/assistant-streaming/ports.ts +++ b/harness/src/turn-orchestrator/assistant-streaming/ports.ts @@ -203,7 +203,7 @@ export function createStreamingPorts(iii: ISdk): AssistantStreamingPorts { await sessionUpdateMessage(iii, { session_id, entry_id, content, origin: opts?.origin }); } catch (err) { if (!opts?.tolerant) throw err; - logger.warn('session::update_message failed mid-stream; continuing', { + logger.warn('session::update-message failed mid-stream; continuing', { session_id, entry_id, err: String(err), diff --git a/harness/src/turn-orchestrator/assistant-streaming/run.ts b/harness/src/turn-orchestrator/assistant-streaming/run.ts index 6000f45cf..0ae4afc79 100644 --- a/harness/src/turn-orchestrator/assistant-streaming/run.ts +++ b/harness/src/turn-orchestrator/assistant-streaming/run.ts @@ -3,7 +3,7 @@ * * The driver loop (integration.md §10): append an empty assistant entry with * a deterministic id before the provider stream starts, replace its content - * via `session::update_message` per coalesced delta batch, and land the final + * via `session::update-message` per coalesced delta batch, and land the final * content with a last strict update. Step re-entry reuses the same entry * (idempotent append) instead of duplicating it. */ diff --git a/harness/tests/_helpers/fakeSessionManager.ts b/harness/tests/_helpers/fakeSessionManager.ts index 98fc741aa..34cf8da29 100644 --- a/harness/tests/_helpers/fakeSessionManager.ts +++ b/harness/tests/_helpers/fakeSessionManager.ts @@ -42,11 +42,11 @@ export class FakeSessionManager { return this.ensure(p); case 'session::append': return this.append(p); - case 'session::update_message': + case 'session::update-message': return this.updateMessage(p); case 'session::messages': return this.messages(p); - case 'session::set_status': + case 'session::set-status': return this.setStatus(p); default: throw new Error(`fake session-manager: unhandled ${function_id}`); diff --git a/harness/tests/turn-orchestrator/coalesce-deltas.test.ts b/harness/tests/turn-orchestrator/coalesce-deltas.test.ts index 45bbfff1b..8a752e393 100644 --- a/harness/tests/turn-orchestrator/coalesce-deltas.test.ts +++ b/harness/tests/turn-orchestrator/coalesce-deltas.test.ts @@ -163,7 +163,7 @@ describe('runStreamTurn wires the coalescer', () => { return { ports, updates }; } - it('collapses a run of same-type deltas into a single session::update_message (via the final flush)', async () => { + it('collapses a run of same-type deltas into a single session::update-message (via the final flush)', async () => { const { ports, updates } = mkPorts(async (onDelta) => { await onDelta(P, td('a')); await onDelta(P, td('b')); diff --git a/harness/tests/turn-orchestrator/run-start.test.ts b/harness/tests/turn-orchestrator/run-start.test.ts index 01c8fdbc9..03dec9ef9 100644 --- a/harness/tests/turn-orchestrator/run-start.test.ts +++ b/harness/tests/turn-orchestrator/run-start.test.ts @@ -307,7 +307,7 @@ describe('execute', () => { await execute(iii, RunStartPayloadSchema.parse(consoleRunStartPayload)); - const statusIdx = calls.findIndex((c) => c.function_id === 'session::set_status'); + const statusIdx = calls.findIndex((c) => c.function_id === 'session::set-status'); const firstAppendIdx = calls.findIndex((c) => c.function_id === 'session::append'); expect(statusIdx).toBeGreaterThanOrEqual(0); expect((calls[statusIdx]?.payload as { status: string }).status).toBe('working'); diff --git a/iii-permissions.yaml b/iii-permissions.yaml index 9235fc216..760026b64 100644 --- a/iii-permissions.yaml +++ b/iii-permissions.yaml @@ -59,14 +59,18 @@ rules: - '!session::store::*' - '!session::create' - '!session::ensure' - - '!session::set_meta' - - '!session::set_status' + - '!session::set-meta' + - '!session::set-status' - '!session::delete' - '!session::append' - - '!session::append_many' - - '!session::update_message' + - '!session::append-many' + - '!session::update-message' - '!session::fork' - - '!session::set_active_leaf' + - '!session::set-active-leaf' + # Operator/automation health signal, not an agent tool: it can surface a + # build-error string (e.g. a data_dir path). Operators/console reach it via + # the privileged dispatch path that bypasses this agent gate. + - '!session::config-status' # context-manager: assemble/compact are pure transforms (nothing to leak) # but each can spend a summariser LLM call, so deny them to in-run agents in # this cost-sensitive deployment (context-manager.md § Agent exposure). @@ -80,6 +84,7 @@ rules: # change cannot inadvertently re-open the injection path. - '!storage::on-config-change' - '!database::on-config-change' + - '!session::on-config-change' # Read-only / introspection (extend below for your tools). - state::get diff --git a/session-manager/Cargo.lock b/session-manager/Cargo.lock index 0f33f88e9..2afd538bd 100644 --- a/session-manager/Cargo.lock +++ b/session-manager/Cargo.lock @@ -1788,7 +1788,7 @@ dependencies = [ [[package]] name = "session-manager" -version = "0.1.1" +version = "0.2.0" dependencies = [ "anyhow", "async-trait", diff --git a/session-manager/Cargo.toml b/session-manager/Cargo.toml index 4e664b89a..c10a7dc82 100644 --- a/session-manager/Cargo.toml +++ b/session-manager/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "session-manager" -version = "0.1.2" +version = "0.2.0" edition = "2021" publish = false diff --git a/session-manager/README.md b/session-manager/README.md index d34974c86..685ac5ed9 100644 --- a/session-manager/README.md +++ b/session-manager/README.md @@ -74,7 +74,7 @@ use iii_sdk::{IIIError, RegisterFunction, RegisterTriggerInput}; use serde_json::{json, Value}; iii.register_function( - "my-ui::on_message_updated", + "my-ui::on-message-updated", RegisterFunction::new_async(|event: Value| async move { // full updated message + monotonic revision; keep the highest println!("{} rev {}", event["entry_id"], event["revision"]); @@ -84,14 +84,14 @@ iii.register_function( iii.register_trigger(RegisterTriggerInput { trigger_type: "session::message-updated".into(), - function_id: "my-ui::on_message_updated".into(), + function_id: "my-ui::on-message-updated".into(), config: json!({ "session_id": session_id, "roles": ["assistant"] }), metadata: None, })?; ``` Streaming an assistant reply uses the same primitives: append an -(initially empty) assistant message, then call `session::update_message` +(initially empty) assistant message, then call `session::update-message` as tokens arrive — each update fires `session::message-updated` with an incremented `revision`. @@ -120,9 +120,10 @@ Delivery is fire-and-forget, at-least-once, and unordered — reconcile message updates by `revision` (keep the highest) and transcript order by the parent chain, never by arrival order. -## Storage backends +## Storage adapters -Two backends, selected by `backend` + a nested `backend_config`: +Two adapters, selected by an `adapter` block (a `name` plus a nested +`config`): - **`fs`** (default) — one append-only JSONL file per session under `data_dir` (`.jsonl`): typed `meta` / `entry` / @@ -130,12 +131,12 @@ Two backends, selected by `backend` + a nested `backend_config`: session delete. This is the durable, single-instance setup. - **`bridge`** — this instance keeps all domain logic (idempotency, revisions, branching, locks) but stores through a **main** instance - running its own session-manager (`backend: fs`) on another iii + running its own session-manager (`adapter name: fs`) on another iii engine, via the internal `session::store::*` protocol. Event propagation in a bridge topology: the main is the single fan-out point. A bridged instance publishes each mutation's events to the main -(`session::store::publish_events`); the main delivers to its own +(`session::store::publish-events`); the main delivers to its own subscribers and forwards an envelope to **every** attached bridged instance over its internal `session::store::events` feed; each bridge re-emits through its local trigger types with its own subscribers' @@ -151,28 +152,57 @@ surface. ## Configuration +Runtime settings live in the **`configuration` worker** under id +**`session-manager`**. At startup the worker registers its JSON Schema, +fetches the live, env-expanded value via `configuration::get`, and binds a +`configuration` trigger so changes apply without a restart. Persisted values +default to `./data/configuration/session-manager.yaml` (the configuration +worker's `fs` adapter) — edit that file directly or call `configuration::set` +and the change propagates. `adapter` is an adjacently tagged enum, so the +console's worker-config form renders a variant picker (`fs` / `bridge`) and +only the selected adapter's `config` fields (`data_dir`, or `url` / +`timeout_ms`), plus the list limits — all as editable inputs. + ```yaml -backend: fs # fs | bridge -backend_config: - data_dir: ~/.iii/data/session-manager # fs: one .jsonl per session +adapter: + name: fs # fs | bridge + config: + data_dir: ~/.iii/data/session-manager # fs: one .jsonl per session -# backend: bridge -# backend_config: -# url: ws://127.0.0.1:49134 # main engine WebSocket URL -# timeout_ms: 5000 # per store/publish call timeout +# adapter: +# name: bridge +# config: +# url: ws://127.0.0.1:49134 # main engine WebSocket URL +# timeout_ms: 5000 # per store/publish call timeout default_list_limit: 50 # page size when list/messages omit `limit` max_list_limit: 500 # hard cap on any requested `limit` ``` -An invalid `backend_config` is fatal at boot (a misconfigured bridge -never silently falls back to writing a local fs store). Other defaults -live in [`src/config.rs`](src/config.rs). +**Reload policy.** Every field hot-reloads on `configuration:updated`, no +restart required. `default_list_limit` / `max_list_limit` swap the shared +snapshot the list/messages calls read. A change to the `adapter` (fs↔bridge, a +new `data_dir`, a bridge url/timeout) rebuilds the store and event plumbing and +swaps it in atomically; the new store's current state is then replayed through +the `session::*` triggers so open subscribers (the console sidebar/transcript, +the harness, ...) stay live without a refetch. A reload that cannot be built +(e.g. an unreadable `data_dir`, or a self-referential bridge `url`) keeps the +previous runtime (last-good) and is surfaced by `session::config-status`. An +invalid bridge `config` is still rejected at parse time, and at boot a +misconfigured bridge is fatal (it never silently falls back to a local fs store). +Switching `data_dir` or bridge `url` changes the backing storage immediately and +does not migrate existing sessions. + +**First boot.** When no value is stored yet for id `session-manager`, the worker +registers [`WorkerConfig::default()`](src/config.rs) as `initial_value`. +Optionally pass `--config ` to seed from a YAML file instead (one-time, +never overwrites an existing stored value). `${VAR:default}` placeholders in +stored values are expanded by the configuration worker on every read. ## Local development & testing ```bash -cargo run --release -- --url ws://127.0.0.1:49134 --config ./config.yaml +cargo run --release -- --url ws://127.0.0.1:49134 cargo test # unit + manifest + BDD (engine scenarios self-skip) cargo test --test bdd -- --tags @pure # no engine required cargo test --test bdd -- --tags @engine # requires a running `iii` diff --git a/session-manager/architecture/README.md b/session-manager/architecture/README.md index c698cfc47..ac6f4b8d8 100644 --- a/session-manager/architecture/README.md +++ b/session-manager/architecture/README.md @@ -68,8 +68,8 @@ flowchart LR | **Active leaf** | The entry the current conversation path ends at. Appends chain from it and move it. Stored per session. | | **Active path** | Walk from the active leaf to the root, reversed (oldest first). What `session::messages` returns. | | **Revision** | Per-entry monotonic counter, starts at 0, +1 per content update. Consumers reconcile streamed snapshots last-write-wins by revision. | -| **Branch** | Appending under a non-leaf parent (or after `session::set_active_leaf`) creates a sibling chain. Abandoned branches stay readable. | +| **Branch** | Appending under a non-leaf parent (or after `session::set-active-leaf`) creates a sibling chain. Abandoned branches stay readable. | | **Fork** | Copy-on-fork: the root→entry path is copied into a *new session* with fresh entry ids. Fully independent afterwards. | | **Main instance** | An fs-backend instance that owns durable storage and is the single event fan-out point in a bridge topology. | -| **Bridged instance** | A `backend: bridge` instance: runs all domain logic locally, stores through the main, publishes its events to the main, and receives every participant's events back through a relay. | +| **Bridged instance** | A bridge-adapter instance: runs all domain logic locally, stores through the main, publishes its events to the main, and receives every participant's events back through a relay. | | **Envelope** | `EventEnvelope { trigger_type, payload, session_metadata }` — the wire form events travel in between instances, carrying session metadata so tenancy filters work at every edge. | diff --git a/session-manager/architecture/integration.md b/session-manager/architecture/integration.md index b50ce87ad..357e6c508 100644 --- a/session-manager/architecture/integration.md +++ b/session-manager/architecture/integration.md @@ -46,13 +46,14 @@ Integration is always some subset of the same triangle: message`. Match on the code substring (the SDK may prefix transport framing). Reads (`get`, `get_message`) return `null` instead of erroring for unknown ids. -- **Pagination**: `limit` (operator-configurable; defaults: 50 when omitted, - hard cap 500) + opaque `cursor`. Re-send the same filters/order with a - cursor; a list cursor used with a different `order` is rejected - (`session/invalid_cursor`). +- **Pagination**: `limit` (operator-configurable via the `configuration` + worker under id `session-manager`; defaults: 50 when omitted, hard cap 500) + + opaque `cursor`. Re-send the same filters/order with a cursor; a list + cursor used with a different `order` is rejected (`session/invalid_cursor`). - **Agent exposure is deny-by-default.** An in-run agent that can write here can rewrite its own transcript. Deny every mutation, all of - `session::store::*`, and expose reads only in single-tenant deployments. + `session::store::*`, and the internal `session::on-config-change` reload + hook, and expose reads only in single-tenant deployments. ## 3. Data types @@ -131,11 +132,11 @@ session-manager` / `get function info`); the shapes below are the contract. // order: "created_asc" | "created_desc" | "updated_desc" (default) // metadata: subset-equality against SessionMeta.metadata (every given key must match) -// session::set_meta — supplied fields replace; metadata replaces WHOLESALE. +// session::set-meta — supplied fields replace; metadata replaces WHOLESALE. // Fires session::meta-updated (all-fields-absent request is a silent no-op). { session_id, title?, description?, metadata? } -> { meta } -// session::set_status — fires session::status-changed; SAME status = no-op, +// session::set-status — fires session::status-changed; SAME status = no-op, // no event (even with a different reason). reason stored only with "error", // cleared on any other status. { session_id, status, reason? } -> { status, previous_status } @@ -157,12 +158,12 @@ session-manager` / `get function info`); the shapes below are the contract. parent_id?, entry_id?, origin? } -> { entry_id, parent_id: string | null, timestamp } -// session::append_many — ordered batch, chained; one message-added per +// session::append-many — ordered batch, chained; one message-added per // entry, in order. NOT idempotent. Empty batch => session/empty_batch. { session_id, messages: AgentMessage[], parent_id?, origin? } -> { entry_ids: string[], last_entry_id } -// session::update_message — replace content (streaming deltas / edits). +// session::update-message — replace content (streaming deltas / edits). // Each success increments revision (echoed on the event). With // expected_revision set, a mismatch writes nothing, fires nothing, and // returns { updated: false, revision: current }. details only for @@ -180,7 +181,7 @@ session-manager` / `get function info`); the shapes below are the contract. -> { messages: [{ entry_id, message?: AgentMessage, custom?: { custom_type, data } }], next_cursor? } -// session::get_message — null when session or entry is unknown. +// session::get-message — null when session or entry is unknown. { session_id, entry_id } -> { entry: SessionEntry } | null ``` @@ -194,7 +195,7 @@ session-manager` / `get function info`); the shapes below are the contract. // session::created (with forked_from). title defaults to the source's. { session_id, entry_id, title? } -> { session_id, meta } -// session::set_active_leaf — branch switch: the active path now ends here; +// session::set-active-leaf — branch switch: the active path now ends here; // subsequent appends chain from it. No event (the spec'd exception). { session_id, entry_id } -> { active_leaf } ``` @@ -207,10 +208,10 @@ Bind with the standard two-step pattern — register a handler function, then register a trigger of the type with a `config` filter: ```typescript -iii.registerFunction("ui::on_message_updated", async (evt) => render(evt)); +iii.registerFunction("ui::on-message-updated", async (evt) => render(evt)); iii.registerTrigger({ type: "session::message-updated", - function_id: "ui::on_message_updated", + function_id: "ui::on-message-updated", config: { session_id: "s_123", roles: ["assistant"] }, }); ``` @@ -335,10 +336,10 @@ filters, or default reads. ## 8. Deployment topologies -**Single instance (`backend: fs`)** — the default. One worker, one +**Single instance (`adapter name: fs`)** — the default. One worker, one `data_dir`, one JSONL file per session. Everything in §4–§7 applies as-is. -**Bridge (`backend: bridge`)** — several iii instances share one +**Bridge (`adapter name: bridge`)** — several iii instances share one conversation store: ```mermaid @@ -405,5 +406,6 @@ The integration the spec was designed around: metadata filter. - **Status is yours**: only the driver flips it; same-status calls are no-ops, so blind `set_status working` at turn start is safe. -- **Agent exposure**: deny all mutations and `session::store::*` to in-run - agents; reads are tenancy-sensitive (see the spec's Agent exposure table). +- **Agent exposure**: deny all mutations, `session::store::*`, and the + internal `session::on-config-change` hook to in-run agents; reads are + tenancy-sensitive (see the spec's Agent exposure table). diff --git a/session-manager/architecture/internals.md b/session-manager/architecture/internals.md index a5c3ed97d..9f7cd28aa 100644 --- a/session-manager/architecture/internals.md +++ b/session-manager/architecture/internals.md @@ -11,8 +11,11 @@ in-process. | Path | Responsibility | |---|---| -| [src/main.rs](../src/main.rs) | Boot: CLI (`--config`, `--url`, `--manifest`), config load, backend resolution (**fatal** on invalid `backend_config`), engine connection, registration order, Ctrl+C shutdown (both connections in bridge mode). | -| [src/config.rs](../src/config.rs) | `WorkerConfig { backend, backend_config, default_list_limit, max_list_limit }`. `backend_config` is raw JSON resolved by `resolve_backend()` into `Backend::Fs(FsBackendConfig)` / `Backend::Bridge(BridgeBackendConfig)` (both `deny_unknown_fields`). `~/` expansion for `data_dir`. | +| [src/main.rs](../src/main.rs) | Boot: CLI (`--config` seed, `--url`, `--manifest`), engine connection, configuration register/fetch (**fatal** on an invalid bridge `config`), builds the initial `SessionRuntime` via `build_runtime` and wraps it in the hot-swappable `AppState`, registration order (store protocol + 14 functions + `configuration` trigger + `session::config-status`), Ctrl+C shutdown (the remote connection too when a bridge runtime is live). | +| [src/config.rs](../src/config.rs) | `WorkerConfig { adapter, default_list_limit, max_list_limit }` with serde + `schemars::JsonSchema`. `adapter` is an adjacently tagged enum `StorageAdapter` (`name` + `config`, `rename_all = "snake_case"`) → `Fs(FsBackendConfig)` / `Bridge(BridgeBackendConfig)`, each `config` a typed `deny_unknown_fields` struct; `schemars` emits a `oneOf` so the console shows a variant picker plus the selected adapter's fields. `resolve_adapter()` returns the typed selection; bridge `url` is required at parse time. `~/` expansion for `data_dir`; `${VAR}` seed expansion; `json_schema()` / `to_json` / `from_json` / `from_file`; `boot_signature()` = the `adapter` half, compared on reload to decide rebuild-vs-tune. | +| [src/configuration.rs](../src/configuration.rs) | Integration with the `configuration` worker: `register_config` (schema + seed), `fetch_config`, the hot-swappable `AppState` (live `SessionRuntime` + reload lock + `ReloadStatus`), `apply_runtime` / `reload_serialized` (build a candidate then swap; last-good on failure), `register_config_trigger` (binds `configuration` → `session::on-config-change`), `register_config_status` (`session::config-status`), `ConfigCell` (per-call list-limit snapshot). | +| [src/runtime.rs](../src/runtime.rs) | `SessionRuntime` (store + service + sink + local emitter + `AdapterMode` + optional bridge remote), `SessionBuildContext`, `build_runtime` (constructs the runtime for an adapter; pure, so the reload path builds a candidate and only swaps on success). | +| [src/resync.rs](../src/resync.rs) | `resync_triggers`: after an adapter swap, replays the new store's state (`created` + per-entry `message-added` / `message-updated`) through the local emitter and emits `deleted` for vanished sessions, so subscribers stay real-time without a refetch. | | [src/manifest.rs](../src/manifest.rs) | `--manifest` JSON for the registry publish pipeline; `default_config` mirrors `WorkerConfig::default()`. | | [src/types.rs](../src/types.rs) | Wire contracts: `Role`, `ContentBlock` (5 variants), `AgentMessage` (4 roles), `SessionEntry` (message/custom envelope), `SessionMeta`, `SessionStatus`, `CustomPayload`, `metadata_matches` (subset-equality helper). All serde + `schemars::JsonSchema`; serde tags keep the JSON byte-compatible with the spec's TypeScript. | | [src/error.rs](../src/error.rs) | `SessionError` — every variant renders as `code: message` with a stable `session/*` code; `From for IIIError` puts that string on the bus. | @@ -21,9 +24,9 @@ in-process. | [src/store/bridge.rs](../src/store/bridge.rs) | Bridge backend (defers to a main instance's `session::store::*`). | | [src/service.rs](../src/service.rs) | **All domain logic.** Per-session locks, id/clock injection, cursors, active-path walks, fork copying. Returns `(response, Vec)` — never emits itself. | | [src/events.rs](../src/events.rs) | The six public trigger types, binding-config parsing + filters, `Emitter`, `EventEnvelope`, `EventSink` (`Emitter` / `RemotePublisher`), the internal `session::store::events` feed, `attach_bridge_relay`. | -| [src/functions/mod.rs](../src/functions/mod.rs) | `Deps { service, sink }`, generic typed registration helper, `register_all` (14 functions). | +| [src/functions/mod.rs](../src/functions/mod.rs) | `Deps { service, sink }` built per call from the live `AppState` runtime (so a hot-reload is picked up without re-registering), generic typed registration helper, `register_all` (14 functions). | | [src/functions/.rs](../src/functions) | One file per function: request/response structs (serde + JsonSchema, doc comments become schema descriptions) and a thin `pub async fn handle(deps, req)` = service call + `sink.publish_all(events)`. | -| [src/functions/store_protocol.rs](../src/functions/store_protocol.rs) | The internal `session::store::*` protocol (11 raw store functions + `publish_events`), served in fs mode only. | +| [src/functions/store_protocol.rs](../src/functions/store_protocol.rs) | The internal `session::store::*` protocol (11 raw store functions + `publish_events`). Registered unconditionally but mode-gated per call: served only while in fs mode, so it follows adapter hot-reloads (bridge mode rejects). | | [tests/](../tests) | Cucumber BDD (`tests/bdd.rs`, `harness = false`) + `tests/manifest.rs` subprocess test. See §10. | Layering rule that keeps everything testable: **handlers are thin, the service @@ -192,8 +195,8 @@ Record per line, discriminated by `type`: Implements the same trait by calling the **main** instance's `session::store::*` functions over a dedicated SDK connection -(`backend_config.url`), one `trigger` per trait method with -`backend_config.timeout_ms`. Failures (unreachable main, malformed replies) +(the bridge adapter's `config.url`), one `trigger` per trait method with +`config.timeout_ms`. Failures (unreachable main, malformed replies) map to `StoreError` → `session/storage`; the bridged mutation fails cleanly. The bridged instance never caches — the main's store (and its cache) is the source of truth. @@ -206,9 +209,9 @@ the 11 trait methods 1:1 (`get_meta`, `put_meta`, `delete_meta`, `get_active_leaf`, `set_active_leaf`, `delete_active_leaf`) plus `publish_events` (§6.3). They bypass all domain logic by design — they are deployment plumbing for bridges, not an app API. Bridge-mode instances never -serve them (a bridge forwarding to itself would recurse; boot also warns when -`backend_config.url` equals the local `--url`). Deployments must deny them to -agents. +serve them (a bridge forwarding to itself would recurse; boot also fails when +the bridge adapter's `config.url` equals the local `--url`). Deployments must +deny them to agents. ## 6. Event pipeline @@ -249,7 +252,7 @@ snapshot filters evaluate against — payloads do not carry it). `EventEnvelope` and deliver it to every subscriber of the internal `session::store::events` feed (§6.3). - **bridge mode** — the sink is `RemotePublisher`: serialize the mutation's - events into envelopes and make **one** `session::store::publish_events` + events into envelopes and make **one** `session::store::publish-events` call to the main. Log-and-continue on failure (the mutation already succeeded; this is the same best-effort stance as `Void` fan-out — see §11). @@ -264,7 +267,7 @@ flowchart LR relay1["relay fn session::bridge::recv::<uuid>"] --> em1[local Emitter] --> subs1[B1 subscribers] end subgraph mainI [main instance, fs] - pubfn[session::store::publish_events] --> emM[main Emitter] + pubfn[session::store::publish-events] --> emM[main Emitter] emM --> subsM[main subscribers] emM --> feed["session::store::events fan-out"] end @@ -309,26 +312,62 @@ bridged instances to force a clean re-attach. ## 7. Configuration and boot +Runtime config lives in the **`configuration` worker** under id +`session-manager`; [`configuration.rs`](../src/configuration.rs) owns the +integration. On first boot (no stored value) the built-in +[`WorkerConfig::default()`](../src/config.rs) is registered as +`initial_value`; an optional `--config ` may seed from YAML instead. + ```yaml -backend: fs | bridge -backend_config: # shape depends on backend - data_dir: ~/.iii/data/session-manager # fs (default shown) - # url: ws://main:49134 # bridge (required) - # timeout_ms: 5000 # bridge (default 5000) +adapter: + name: fs | bridge + config: # shape depends on the adapter name + data_dir: ~/.iii/data/session-manager # fs (default shown) + # url: ws://main:49134 # bridge (required) + # timeout_ms: 5000 # bridge (default 5000) default_list_limit: 50 max_list_limit: 500 ``` Boot rules (see `main.rs` header for the full sequence): -- Missing/unreadable config file ⇒ warn + full defaults (scaffold-standard). -- **Invalid `backend_config` ⇒ fatal.** A misconfigured bridge must never - silently fall back to writing a local fs store. -- Registration order matters: six public trigger types → backend-specific - pieces (fs: store-events feed + store protocol; bridge: remote connection, - relay, publisher) → the 14 functions. -- Shutdown awaits `shutdown_async` on the local connection and, in bridge - mode, the remote one as well. +- **Connect first, then config.** After the engine connection, `register_config` + registers `WorkerConfig::json_schema()` (+ the seed as `initial_value` when + one is supplied or nothing is stored yet) and `fetch_config` reads the + authoritative, env-expanded value. `configuration` is a **required boot + dependency** — a failed register/fetch aborts startup. +- A missing/unparseable `--config` seed ⇒ warn + rely on the stored entry (or + the built-in default registered on first boot); the seed never overwrites an + already-stored value. +- **Invalid bridge `config` ⇒ fatal.** A bridge without a `url` fails at parse + time, so a misconfigured bridge never silently falls back to writing a local + fs store. +- Registration order matters: six public trigger types + the internal + store-events feed (both registered unconditionally, even in bridge mode, so a + later bridge→fs reload has them ready) → `build_runtime` for the boot adapter → + the `session::store::*` protocol (mode-gated) → the 14 functions → the + `configuration` trigger (`session::on-config-change`) → `session::config-status`. + Handlers read the live runtime from `AppState` per call, so registration + happens once and never needs to repeat across a reload. +- **Adapter hot-reload (full).** Every field reloads live. On + `configuration:updated`, `session::on-config-change` re-fetches the + authoritative value under the serialized `reload_lock`: + - **Adapter unchanged** (only the list limits differ): swap the shared + `ConfigCell` so `list` / `messages` pick up the new limits per call. No + store/sink rebuild. + - **Adapter changed** (fs↔bridge, a new `data_dir`, a bridge url/timeout): + `build_runtime` constructs a fresh `SessionRuntime`; a failed build keeps the + previous runtime (last-good) and records `Rejected` for `session::config-status`. + On success the runtime is swapped under the write lock, then `resync_triggers` + replays the new store's state through the trigger fan-out (and `deleted` for + sessions gone across the swap) so subscribers stay real-time. The previous + bridge connection, if any, is shut down afterward. + + The handler ignores the trigger payload and re-reads via `configuration::get`, + so a direct call cannot inject config — it is denied to agents in + `iii-permissions.yaml`. +- Shutdown awaits `shutdown_async` on the local connection and, when a bridge + runtime is live, the remote one as well. ## 8. Error model @@ -416,12 +455,12 @@ cargo test --test bdd -- --tags @engine # with a running engine feature file. If it mutates, take the session lock and decide which event it fires (every mutation has an event — `set_active_leaf` is the one spec'd exception). -- **New storage backend:** implement `SessionStore` (store/fetch only — no - ordering/counting logic), add a `BackendKind` + typed config struct - (`deny_unknown_fields`) + `resolve_backend` arm + `main.rs` wiring. Decide - whether it is *authoritative* (serves the store protocol + events feed, - like fs) or *deferring* (like bridge). Reuse `persistence.feature`'s - restart scenarios as the acceptance bar. +- **New storage adapter:** implement `SessionStore` (store/fetch only — no + ordering/counting logic), add a `StorageAdapter` variant + its typed config + struct (`deny_unknown_fields`) + a `main.rs` wiring arm. Decide whether it is + *authoritative* (serves the store protocol + events feed, like fs) or + *deferring* (like bridge). Reuse `persistence.feature`'s restart scenarios as + the acceptance bar. - **New trigger type:** add the const + `EventKind` variant + payload struct + config struct, register it in `register_trigger_types`, extend `EventEnvelope::to_emittable`, and cover the filter matrix in diff --git a/session-manager/config.yaml b/session-manager/config.yaml deleted file mode 100644 index e01616ca2..000000000 --- a/session-manager/config.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# session-manager runtime configuration. - -# Storage backend: fs | bridge -backend: fs -backend_config: - # fs: one .jsonl per session ("~/" expands to $HOME) - data_dir: ~/.iii/data/session-manager - -# Bridge example: defer storage + event fan-out to a main instance -# running its own session-manager (backend: fs). -# -# backend: bridge -# backend_config: -# url: ws://127.0.0.1:49134 # main engine WebSocket URL -# timeout_ms: 5000 # per store/publish call timeout - -# Default page size for session::list / session::messages when the -# request omits `limit`. -default_list_limit: 50 - -# Hard cap applied to any requested `limit` (items per page). -max_list_limit: 500 diff --git a/session-manager/src/config.rs b/session-manager/src/config.rs index b935d5639..2bb58570f 100644 --- a/session-manager/src/config.rs +++ b/session-manager/src/config.rs @@ -1,58 +1,53 @@ -//! Operator-facing runtime configuration loaded from `config.yaml`. +//! Operator-facing runtime configuration registered with the `configuration` +//! worker (id `session-manager`). The authoritative value is fetched at boot +//! via `configuration::get`; built-in defaults in [`WorkerConfig::default`] +//! seed the first registration when nothing is stored yet. An optional +//! `--config` YAML path may override that seed on first boot only. //! -//! The storage backend is selected by a `backend` discriminator plus a -//! nested, backend-specific `backend_config` object: +//! The storage adapter is selected by an `adapter` block: a `name` +//! discriminator plus a nested, adapter-specific `config` object (the same +//! shape other iii workers like `iii-state` use): //! //! ```yaml -//! backend: fs -//! backend_config: -//! data_dir: ~/.iii/data/session-manager +//! adapter: +//! name: fs +//! config: +//! data_dir: ~/.iii/data/session-manager //! ``` //! //! or //! //! ```yaml -//! backend: bridge -//! backend_config: -//! url: ws://127.0.0.1:49134 -//! timeout_ms: 5000 +//! adapter: +//! name: bridge +//! config: +//! url: ws://127.0.0.1:49134 +//! timeout_ms: 5000 //! ``` //! -//! `backend_config` is held raw and resolved into a typed per-backend -//! struct by [`WorkerConfig::resolve_backend`]; resolution failures are -//! fatal at boot (a misconfigured bridge must never silently fall back -//! to writing a local fs store). +//! `adapter` is an adjacently tagged enum (`name` + `config`), so `schemars` +//! emits a JSON Schema `oneOf` the console renders as a variant picker plus +//! the fields for the selected adapter. Each variant's `config` is a typed +//! struct (`deny_unknown_fields`), so a bridge without a `url` — or a typo'd +//! key — fails at parse time, before the worker ever tries to boot (a +//! misconfigured bridge must never silently fall back to a local fs store). use std::path::PathBuf; -use anyhow::{Context, Result}; -use serde::Deserialize; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; use serde_json::Value; -#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum BackendKind { - /// One append-only JSONL file per session under `data_dir`. - Fs, - /// Defer raw storage (and event fan-out) to a main session-manager - /// on another iii instance. - Bridge, -} - /// Root config shape. Unknown keys are rejected so a typo'd field -/// (e.g. `backnd: bridge`) fails loudly instead of silently running -/// the default backend. -#[derive(Deserialize, Debug, Clone)] +/// (e.g. `adaptr:`) fails loudly instead of silently running the default +/// adapter. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct WorkerConfig { - /// Storage backend selector. Default `fs`. - #[serde(default = "default_backend")] - pub backend: BackendKind, - - /// Backend-specific settings; shape depends on `backend`. See - /// [`FsBackendConfig`] / [`BridgeBackendConfig`]. - #[serde(default = "default_backend_config")] - pub backend_config: Value, + /// Storage adapter selection (`fs` or `bridge`) plus its settings. + /// Default: `fs` with the standard data directory. + #[serde(default)] + pub adapter: StorageAdapter, /// Default page size for `session::list` / `session::messages` when /// the request omits `limit`. @@ -64,8 +59,28 @@ pub struct WorkerConfig { pub max_list_limit: usize, } -/// Typed settings for `backend: fs`. -#[derive(Deserialize, Debug, Clone)] +/// Storage adapter selection. Adjacently tagged (`name` + `config`) so the +/// wire shape mirrors other iii workers and `schemars` emits a `oneOf` the +/// console renders as a variant picker exposing only the selected adapter's +/// fields. Each variant's `config` is validated at parse time. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] +#[serde(tag = "name", content = "config", rename_all = "snake_case")] +pub enum StorageAdapter { + /// One append-only JSONL file per session under `data_dir`. + Fs(FsBackendConfig), + /// Defer raw storage (and event fan-out) to a main session-manager on + /// another iii instance. + Bridge(BridgeBackendConfig), +} + +impl Default for StorageAdapter { + fn default() -> Self { + StorageAdapter::Fs(FsBackendConfig::default()) + } +} + +/// Settings for the `fs` adapter. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct FsBackendConfig { /// Directory holding one `.jsonl` per session. A @@ -88,8 +103,8 @@ impl Default for FsBackendConfig { } } -/// Typed settings for `backend: bridge`. -#[derive(Deserialize, Debug, Clone)] +/// Settings for the `bridge` adapter. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct BridgeBackendConfig { /// WebSocket URL of the main instance's engine. @@ -100,43 +115,83 @@ pub struct BridgeBackendConfig { pub timeout_ms: u64, } -/// A resolved, validated backend selection. -#[derive(Debug, Clone)] -pub enum Backend { - Fs(FsBackendConfig), - Bridge(BridgeBackendConfig), -} - impl WorkerConfig { - /// Parse `backend_config` into the typed struct for `backend`. - /// Unknown keys are rejected so a typo'd field fails loudly. - pub fn resolve_backend(&self) -> Result { - let raw = if self.backend_config.is_null() { - Value::Object(serde_json::Map::new()) - } else { - self.backend_config.clone() - }; - match self.backend { - BackendKind::Fs => { - let cfg: FsBackendConfig = serde_json::from_value(raw) - .context("invalid backend_config for backend `fs`")?; - Ok(Backend::Fs(cfg)) - } - BackendKind::Bridge => { - let cfg: BridgeBackendConfig = serde_json::from_value(raw) - .context("invalid backend_config for backend `bridge`")?; - Ok(Backend::Bridge(cfg)) + /// The validated storage adapter. Parsing already enforces each adapter's + /// shape (`fs` needs nothing; `bridge` requires `url`), so this is simply + /// the typed selection cloned for the boot wiring in `main.rs`. + pub fn resolve_adapter(&self) -> StorageAdapter { + self.adapter.clone() + } + + /// Parse a seed config from YAML, expanding `${NAME}` against the + /// process env FIRST (the seed file is the only path that needs + /// expansion — values fetched from `configuration::get` are already + /// env-expanded by the configuration worker), then deserializing. + pub fn from_yaml(yaml: &str) -> Result { + let expanded = expand_env(yaml); + serde_yaml::from_str(&expanded).map_err(|e| format!("yaml parse: {e}")) + } + + /// Read and parse a YAML seed file (env-expanded — see [`Self::from_yaml`]). + pub fn from_file(path: &str) -> Result { + let raw = std::fs::read_to_string(path).map_err(|e| format!("read {path}: {e}"))?; + Self::from_yaml(&raw) + } + + /// Parse a config from a JSON value already env-expanded by the + /// configuration worker. Does NOT run `expand_env` (double expansion + /// would be a bug) and tolerates a zero-field object (serde defaults + /// fill in). + pub fn from_json(value: &Value) -> Result { + serde_json::from_value(value.clone()).map_err(|e| format!("json parse: {e}")) + } + + pub fn to_json(&self) -> Value { + serde_json::to_value(self).expect("WorkerConfig serializes") + } + + /// The JSON Schema registered with the `configuration` worker. Field + /// doc-comments become property descriptions; the shipped defaults are + /// attached as a top-level `example`. `adapter` is an adjacently tagged + /// enum, so it renders as a `oneOf` (a variant picker plus the selected + /// adapter's fields) in the console; the per-adapter `config` shape is + /// validated at parse time by serde. + pub fn json_schema() -> Value { + let root = schemars::schema_for!(WorkerConfig); + let mut schema = + serde_json::to_value(&root.schema).expect("WorkerConfig JSON Schema serializes"); + if let Some(obj) = schema.as_object_mut() { + if !root.definitions.is_empty() { + obj.insert( + "definitions".into(), + serde_json::to_value(&root.definitions).expect("definitions serialize"), + ); } + obj.insert("example".into(), WorkerConfig::default().to_json()); } + schema } -} -fn default_backend() -> BackendKind { - BackendKind::Fs + /// The adapter-defining fields: everything the storage `SessionStore` and + /// event sink are built from. The reload path compares this to decide + /// whether a config change needs a full runtime rebuild (the `adapter` + /// differs) or only a per-call list-limit swap (it matches). Both paths + /// apply live — nothing requires a restart. + pub fn boot_signature(&self) -> BootSignature { + BootSignature { + adapter: self.adapter.clone(), + } + } } -fn default_backend_config() -> Value { - Value::Object(serde_json::Map::new()) +/// Signature of the adapter-defining config fields (see +/// [`WorkerConfig::boot_signature`]). Two configs with an equal signature +/// differ only in the per-call list limits (a cheap snapshot swap); any other +/// difference (an adapter swap, a new `data_dir`, a bridge URL/timeout change) +/// rebuilds and hot-swaps the runtime. +#[derive(Clone, Debug, PartialEq)] +pub struct BootSignature { + pub adapter: StorageAdapter, } pub fn default_data_dir() -> String { @@ -168,23 +223,51 @@ fn expand_tilde(path: &str) -> PathBuf { PathBuf::from(path) } +/// Expand `${NAME}` occurrences against the process environment. Unknown +/// variables expand to the empty string and emit a tracing warning. Only the +/// `--config` seed path uses this — values from `configuration::get` are +/// already expanded by the worker. A `${VAR:default}` placeholder (the +/// configuration worker's own syntax) is left untouched for the worker to +/// expand on read; an unterminated `${` is kept as a literal. +fn expand_env(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut rest = input; + while let Some(start) = rest.find("${") { + out.push_str(&rest[..start]); + let after = &rest[start + 2..]; + let Some(end) = after.find('}') else { + out.push_str(&rest[start..]); + return out; + }; + let name = &after[..end]; + if name.contains(':') { + // `${VAR:default}` belongs to the configuration worker; preserve it. + out.push_str(&rest[start..start + 2 + end + 1]); + } else { + match std::env::var(name) { + Ok(val) => out.push_str(&val), + Err(_) => tracing::warn!( + var = %name, + "seed config references unset env var; expanding to empty string" + ), + } + } + rest = &after[end + 1..]; + } + out.push_str(rest); + out +} + impl Default for WorkerConfig { fn default() -> Self { Self { - backend: default_backend(), - backend_config: default_backend_config(), + adapter: StorageAdapter::default(), default_list_limit: default_default_list_limit(), max_list_limit: default_max_list_limit(), } } } -pub fn load_config(path: &str) -> Result { - let contents = std::fs::read_to_string(path)?; - let cfg: WorkerConfig = serde_yaml::from_str(&contents)?; - Ok(cfg) -} - #[cfg(test)] mod tests { use super::*; @@ -192,21 +275,20 @@ mod tests { #[test] fn defaults_from_empty_yaml_resolve_to_fs() { let cfg: WorkerConfig = serde_yaml::from_str("{}").unwrap(); - assert_eq!(cfg.backend, BackendKind::Fs); assert_eq!(cfg.default_list_limit, 50); assert_eq!(cfg.max_list_limit, 500); - let Backend::Fs(fs) = cfg.resolve_backend().unwrap() else { - panic!("expected fs backend"); + let StorageAdapter::Fs(fs) = cfg.resolve_adapter() else { + panic!("expected fs adapter"); }; assert_eq!(fs.data_dir, "~/.iii/data/session-manager"); } #[test] fn fs_yaml_overrides_data_dir() { - let yaml = "backend: fs\nbackend_config:\n data_dir: /tmp/sessions"; + let yaml = "adapter:\n name: fs\n config:\n data_dir: /tmp/sessions"; let cfg: WorkerConfig = serde_yaml::from_str(yaml).unwrap(); - let Backend::Fs(fs) = cfg.resolve_backend().unwrap() else { - panic!("expected fs backend"); + let StorageAdapter::Fs(fs) = cfg.resolve_adapter() else { + panic!("expected fs adapter"); }; assert_eq!(fs.data_dir, "/tmp/sessions"); assert_eq!(fs.resolved_data_dir(), PathBuf::from("/tmp/sessions")); @@ -224,34 +306,37 @@ mod tests { #[test] fn bridge_yaml_parses_with_defaulted_timeout() { - let yaml = "backend: bridge\nbackend_config:\n url: ws://main:49134"; + let yaml = "adapter:\n name: bridge\n config:\n url: ws://main:49134"; let cfg: WorkerConfig = serde_yaml::from_str(yaml).unwrap(); - let Backend::Bridge(bridge) = cfg.resolve_backend().unwrap() else { - panic!("expected bridge backend"); + let StorageAdapter::Bridge(bridge) = cfg.resolve_adapter() else { + panic!("expected bridge adapter"); }; assert_eq!(bridge.url, "ws://main:49134"); assert_eq!(bridge.timeout_ms, 5000); } #[test] - fn bridge_without_url_fails_resolution() { - let yaml = "backend: bridge\nbackend_config:\n timeout_ms: 100"; - let cfg: WorkerConfig = serde_yaml::from_str(yaml).unwrap(); - let err = cfg.resolve_backend().unwrap_err(); - assert!(format!("{err:#}").contains("backend `bridge`")); + fn bridge_without_url_is_rejected_at_parse() { + // `url` is required by `BridgeBackendConfig`, so a bridge adapter + // without it fails at parse time rather than silently at boot. + let yaml = "adapter:\n name: bridge\n config:\n timeout_ms: 100"; + let err = serde_yaml::from_str::(yaml).unwrap_err(); + assert!(err.to_string().contains("url"), "got: {err}"); } #[test] - fn unknown_backend_config_key_fails_resolution() { - let yaml = "backend: fs\nbackend_config:\n datadir: /tmp/x"; - let cfg: WorkerConfig = serde_yaml::from_str(yaml).unwrap(); - let err = format!("{:#}", cfg.resolve_backend().unwrap_err()); - assert!(err.contains("unknown field"), "got: {err}"); + fn unknown_adapter_config_key_is_rejected_at_parse() { + // Each adapter `config` is `deny_unknown_fields`, so a typo'd key + // fails at parse time. + let yaml = "adapter:\n name: fs\n config:\n datadir: /tmp/x"; + let err = serde_yaml::from_str::(yaml).unwrap_err(); + assert!(err.to_string().contains("unknown field"), "got: {err}"); } #[test] - fn unknown_backend_kind_is_rejected_at_parse() { - assert!(serde_yaml::from_str::("backend: cloud").is_err()); + fn unknown_adapter_name_is_rejected_at_parse() { + let yaml = "adapter:\n name: cloud\n config: {}"; + assert!(serde_yaml::from_str::(yaml).is_err()); } #[test] @@ -264,19 +349,102 @@ mod tests { fn impl_default_matches_yaml_defaults() { let from_yaml: WorkerConfig = serde_yaml::from_str("{}").unwrap(); let from_default = WorkerConfig::default(); - assert_eq!(from_yaml.backend, from_default.backend); - assert_eq!(from_yaml.backend_config, from_default.backend_config); - assert_eq!( - from_yaml.default_list_limit, - from_default.default_list_limit + assert_eq!(from_yaml, from_default); + } + + #[test] + fn json_roundtrips_through_to_from() { + let cfg = WorkerConfig { + default_list_limit: 7, + max_list_limit: 99, + ..WorkerConfig::default() + }; + let back = WorkerConfig::from_json(&cfg.to_json()).unwrap(); + assert_eq!(back, cfg); + } + + #[test] + fn from_json_empty_object_uses_defaults() { + let cfg = WorkerConfig::from_json(&serde_json::json!({})).unwrap(); + assert_eq!(cfg, WorkerConfig::default()); + } + + #[test] + fn json_schema_carries_a_default_example() { + let schema = WorkerConfig::json_schema(); + assert_eq!(schema["example"], WorkerConfig::default().to_json()); + } + + #[test] + fn json_schema_exposes_adapter_oneof_variants() { + // The console renders a variant picker plus the selected adapter's + // fields only when `adapter` is a `oneOf` of typed objects keyed by + // `name`. Assert the shape so it can never regress to the read-only + // "unsupported schema" fallback. + let schema = WorkerConfig::json_schema(); + let adapter = &schema["definitions"]["StorageAdapter"]; + let variants = adapter["oneOf"] + .as_array() + .unwrap_or_else(|| panic!("StorageAdapter schema is a oneOf; got: {adapter}")); + assert_eq!(variants.len(), 2, "fs + bridge variants: {adapter}"); + let names: Vec<&str> = variants + .iter() + .filter_map(|v| v["properties"]["name"]["enum"][0].as_str()) + .collect(); + assert!(names.contains(&"fs"), "missing fs variant: {adapter}"); + assert!( + names.contains(&"bridge"), + "missing bridge variant: {adapter}" ); - assert_eq!(from_yaml.max_list_limit, from_default.max_list_limit); } #[test] - fn committed_config_yaml_parses_and_resolves() { - let cfg = load_config(concat!(env!("CARGO_MANIFEST_DIR"), "/config.yaml")).unwrap(); - assert_eq!(cfg.backend, BackendKind::Fs); - assert!(matches!(cfg.resolve_backend().unwrap(), Backend::Fs(_))); + fn boot_signature_matches_for_tuning_only_change() { + let boot = WorkerConfig::default(); + let tuned = WorkerConfig { + default_list_limit: boot.default_list_limit + 1, + max_list_limit: boot.max_list_limit + 1, + ..boot.clone() + }; + assert_eq!(tuned.boot_signature(), boot.boot_signature()); + } + + #[test] + fn boot_signature_differs_when_adapter_changes() { + let boot = WorkerConfig::default(); + let bridged = WorkerConfig { + adapter: StorageAdapter::Bridge(BridgeBackendConfig { + url: "ws://main:49134".to_string(), + timeout_ms: default_bridge_timeout_ms(), + }), + ..boot.clone() + }; + assert_ne!(bridged.boot_signature(), boot.boot_signature()); + } + + #[test] + fn boot_signature_differs_when_adapter_config_changes() { + let boot = WorkerConfig::default(); + let moved = WorkerConfig { + adapter: StorageAdapter::Fs(FsBackendConfig { + data_dir: "/tmp/elsewhere".to_string(), + }), + ..boot.clone() + }; + assert_ne!(moved.boot_signature(), boot.boot_signature()); + } + + #[test] + fn from_yaml_expands_env_and_preserves_worker_placeholders() { + std::env::set_var("SM_TEST_LIMIT", "123"); + let yaml = "default_list_limit: ${SM_TEST_LIMIT}\nadapter:\n name: fs\n config:\n data_dir: \"${SM_UNSET_DIR:/var/data}\""; + let cfg = WorkerConfig::from_yaml(yaml).unwrap(); + assert_eq!(cfg.default_list_limit, 123); + // `${VAR:default}` is left for the configuration worker to expand. + let StorageAdapter::Fs(fs) = cfg.resolve_adapter() else { + panic!("expected fs adapter"); + }; + assert_eq!(fs.data_dir, "${SM_UNSET_DIR:/var/data}"); + std::env::remove_var("SM_TEST_LIMIT"); } } diff --git a/session-manager/src/configuration.rs b/session-manager/src/configuration.rs new file mode 100644 index 000000000..6d47ab334 --- /dev/null +++ b/session-manager/src/configuration.rs @@ -0,0 +1,527 @@ +//! Integration with the `configuration` worker — register the schema, +//! fetch the authoritative value at boot, and hot-reload it when it +//! changes. Mirrors [`context-manager`](../../context-manager/src/configuration.rs) / +//! [`shell`](../../shell/src/configuration.rs) / +//! [`storage`](../../storage/src/configuration.rs). +//! +//! Every field hot-reloads: +//! +//! - The `adapter` block builds the [`SessionStore`](crate::store::SessionStore) +//! and event sink. A change to it (fs <-> bridge, a new `data_dir`, a bridge +//! url/timeout) rebuilds a fresh [`SessionRuntime`] and swaps it in under a +//! write lock; a failed build keeps the previous runtime (last-good) and is +//! recorded for `session::config-status`. After a successful swap the new +//! store's state is replayed through the trigger fan-out (see +//! [`resync`](crate::resync)) so subscribers stay real-time. +//! - The two list limits (`default_list_limit` / `max_list_limit`) are per-call +//! tuning knobs. When only they change, the shared [`ConfigCell`] snapshot is +//! swapped without rebuilding the runtime; `SessionService` reads it per +//! `list` / `messages` call. +//! +//! Reloads are serialized by [`AppState::reload_lock`] and re-fetch the +//! authoritative value inside the lock, so overlapping `configuration:updated` +//! events converge to the latest config and a slow build from an older event +//! can never clobber a newer one. + +use std::sync::Arc; +use std::time::Duration; + +use iii_sdk::{IIIError, RegisterFunction, RegisterTriggerInput, TriggerRequest, III}; +use serde_json::{json, Value}; +use tokio::sync::{Mutex, RwLock}; + +use crate::config::WorkerConfig; +use crate::runtime::{build_runtime, SessionBuildContext, SessionRuntime}; + +/// Hot-swappable config snapshot shared with `SessionService`. The +/// `Arc>>` shape lets a handler take a +/// `read().await` and `clone()` the inner `Arc` out (a cheap refcount bump) +/// without holding the lock across its work, while a reload whole-snapshot +/// replaces the inner `Arc` under the write lock. +pub type ConfigCell = Arc>>; + +pub const CONFIG_ID: &str = "session-manager"; +const CONFIG_FN_ID: &str = "session::on-config-change"; +const CONFIG_STATUS_FN_ID: &str = "session::config-status"; +const CONFIG_TIMEOUT_MS: u64 = 5_000; +const CONFIG_RETRIES: u32 = 3; +/// Base backoff between configuration RPC retries; multiplied by the attempt +/// number for a linear backoff (250ms, 500ms, …). +const CONFIG_RETRY_BACKOFF_MS: u64 = 250; + +/// Shared worker state the function handlers and the config-change trigger read. +/// `runtime` is the live, swappable storage + event plumbing; the rest is the +/// machinery to rebuild it on a config change. +#[derive(Clone)] +pub struct AppState { + /// The live runtime. Handlers clone the `service` / `sink` out per call; the + /// reload path swaps a freshly-built runtime in under the write lock. + pub runtime: Arc>, + /// Boot-time wiring `build_runtime` reuses on every rebuild (engine handle, + /// subscriber sets, bridge registry, shared config snapshot). + pub ctx: Arc, + /// Serializes reloads: held across the authoritative fetch + build + swap so + /// an older event's slow build can never clobber a newer applied config. + pub reload_lock: Arc>, + /// Last hot-reload outcome, exposed via `session::config-status`. + pub reload_status: Arc>, +} + +impl AppState { + /// Wrap a freshly-built runtime + its build context into shared state. + pub fn new(runtime: SessionRuntime, ctx: Arc) -> Self { + Self { + runtime: Arc::new(RwLock::new(runtime)), + ctx, + reload_lock: Arc::new(Mutex::new(())), + reload_status: Arc::new(RwLock::new(ReloadStatus::default())), + } + } +} + +/// Outcome of the most recent hot-reload attempt, exposed via +/// `session::config-status`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ReloadOutcome { + /// The live runtime reflects the most recent configuration the worker loaded. + Applied, + /// The most recent `configuration:updated` delivered a value the worker could + /// NOT build (e.g. an unreadable `data_dir` or a self-referential bridge + /// url); the previous runtime is still active and the central store has + /// DIVERGED from what this worker is running. + Rejected, +} + +/// Operator-visible hot-reload status. `rejected_reloads > 0` (or +/// `last_outcome == Rejected`) means a stored config was refused and the worker +/// is running an older adapter than the central store — actionable divergence. +#[derive(Clone, Debug, serde::Serialize, schemars::JsonSchema)] +pub struct ReloadStatus { + pub last_outcome: ReloadOutcome, + /// Build error from the most recent rejected reload (why it was refused). + pub last_error: Option, + /// Cumulative count of rejected reloads since boot (never reset). + pub rejected_reloads: u64, +} + +impl Default for ReloadStatus { + fn default() -> Self { + // The initial runtime is built from a validated config at boot. + Self { + last_outcome: ReloadOutcome::Applied, + last_error: None, + rejected_reloads: 0, + } + } +} + +impl ReloadStatus { + fn record_applied(&mut self) { + self.last_outcome = ReloadOutcome::Applied; + self.last_error = None; + } + + fn record_rejected(&mut self, err: String) { + self.last_outcome = ReloadOutcome::Rejected; + self.last_error = Some(err); + self.rejected_reloads = self.rejected_reloads.saturating_add(1); + } +} + +/// Register the `session-manager` configuration schema with the +/// configuration worker. When `seed` is present, its value is installed as +/// `initial_value`. Otherwise, the built-in default is seeded only when no +/// stored value exists yet (re-registration preserves the stored value, so +/// this is safe to call every boot). +pub async fn register_config(iii: &III, seed: Option<&WorkerConfig>) -> Result<(), String> { + let mut payload = json!({ + "id": CONFIG_ID, + "name": "Session Manager", + "description": "Durable conversation store settings: the storage adapter (fs / bridge) \ + and its config, plus the default and maximum page sizes for \ + session::list / session::messages.", + "schema": WorkerConfig::json_schema(), + }); + if let Some(seed) = seed { + payload["initial_value"] = seed.to_json(); + } else if should_seed_default_value(iii).await? { + payload["initial_value"] = WorkerConfig::default().to_json(); + } + trigger_with_retry(iii, "configuration::register", payload).await?; + Ok(()) +} + +/// Read the live `session-manager` configuration (env-expanded by the +/// configuration worker — `from_json` does NOT re-expand). +pub async fn fetch_config(iii: &III) -> Result { + let value = get_config_value(iii).await?; + if value.is_null() { + tracing::info!("no configuration value found; using built-in default configuration"); + return Ok(WorkerConfig::default()); + } + WorkerConfig::from_json(&value) +} + +async fn should_seed_default_value(iii: &III) -> Result { + match try_get_config_value(iii).await? { + None => Ok(true), + Some(value) if value.is_null() => Ok(true), + Some(_) => Ok(false), + } +} + +async fn get_config_value(iii: &III) -> Result { + try_get_config_value(iii) + .await? + .ok_or_else(|| format!("configuration `{CONFIG_ID}` not found")) +} + +/// Returns `Ok(None)` when the entry does not exist. The engine's +/// missing-entry codes vary in case (`function_not_found`, +/// `STATEMENT_NOT_FOUND`, `NOT_FOUND`), so match case-insensitively. +async fn try_get_config_value(iii: &III) -> Result, String> { + match trigger_with_retry(iii, "configuration::get", json!({ "id": CONFIG_ID })).await { + Ok(resp) => Ok(resp.get("value").cloned()), + Err(e) if e.to_ascii_uppercase().contains("NOT_FOUND") => Ok(None), + Err(e) => Err(e), + } +} + +/// Apply a freshly-fetched config to the live runtime. +/// +/// When only the list limits changed (the `adapter` is identical), swaps the +/// shared snapshot the service reads per call — no store/sink rebuild. When the +/// adapter changed, builds a new [`SessionRuntime`] (a failed build returns +/// `Err` and leaves the running runtime untouched), swaps it in, then replays +/// the new store's state through the trigger fan-out so subscribers stay live; +/// the previous bridge connection, if any, is shut down afterward. +/// +/// MUST only be called from [`reload_serialized`] (i.e. while holding +/// `reload_lock`): the build happens before the swap, so an unserialized caller +/// could let a slow older build clobber a newer applied config. +pub async fn apply_runtime(state: &AppState, cfg: WorkerConfig) -> Result<(), String> { + let adapter_changed = { + let rt = state.runtime.read().await; + rt.config.boot_signature() != cfg.boot_signature() + }; + + if !adapter_changed { + // List-limit-only change: swap the shared snapshot the live service + // reads per call. No store/sink rebuild, no resync. + let next = Arc::new(cfg); + *state.ctx.config_cell.write().await = next.clone(); + state.runtime.write().await.config = next; + tracing::info!("session-manager list limits reloaded (adapter unchanged)"); + return Ok(()); + } + + // Adapter changed: snapshot the pre-swap sessions (for delete signalling), + // build the new runtime, swap it in, then replay current state through the + // new local emitter so open views reconcile in place. + let old_metas = { + let store = state.runtime.read().await.store.clone(); + store.list_metas().await.unwrap_or_else(|e| { + tracing::warn!( + error = %e, + "resync: listing pre-swap sessions failed; deletions will not be signalled" + ); + Vec::new() + }) + }; + + let new_runtime = build_runtime(&cfg, &state.ctx)?; + let new_store = new_runtime.store.clone(); + let new_emitter = new_runtime.local_emitter.clone(); + + // Swap the shared config snapshot first so any concurrent list call sees + // limits consistent with the new runtime, then swap the runtime itself. + *state.ctx.config_cell.write().await = Arc::new(cfg); + let old_runtime = { + let mut guard = state.runtime.write().await; + std::mem::replace(&mut *guard, new_runtime) + }; + + match crate::resync::resync_triggers(&old_metas, new_store.as_ref(), new_emitter.as_ref()).await + { + Ok(stats) => tracing::info!( + sessions = stats.sessions, + entries = stats.entries, + deleted = stats.deleted, + events = stats.events, + "adapter hot-reloaded; replayed store state to subscribers" + ), + Err(e) => tracing::warn!( + error = %e, + "adapter hot-reloaded but trigger resync failed; subscribers may be stale until the next mutation" + ), + } + + // Retire the previous bridge connection (if any) after the resync so any + // in-flight reads on it have drained. + if let Some(remote) = old_runtime.bridge_remote.clone() { + remote.shutdown_async().await; + } + drop(old_runtime); + Ok(()) +} + +/// Run a config reload under the serialized reload lock. The `fetch` future is +/// awaited INSIDE the lock, so overlapping `configuration:updated` events are +/// applied one at a time and each observes the latest authoritative value. +async fn reload_serialized(state: &AppState, fetch: F) -> Result +where + F: FnOnce() -> Fut, + Fut: std::future::Future>, +{ + let _reload = state.reload_lock.lock().await; + let cfg = match fetch().await { + Ok(cfg) => cfg, + Err(e) => { + // Transient fetch failure (e.g. configuration::get timed out): the + // authoritative value is unknown. Keep the previous runtime AND + // surface the error so the dispatcher can retry — never ack success. + tracing::error!( + error = %e, + "config-change: failed to fetch authoritative configuration; keeping previous runtime, signaling retry" + ); + return Err(e); + } + }; + match apply_runtime(state, cfg).await { + Ok(()) => { + state.reload_status.write().await.record_applied(); + Ok(ReloadOutcome::Applied) + } + Err(e) => { + // Config was fetched but is unbuildable (unreadable data_dir, + // self-referential bridge url). Re-fetching returns the SAME value, + // so ack + keep last-good to avoid a retry storm; the error log and + // session::config-status surface the divergence. + tracing::error!( + error = %e, + "rejected configuration change; keeping previous runtime (config could not be built)" + ); + state.reload_status.write().await.record_rejected(e); + Ok(ReloadOutcome::Rejected) + } + } +} + +/// Register the internal config-change handler and bind a `configuration` +/// trigger. On `configuration:updated` the handler rebuilds and swaps the +/// runtime (or just the list limits) from the authoritative value. +pub fn register_config_trigger(iii: &III, state: AppState) -> Result<(), IIIError> { + let st = state.clone(); + iii.register_function( + CONFIG_FN_ID, + RegisterFunction::new_async(move |_payload: Value| { + let st = st.clone(); + async move { + on_config_change(&st).await.map_err(IIIError::Handler)?; + Ok::(json!({ "ok": true })) + } + }) + .description( + "Internal: hot-reload session-manager from the authoritative configuration when it \ + changes — rebuilds the storage adapter and event plumbing on an adapter change \ + (replaying current state to subscribers) and swaps the list limits otherwise.", + ), + ); + + iii.register_trigger(RegisterTriggerInput { + trigger_type: "configuration".to_string(), + function_id: CONFIG_FN_ID.to_string(), + config: json!({ + "configuration_id": CONFIG_ID, + "event_types": ["configuration:updated"], + }), + metadata: None, + })?; + Ok(()) +} + +/// Register `session::config-status`, reporting the last hot-reload outcome so +/// operators can detect when a stored config was rejected and the active +/// adapter diverged from the central store. +pub fn register_config_status(iii: &III, state: AppState) { + let st = state.clone(); + iii.register_function( + CONFIG_STATUS_FN_ID, + // Ignore the payload: a no-arg call, and the engine-injected + // `_caller_worker_id` would break a typed param. + RegisterFunction::new_async(move |_payload: Value| { + let st = st.clone(); + async move { + let status = { st.reload_status.read().await.clone() }; + Ok::( + serde_json::to_value(status).expect("ReloadStatus serializes"), + ) + } + }) + .description( + "Report the last configuration hot-reload outcome: last_outcome (applied|rejected), \ + last_error, and rejected_reloads (count since boot). A rejected outcome or non-zero \ + count means a stored config was refused and the active storage adapter diverged from \ + the central store. Takes no arguments.", + ), + ); +} + +/// Reload from the AUTHORITATIVE configuration. +/// +/// The caller-supplied trigger payload is intentionally ignored: +/// `session::on-config-change` is a discoverable bus function, so trusting +/// `payload.new_value` would let any caller inject arbitrary config without +/// updating persisted state. Re-fetch the stored value via `configuration::get` +/// instead. The previous runtime is always kept on any failure path. +async fn on_config_change(state: &AppState) -> Result<(), String> { + reload_serialized(state, || fetch_config(&state.ctx.iii)) + .await + .map(|_| ()) +} + +async fn trigger_with_retry(iii: &III, function_id: &str, payload: Value) -> Result { + let mut last_err = String::new(); + for attempt in 1..=CONFIG_RETRIES { + match iii + .trigger(TriggerRequest { + function_id: function_id.to_string(), + payload: payload.clone(), + action: None, + timeout_ms: Some(CONFIG_TIMEOUT_MS), + }) + .await + { + Ok(v) => return Ok(v), + Err(e) => { + last_err = e.to_string(); + if attempt < CONFIG_RETRIES { + tracing::warn!( + function_id, + attempt, + error = %last_err, + "configuration RPC failed; retrying" + ); + tokio::time::sleep(Duration::from_millis( + CONFIG_RETRY_BACKOFF_MS * u64::from(attempt), + )) + .await; + } + } + } + } + Err(format!( + "{function_id} failed after {CONFIG_RETRIES} attempts: {last_err}" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{BridgeBackendConfig, FsBackendConfig, StorageAdapter}; + use crate::events::{BridgeSubscribers, TriggerSets}; + + const TEST_URL: &str = "ws://127.0.0.1:59611"; + + /// Build an `AppState` over an fs runtime rooted at `data_dir`. The engine + /// handle never connects (no live engine in unit tests); these tests only + /// exercise the build/swap mechanics, not delivery. + fn fs_state(data_dir: &std::path::Path) -> AppState { + let iii = Arc::new(iii_sdk::register_worker( + TEST_URL, + iii_sdk::InitOptions::default(), + )); + let cfg = fs_config(data_dir); + let config_cell: ConfigCell = Arc::new(RwLock::new(Arc::new(cfg.clone()))); + let ctx = Arc::new(SessionBuildContext { + iii, + local_url: TEST_URL.to_string(), + sets: TriggerSets::new(), + bridge_subscribers: BridgeSubscribers::new(), + config_cell, + }); + let runtime = build_runtime(&cfg, &ctx).expect("build fs runtime"); + AppState::new(runtime, ctx) + } + + fn fs_config(data_dir: &std::path::Path) -> WorkerConfig { + WorkerConfig { + adapter: StorageAdapter::Fs(FsBackendConfig { + data_dir: data_dir.display().to_string(), + }), + ..WorkerConfig::default() + } + } + + #[tokio::test] + async fn list_limit_only_change_keeps_store() { + let tmp = tempfile::tempdir().unwrap(); + let state = fs_state(tmp.path()); + let store_before = state.runtime.read().await.store.clone(); + + let mut cfg = (*state.runtime.read().await.config).clone(); + cfg.default_list_limit += 7; + apply_runtime(&state, cfg).await.expect("list-limit reload"); + + let store_after = state.runtime.read().await.store.clone(); + assert!( + Arc::ptr_eq(&store_before, &store_after), + "store must NOT be rebuilt on a list-limit-only change" + ); + assert_eq!( + state.ctx.config_cell.read().await.default_list_limit, + WorkerConfig::default().default_list_limit + 7, + "the shared snapshot the service reads must reflect the new limit" + ); + } + + #[tokio::test] + async fn adapter_change_rebuilds_store() { + let tmp1 = tempfile::tempdir().unwrap(); + let tmp2 = tempfile::tempdir().unwrap(); + let state = fs_state(tmp1.path()); + let store_before = state.runtime.read().await.store.clone(); + + apply_runtime(&state, fs_config(tmp2.path())) + .await + .expect("adapter reload"); + + let store_after = state.runtime.read().await.store.clone(); + assert!( + !Arc::ptr_eq(&store_before, &store_after), + "store must be rebuilt on a data_dir change" + ); + } + + #[tokio::test] + async fn rejected_reload_keeps_previous_runtime() { + let tmp = tempfile::tempdir().unwrap(); + let state = fs_state(tmp.path()); + let store_before = state.runtime.read().await.store.clone(); + + // A bridge whose url equals the local engine would defer to itself, so + // build_runtime rejects it — the previous runtime must survive. + let bad = WorkerConfig { + adapter: StorageAdapter::Bridge(BridgeBackendConfig { + url: state.ctx.local_url.clone(), + timeout_ms: 5000, + }), + ..WorkerConfig::default() + }; + let outcome = reload_serialized(&state, || async move { Ok::<_, String>(bad) }) + .await + .expect("reload_serialized acks even when the config is rejected"); + assert_eq!(outcome, ReloadOutcome::Rejected); + + let store_after = state.runtime.read().await.store.clone(); + assert!( + Arc::ptr_eq(&store_before, &store_after), + "runtime must be kept on a rejected reload" + ); + let status = state.reload_status.read().await; + assert_eq!(status.last_outcome, ReloadOutcome::Rejected); + assert_eq!(status.rejected_reloads, 1); + assert!(status.last_error.is_some()); + } +} diff --git a/session-manager/src/error.rs b/session-manager/src/error.rs index c5aa51de7..f0a095360 100644 --- a/session-manager/src/error.rs +++ b/session-manager/src/error.rs @@ -26,7 +26,7 @@ pub enum SessionError { #[error("session/details_not_supported: {0}")] DetailsNotSupported(String), - /// `session::append_many` requires at least one message. + /// `session::append-many` requires at least one message. #[error("session/empty_batch: {0}")] EmptyBatch(String), diff --git a/session-manager/src/events.rs b/session-manager/src/events.rs index afe36c23d..515e12e12 100644 --- a/session-manager/src/events.rs +++ b/session-manager/src/events.rs @@ -26,7 +26,7 @@ //! [`EventEnvelope`]; every bridged instance subscribes a relay to it //! at boot ([`attach_bridge_relay`]). A bridged instance never emits //! locally at mutation time — its [`RemotePublisher`] sends the -//! envelopes to the main (`session::store::publish_events`), the main +//! envelopes to the main (`session::store::publish-events`), the main //! runs them through its own [`Emitter`] (local subscribers + envelope //! fan-out to *all* bridges, originator included), and each bridge's //! relay re-emits through its local `Emitter`. One canonical path, no @@ -845,7 +845,7 @@ impl Emitter { } /// Ingest path for externally-produced envelopes (the main's - /// `session::store::publish_events` and the bridges' relays). + /// `session::store::publish-events` and the bridges' relays). /// Returns the number of well-formed envelopes processed; malformed /// ones are skipped with a warning. pub async fn emit_envelopes(&self, envelopes: &[EventEnvelope]) -> usize { @@ -912,7 +912,7 @@ impl EventSink for Emitter { } /// Bridge-mode sink: batch the mutation's events into one -/// `session::store::publish_events` call to the main instance. +/// `session::store::publish-events` call to the main instance. /// Failures are logged and swallowed (same best-effort stance as the /// fire-and-forget local fan-out): the mutation itself already /// succeeded against the main store. diff --git a/session-manager/src/functions/append_many.rs b/session-manager/src/functions/append_many.rs index 3f97ae57f..f9e029b35 100644 --- a/session-manager/src/functions/append_many.rs +++ b/session-manager/src/functions/append_many.rs @@ -1,4 +1,4 @@ -//! `session::append_many` — append several message entries in order. +//! `session::append-many` — append several message entries in order. use schemars::JsonSchema; use serde::{Deserialize, Serialize}; diff --git a/session-manager/src/functions/create.rs b/session-manager/src/functions/create.rs index eef2746c3..c30fc2813 100644 --- a/session-manager/src/functions/create.rs +++ b/session-manager/src/functions/create.rs @@ -9,7 +9,7 @@ use crate::types::{JsonMap, SessionMeta}; #[derive(Debug, Clone, Default, Deserialize, JsonSchema)] pub struct CreateRequest { - /// Session title; may be refined later with `session::set_meta`. Default "". + /// Session title; may be refined later with `session::set-meta`. Default "". pub title: Option, /// Session description. Default "". pub description: Option, diff --git a/session-manager/src/functions/get_message.rs b/session-manager/src/functions/get_message.rs index 62dc40881..887b438cf 100644 --- a/session-manager/src/functions/get_message.rs +++ b/session-manager/src/functions/get_message.rs @@ -1,4 +1,4 @@ -//! `session::get_message` — read a single entry by id. +//! `session::get-message` — read a single entry by id. use schemars::JsonSchema; use serde::{Deserialize, Serialize}; diff --git a/session-manager/src/functions/mod.rs b/session-manager/src/functions/mod.rs index f57190cfa..63eba2de8 100644 --- a/session-manager/src/functions/mod.rs +++ b/session-manager/src/functions/mod.rs @@ -31,23 +31,28 @@ use schemars::JsonSchema; use serde::de::DeserializeOwned; use serde::Serialize; +use crate::configuration::AppState; use crate::error::SessionError; use crate::events::EventSink; use crate::service::SessionService; -/// Everything a function handler needs. The sink is mode-dependent: -/// fs mode publishes through the local `Emitter`; bridge mode forwards -/// envelopes to the main instance (`RemotePublisher`). +/// Everything a function handler needs. Built fresh per call from the live +/// [`SessionRuntime`](crate::runtime::SessionRuntime), so a config reload that +/// swapped the adapter is picked up by the next call. The sink is +/// mode-dependent: fs mode publishes through the local `Emitter`; bridge mode +/// forwards envelopes to the main instance (`RemotePublisher`). pub struct Deps { pub service: Arc, pub sink: Arc, } /// Register one typed handler under `id`, mapping `SessionError` into -/// the bus error shape (`code: message`). +/// the bus error shape (`code: message`). Each call snapshots the live +/// runtime's `service` + `sink` from [`AppState`], so handlers never capture a +/// stale adapter across a hot-reload. fn register( iii: &Arc, - deps: &Arc, + state: &AppState, id: &str, description: &str, handler: F, @@ -57,114 +62,123 @@ fn register( F: Fn(Arc, Req) -> Fut + Send + Sync + Clone + 'static, Fut: Future> + Send + 'static, { - let deps = deps.clone(); + let state = state.clone(); iii.register_function( id, RegisterFunction::new_async(move |req: Req| { - let deps = deps.clone(); + let state = state.clone(); let handler = handler.clone(); - async move { handler(deps, req).await.map_err(IIIError::from) } + async move { + let deps = { + let rt = state.runtime.read().await; + Arc::new(Deps { + service: rt.service.clone(), + sink: rt.sink.clone(), + }) + }; + handler(deps, req).await.map_err(IIIError::from) + } }) .description(description), ); } -pub fn register_all(iii: &Arc, deps: &Arc) { +pub fn register_all(iii: &Arc, state: &AppState) { register( iii, - deps, + state, "session::create", "Create a session at status idle; fires session::created.", |d, r| async move { create::handle(&d, r).await }, ); register( iii, - deps, + state, "session::ensure", "Idempotently ensure a session with a given id exists; fires session::created only when it creates.", |d, r| async move { ensure::handle(&d, r).await }, ); register( iii, - deps, + state, "session::get", "Read one session's metadata (null when unknown).", |d, r| async move { get::handle(&d, r).await }, ); register( iii, - deps, + state, "session::list", "List sessions with pagination, ordering, and status/metadata filters.", |d, r| async move { list::handle(&d, r).await }, ); register( iii, - deps, - "session::set_meta", + state, + "session::set-meta", "Update a session's title/description/metadata; fires session::meta-updated.", |d, r| async move { set_meta::handle(&d, r).await }, ); register( iii, - deps, - "session::set_status", + state, + "session::set-status", "Set status idle/working/done/error; fires session::status-changed (no-op when unchanged).", |d, r| async move { set_status::handle(&d, r).await }, ); register( iii, - deps, + state, "session::delete", "Delete a session and its entries; fires session::deleted.", |d, r| async move { delete::handle(&d, r).await }, ); register( iii, - deps, + state, "session::append", "Append one entry (idempotent on entry_id); fires session::message-added.", |d, r| async move { append::handle(&d, r).await }, ); register( iii, - deps, - "session::append_many", + state, + "session::append-many", "Append several message entries in order; fires session::message-added per entry.", |d, r| async move { append_many::handle(&d, r).await }, ); register( iii, - deps, - "session::update_message", + state, + "session::update-message", "Replace a message entry's content (optimistic concurrency via expected_revision); fires session::message-updated.", |d, r| async move { update_message::handle(&d, r).await }, ); register( iii, - deps, + state, "session::messages", "Load the active path as messages with entry ids, oldest first; pagination and role filtering.", |d, r| async move { messages::handle(&d, r).await }, ); register( iii, - deps, - "session::get_message", + state, + "session::get-message", "Read a single entry by id (null when unknown).", |d, r| async move { get_message::handle(&d, r).await }, ); register( iii, - deps, + state, "session::fork", "Copy history up to an entry into a new session (copy-on-fork); fires session::created.", |d, r| async move { fork::handle(&d, r).await }, ); register( iii, - deps, - "session::set_active_leaf", + state, + "session::set-active-leaf", "Move the active path to end at a given entry (branch switch).", |d, r| async move { set_active_leaf::handle(&d, r).await }, ); diff --git a/session-manager/src/functions/set_active_leaf.rs b/session-manager/src/functions/set_active_leaf.rs index 81a3f74df..7a5ce6856 100644 --- a/session-manager/src/functions/set_active_leaf.rs +++ b/session-manager/src/functions/set_active_leaf.rs @@ -1,4 +1,4 @@ -//! `session::set_active_leaf` — switch the active path (branch switch). +//! `session::set-active-leaf` — switch the active path (branch switch). use schemars::JsonSchema; use serde::{Deserialize, Serialize}; diff --git a/session-manager/src/functions/set_meta.rs b/session-manager/src/functions/set_meta.rs index 402fb45ec..7530046d2 100644 --- a/session-manager/src/functions/set_meta.rs +++ b/session-manager/src/functions/set_meta.rs @@ -1,4 +1,4 @@ -//! `session::set_meta` — update title/description/metadata. +//! `session::set-meta` — update title/description/metadata. use schemars::JsonSchema; use serde::{Deserialize, Serialize}; diff --git a/session-manager/src/functions/set_status.rs b/session-manager/src/functions/set_status.rs index 870856439..530db25c2 100644 --- a/session-manager/src/functions/set_status.rs +++ b/session-manager/src/functions/set_status.rs @@ -1,4 +1,4 @@ -//! `session::set_status` — set the session lifecycle status. +//! `session::set-status` — set the session lifecycle status. use schemars::JsonSchema; use serde::{Deserialize, Serialize}; diff --git a/session-manager/src/functions/store_protocol.rs b/session-manager/src/functions/store_protocol.rs index 7c0599091..81c513f27 100644 --- a/session-manager/src/functions/store_protocol.rs +++ b/session-manager/src/functions/store_protocol.rs @@ -15,22 +15,24 @@ use iii_sdk::{IIIError, RegisterFunction, III}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use crate::configuration::AppState; use crate::events::{Emitter, EventEnvelope}; +use crate::runtime::AdapterMode; use crate::store::SessionStore; use crate::types::{SessionEntry, SessionMeta}; -pub const GET_META: &str = "session::store::get_meta"; -pub const PUT_META: &str = "session::store::put_meta"; -pub const DELETE_META: &str = "session::store::delete_meta"; -pub const LIST_METAS: &str = "session::store::list_metas"; -pub const GET_ENTRY: &str = "session::store::get_entry"; -pub const PUT_ENTRY: &str = "session::store::put_entry"; -pub const LIST_ENTRIES: &str = "session::store::list_entries"; -pub const DELETE_ENTRIES: &str = "session::store::delete_entries"; -pub const GET_ACTIVE_LEAF: &str = "session::store::get_active_leaf"; -pub const SET_ACTIVE_LEAF: &str = "session::store::set_active_leaf"; -pub const DELETE_ACTIVE_LEAF: &str = "session::store::delete_active_leaf"; -pub const PUBLISH_EVENTS: &str = "session::store::publish_events"; +pub const GET_META: &str = "session::store::get-meta"; +pub const PUT_META: &str = "session::store::put-meta"; +pub const DELETE_META: &str = "session::store::delete-meta"; +pub const LIST_METAS: &str = "session::store::list-metas"; +pub const GET_ENTRY: &str = "session::store::get-entry"; +pub const PUT_ENTRY: &str = "session::store::put-entry"; +pub const LIST_ENTRIES: &str = "session::store::list-entries"; +pub const DELETE_ENTRIES: &str = "session::store::delete-entries"; +pub const GET_ACTIVE_LEAF: &str = "session::store::get-active-leaf"; +pub const SET_ACTIVE_LEAF: &str = "session::store::set-active-leaf"; +pub const DELETE_ACTIVE_LEAF: &str = "session::store::delete-active-leaf"; +pub const PUBLISH_EVENTS: &str = "session::store::publish-events"; #[derive(Debug, Clone, Deserialize, JsonSchema)] pub struct SessionIdRequest { @@ -94,70 +96,104 @@ fn storage_err(e: crate::store::StoreError) -> IIIError { IIIError::from(crate::error::SessionError::from(e)) } -/// Register the raw store surface backed by `store`, plus the -/// `publish_events` ingest feeding `emitter` (which fans out locally -/// and to every attached bridge). -pub fn register_store_protocol( - iii: &Arc, - store: Arc, - emitter: Arc, -) { - let s = store.clone(); +/// Message returned when a non-authoritative (bridge-mode) instance is asked to +/// serve the raw store protocol. Only an fs-main instance is the durable store; +/// a bridge forwarding these to itself would recurse. +const BRIDGE_MODE_REJECT: &str = + "session::store::* is served only by an authoritative (fs-mode) instance; \ + this instance is currently in bridge mode"; + +/// The live store, or a rejection when the instance is not fs-main. Snapshotted +/// per call so a bridge->fs hot-reload re-enables the protocol (and fs->bridge +/// disables it) without re-registering these functions. +async fn fs_store(state: &AppState) -> Result, IIIError> { + let rt = state.runtime.read().await; + if rt.mode != AdapterMode::FsMain { + return Err(IIIError::Handler(BRIDGE_MODE_REJECT.to_string())); + } + Ok(rt.store.clone()) +} + +/// The live local emitter, gated to fs-main like [`fs_store`]. +async fn fs_emitter(state: &AppState) -> Result, IIIError> { + let rt = state.runtime.read().await; + if rt.mode != AdapterMode::FsMain { + return Err(IIIError::Handler(BRIDGE_MODE_REJECT.to_string())); + } + Ok(rt.local_emitter.clone()) +} + +/// Register the raw store surface plus the `publish-events` ingest. Both read +/// the live runtime per call (see [`fs_store`] / [`fs_emitter`]) and reject when +/// the instance is in bridge mode, so the protocol follows adapter hot-reloads. +pub fn register_store_protocol(iii: &Arc, state: AppState) { + let st = state.clone(); iii.register_function( GET_META, RegisterFunction::new_async(move |req: SessionIdRequest| { - let s = s.clone(); - async move { s.get_meta(&req.session_id).await.map_err(storage_err) } + let st = st.clone(); + async move { + let store = fs_store(&st).await?; + store.get_meta(&req.session_id).await.map_err(storage_err) + } }) .description("Internal store protocol: read one SessionMeta (null when unknown)."), ); - let s = store.clone(); + let st = state.clone(); iii.register_function( PUT_META, RegisterFunction::new_async(move |req: PutMetaRequest| { - let s = s.clone(); + let st = st.clone(); async move { - s.put_meta(&req.meta).await.map_err(storage_err)?; + let store = fs_store(&st).await?; + store.put_meta(&req.meta).await.map_err(storage_err)?; Ok::<_, IIIError>(OkResponse { ok: true }) } }) .description("Internal store protocol: write one SessionMeta."), ); - let s = store.clone(); + let st = state.clone(); iii.register_function( DELETE_META, RegisterFunction::new_async(move |req: SessionIdRequest| { - let s = s.clone(); + let st = st.clone(); async move { - s.delete_meta(&req.session_id).await.map_err(storage_err)?; + let store = fs_store(&st).await?; + store + .delete_meta(&req.session_id) + .await + .map_err(storage_err)?; Ok::<_, IIIError>(OkResponse { ok: true }) } }) .description("Internal store protocol: delete one SessionMeta."), ); - let s = store.clone(); + let st = state.clone(); iii.register_function( LIST_METAS, RegisterFunction::new_async(move |_req: ListMetasRequest| { - let s = s.clone(); + let st = st.clone(); async move { - let metas = s.list_metas().await.map_err(storage_err)?; + let store = fs_store(&st).await?; + let metas = store.list_metas().await.map_err(storage_err)?; Ok::<_, IIIError>(ListMetasResponse { metas }) } }) .description("Internal store protocol: list every SessionMeta."), ); - let s = store.clone(); + let st = state.clone(); iii.register_function( GET_ENTRY, RegisterFunction::new_async(move |req: EntryIdRequest| { - let s = s.clone(); + let st = st.clone(); async move { - s.get_entry(&req.session_id, &req.entry_id) + let store = fs_store(&st).await?; + store + .get_entry(&req.session_id, &req.entry_id) .await .map_err(storage_err) } @@ -165,13 +201,15 @@ pub fn register_store_protocol( .description("Internal store protocol: read one SessionEntry (null when unknown)."), ); - let s = store.clone(); + let st = state.clone(); iii.register_function( PUT_ENTRY, RegisterFunction::new_async(move |req: PutEntryRequest| { - let s = s.clone(); + let st = st.clone(); async move { - s.put_entry(&req.session_id, &req.entry) + let store = fs_store(&st).await?; + store + .put_entry(&req.session_id, &req.entry) .await .map_err(storage_err)?; Ok::<_, IIIError>(OkResponse { ok: true }) @@ -180,26 +218,32 @@ pub fn register_store_protocol( .description("Internal store protocol: write one SessionEntry."), ); - let s = store.clone(); + let st = state.clone(); iii.register_function( LIST_ENTRIES, RegisterFunction::new_async(move |req: SessionIdRequest| { - let s = s.clone(); + let st = st.clone(); async move { - let entries = s.list_entries(&req.session_id).await.map_err(storage_err)?; + let store = fs_store(&st).await?; + let entries = store + .list_entries(&req.session_id) + .await + .map_err(storage_err)?; Ok::<_, IIIError>(ListEntriesResponse { entries }) } }) .description("Internal store protocol: list every entry of a session."), ); - let s = store.clone(); + let st = state.clone(); iii.register_function( DELETE_ENTRIES, RegisterFunction::new_async(move |req: SessionIdRequest| { - let s = s.clone(); + let st = st.clone(); async move { - s.delete_entries(&req.session_id) + let store = fs_store(&st).await?; + store + .delete_entries(&req.session_id) .await .map_err(storage_err)?; Ok::<_, IIIError>(OkResponse { ok: true }) @@ -208,13 +252,14 @@ pub fn register_store_protocol( .description("Internal store protocol: delete every entry of a session."), ); - let s = store.clone(); + let st = state.clone(); iii.register_function( GET_ACTIVE_LEAF, RegisterFunction::new_async(move |req: SessionIdRequest| { - let s = s.clone(); + let st = st.clone(); async move { - let entry_id = s + let store = fs_store(&st).await?; + let entry_id = store .get_active_leaf(&req.session_id) .await .map_err(storage_err)?; @@ -224,13 +269,15 @@ pub fn register_store_protocol( .description("Internal store protocol: read a session's active leaf pointer."), ); - let s = store.clone(); + let st = state.clone(); iii.register_function( SET_ACTIVE_LEAF, RegisterFunction::new_async(move |req: EntryIdRequest| { - let s = s.clone(); + let st = st.clone(); async move { - s.set_active_leaf(&req.session_id, &req.entry_id) + let store = fs_store(&st).await?; + store + .set_active_leaf(&req.session_id, &req.entry_id) .await .map_err(storage_err)?; Ok::<_, IIIError>(OkResponse { ok: true }) @@ -239,13 +286,15 @@ pub fn register_store_protocol( .description("Internal store protocol: move a session's active leaf pointer."), ); - let s = store.clone(); + let st = state.clone(); iii.register_function( DELETE_ACTIVE_LEAF, RegisterFunction::new_async(move |req: SessionIdRequest| { - let s = s.clone(); + let st = st.clone(); async move { - s.delete_active_leaf(&req.session_id) + let store = fs_store(&st).await?; + store + .delete_active_leaf(&req.session_id) .await .map_err(storage_err)?; Ok::<_, IIIError>(OkResponse { ok: true }) @@ -254,13 +303,14 @@ pub fn register_store_protocol( .description("Internal store protocol: clear a session's active leaf pointer."), ); - let em = emitter.clone(); + let st = state.clone(); iii.register_function( PUBLISH_EVENTS, RegisterFunction::new_async(move |req: PublishEventsRequest| { - let em = em.clone(); + let st = st.clone(); async move { - let published = em.emit_envelopes(&req.events).await; + let emitter = fs_emitter(&st).await?; + let published = emitter.emit_envelopes(&req.events).await; Ok::<_, IIIError>(PublishEventsResponse { published }) } }) @@ -272,3 +322,45 @@ pub fn register_store_protocol( tracing::info!("session::store::* protocol registered (12 functions)"); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{BridgeBackendConfig, StorageAdapter, WorkerConfig}; + use crate::events::{BridgeSubscribers, TriggerSets}; + use crate::runtime::{build_runtime, SessionBuildContext}; + + #[tokio::test] + async fn store_protocol_gated_off_in_bridge_mode() { + let iii = Arc::new(iii_sdk::register_worker( + "ws://127.0.0.1:59641", + iii_sdk::InitOptions::default(), + )); + let cfg = WorkerConfig { + adapter: StorageAdapter::Bridge(BridgeBackendConfig { + url: "ws://127.0.0.1:59642".to_string(), + timeout_ms: 5000, + }), + ..WorkerConfig::default() + }; + let config_cell = Arc::new(tokio::sync::RwLock::new(Arc::new(cfg.clone()))); + let ctx = Arc::new(SessionBuildContext { + iii, + local_url: "ws://127.0.0.1:59641".to_string(), + sets: TriggerSets::new(), + bridge_subscribers: BridgeSubscribers::new(), + config_cell, + }); + let runtime = build_runtime(&cfg, &ctx).expect("bridge runtime builds"); + let state = AppState::new(runtime, ctx); + + assert!( + fs_store(&state).await.is_err(), + "raw store protocol must be refused in bridge mode" + ); + assert!( + fs_emitter(&state).await.is_err(), + "publish-events ingest must be refused in bridge mode" + ); + } +} diff --git a/session-manager/src/functions/update_message.rs b/session-manager/src/functions/update_message.rs index 2feb7092b..984b6a7a3 100644 --- a/session-manager/src/functions/update_message.rs +++ b/session-manager/src/functions/update_message.rs @@ -1,4 +1,4 @@ -//! `session::update_message` — replace a message entry's content +//! `session::update-message` — replace a message entry's content //! (streaming deltas, edited function output). use schemars::JsonSchema; diff --git a/session-manager/src/lib.rs b/session-manager/src/lib.rs index 660cfb19d..b3ff3c4aa 100644 --- a/session-manager/src/lib.rs +++ b/session-manager/src/lib.rs @@ -26,12 +26,19 @@ //! point and every bridged instance re-emits its feed locally. //! - [`functions`] — the 14 `session::*` typed function handlers plus //! the internal `session::store::*` protocol. +//! - [`runtime`] — the hot-swappable storage + event runtime built from +//! the `adapter` config, so an adapter change reloads live (no restart). +//! - [`resync`] — replays the new store's state through the trigger +//! fan-out after an adapter swap so subscribers stay real-time. pub mod config; +pub mod configuration; pub mod error; pub mod events; pub mod functions; pub mod manifest; +pub mod resync; +pub mod runtime; pub mod service; pub mod store; pub mod types; diff --git a/session-manager/src/main.rs b/session-manager/src/main.rs index 4754d3399..ab0a8d2e6 100644 --- a/session-manager/src/main.rs +++ b/session-manager/src/main.rs @@ -1,41 +1,42 @@ //! `session-manager` binary entry. //! //! Boot sequence: -//! 1. Parse CLI / load YAML config (a missing file falls back to -//! defaults), then resolve the storage backend — a malformed -//! config file or invalid `backend_config` is **fatal** (a -//! misconfigured bridge must never silently fall back to a local -//! fs store). +//! 1. Parse CLI. An optional `--config` YAML file is only a SEED for the +//! first registration; the authoritative config lives in the +//! `configuration` worker. //! 2. Connect to the local iii engine over WebSocket. -//! 3. Register the six public trigger types (`session::created`, ...) -//! — first, because the function handlers capture the subscriber -//! sets they fan out to. -//! 4. Per backend: -//! - **fs**: open the data_dir, register the internal -//! `session::store::events` feed + the `session::store::*` raw -//! protocol, and emit locally (plus envelope fan-out to every -//! attached bridged instance). -//! - **bridge**: connect to the main instance, store via its -//! `session::store::*`, publish events to it -//! (`session::store::publish_events`), and attach a relay to its -//! `session::store::events` feed so local subscribers receive -//! every participant's events. -//! 5. Register the 14 `session::*` functions. -//! 6. Sleep on Ctrl+C, then `shutdown_async` cleanly (both -//! connections in bridge mode). +//! 3. Register the config schema (+ seed) with the `configuration` worker +//! and fetch the authoritative, env-expanded value — an invalid bridge +//! `config` is **fatal** at parse time (a misconfigured bridge must never +//! silently fall back to a local fs store). `configuration` is a required +//! boot dependency. +//! 4. Register the six public trigger types (`session::created`, ...) and the +//! internal `session::store::events` feed — first, because the function +//! handlers and the store protocol capture the subscriber sets. +//! 5. Build the storage + event runtime from the `adapter` config +//! (`build_runtime`) and wrap it in the shared, hot-swappable `AppState`. +//! 6. Register the `session::store::*` raw protocol (mode-gated: served only +//! while in fs mode), the 14 `session::*` functions (which read the live +//! runtime per call), the `configuration` change trigger (which rebuilds +//! and swaps the runtime on an adapter change), and `session::config-status`. +//! 7. Sleep on Ctrl+C, then `shutdown_async` cleanly (both connections when a +//! bridge runtime is live). +//! +//! Because the runtime is swappable, an `adapter` change (fs <-> bridge, a new +//! `data_dir`, a bridge url/timeout) hot-reloads without a restart; the new +//! store's state is replayed through the triggers so subscribers stay live. use std::sync::Arc; use anyhow::{Context, Result}; use clap::Parser; -use iii_sdk::{register_worker, InitOptions, WorkerMetadata, III}; +use iii_sdk::{register_worker, InitOptions}; +use tokio::sync::RwLock; -use session_manager::config::Backend; -use session_manager::events::{Emitter, EventSink, IiiDeliverer, RemotePublisher}; -use session_manager::functions::{store_protocol, Deps}; -use session_manager::service::SessionService; -use session_manager::store::{BridgeStore, FsStore, SessionStore}; -use session_manager::{config, events, functions, manifest}; +use session_manager::configuration::{self, AppState, ConfigCell}; +use session_manager::functions::{self, store_protocol}; +use session_manager::runtime::{build_runtime, worker_metadata, SessionBuildContext}; +use session_manager::{config, events, manifest}; #[derive(Parser, Debug)] #[command( @@ -43,8 +44,11 @@ use session_manager::{config, events, functions, manifest}; about = "Durable, reactive, branching store of typed conversation entries." )] struct Cli { - #[arg(long, default_value = "./config.yaml")] - config: String, + /// Optional seed config.yaml used to populate `initial_value` on the + /// first registration. The AUTHORITATIVE config is always fetched from + /// the `configuration` worker afterward; this file only seeds it. + #[arg(long)] + config: Option, #[arg(long, default_value = "ws://127.0.0.1:49134")] url: String, @@ -53,18 +57,6 @@ struct Cli { manifest: bool, } -fn worker_metadata() -> WorkerMetadata { - WorkerMetadata { - runtime: "rust".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - name: "session-manager".to_string(), - os: std::env::consts::OS.to_string(), - pid: Some(std::process::id()), - telemetry: None, - ..WorkerMetadata::default() - } -} - #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() @@ -82,26 +74,26 @@ async fn main() -> Result<()> { return Ok(()); } - let cfg = match config::load_config(&cli.config) { - Ok(c) => c, - // A missing file is fine (run on defaults); a file that exists - // but doesn't parse is fatal — a typo'd config must never - // silently run the default backend. - Err(e) - if e.downcast_ref::() - .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound) => - { - tracing::warn!(path = %cli.config, "config file not found, using defaults"); - config::WorkerConfig::default() - } - Err(e) => { - return Err(e.context(format!("invalid config {} — refusing to start", cli.config))); - } + // A `--config` file only SEEDS the first registration; a failed parse + // WARNS and falls through to None (the authoritative config comes from the + // configuration worker). The seed IS env-expanded (`${VAR}`). + let seed = match cli.config.as_deref() { + Some(path) => match config::WorkerConfig::from_file(path) { + Ok(c) => { + tracing::info!(path = %path, "loaded seed config for initial registration"); + Some(c) + } + Err(e) => { + tracing::warn!( + path = %path, + error = %e, + "failed to load seed config; relying on the stored configuration entry" + ); + None + } + }, + None => None, }; - let backend = cfg - .resolve_backend() - .context("invalid backend configuration — refusing to start")?; - let cfg = Arc::new(cfg); let iii = Arc::new(register_worker( &cli.url, @@ -111,85 +103,63 @@ async fn main() -> Result<()> { }, )); - // The six public trigger types come first: the function handlers - // capture the subscriber sets they fan out to. The engine replays - // any existing trigger registrations back to us, so the sets - // self-rebuild after a worker restart. + // Register the schema (+ optional seed) and fetch the authoritative, + // env-expanded config. `configuration` is a required boot dependency: a + // failed register/fetch aborts startup. + configuration::register_config(&iii, seed.as_ref()) + .await + .map_err(anyhow::Error::msg) + .context("registering session-manager configuration schema")?; + let cfg = configuration::fetch_config(&iii) + .await + .map_err(anyhow::Error::msg) + .context("loading session-manager configuration")?; + + // The shared config snapshot the service reads list limits from per call. + let config_cell: ConfigCell = Arc::new(RwLock::new(Arc::new(cfg.clone()))); + + // The six public trigger types + the internal store-events feed come + // first: the function handlers and store protocol capture these subscriber + // sets. Both are registered unconditionally (even when booting in bridge + // mode) so a later bridge->fs hot-reload has them ready without dynamic + // registration. The engine replays existing registrations on reconnect, so + // the sets self-rebuild after a worker restart. let sets = events::register_trigger_types(&iii); - - // `remote` outlives the loop so bridge mode can shut it down too. - let mut remote_handle: Option> = None; - - let deps = match backend { - Backend::Fs(fs_cfg) => { - let data_dir = fs_cfg.resolved_data_dir(); - let store: Arc = Arc::new( - FsStore::new(&data_dir) - .with_context(|| format!("open data_dir {}", data_dir.display()))?, - ); - - // Main mode: serve the envelope feed + the raw store - // protocol for bridged instances. - let bridges = events::register_store_events_type(&iii); - let emitter = Arc::new(Emitter::with_bridges( - sets, - Arc::new(IiiDeliverer::new(iii.clone())), - bridges, - )); - store_protocol::register_store_protocol(&iii, store.clone(), emitter.clone()); - - let service = Arc::new(SessionService::new(store, &cfg)); - let sink: Arc = emitter; - tracing::info!(data_dir = %data_dir.display(), "backend: fs (main)"); - Arc::new(Deps { service, sink }) - } - Backend::Bridge(bridge_cfg) => { - // Exact match is unambiguous misconfiguration: the bridge - // would defer storage to itself and hang on the first call. - // (Aliased URLs of the same engine can still slip through — - // this is a best-effort guard.) - if bridge_cfg.url == cli.url { - anyhow::bail!( - "bridge url {} equals the local engine url {} — this instance would defer \ - to itself; fix backend_config.url", - bridge_cfg.url, - cli.url - ); - } - let remote = Arc::new(register_worker( - &bridge_cfg.url, - InitOptions { - metadata: Some(worker_metadata()), - ..InitOptions::default() - }, - )); - remote_handle = Some(remote.clone()); - - let store: Arc = - Arc::new(BridgeStore::new(remote.clone(), bridge_cfg.timeout_ms)); - - // Local emitter serves local subscribers; it is fed by the - // relay (events coming back from the main), never directly - // by the handlers. - let local_emitter = - Arc::new(Emitter::new(sets, Arc::new(IiiDeliverer::new(iii.clone())))); - let relay = events::attach_bridge_relay(&remote, local_emitter); - - let service = Arc::new(SessionService::new(store, &cfg)); - let sink: Arc = - Arc::new(RemotePublisher::new(remote, bridge_cfg.timeout_ms)); - tracing::info!(main = %bridge_cfg.url, relay = %relay, "backend: bridge"); - Arc::new(Deps { service, sink }) - } - }; - - functions::register_all(&iii, &deps); - - tracing::info!("session-manager ready: 14 session::* functions + 6 custom trigger types"); + let bridges = events::register_store_events_type(&iii); + + let ctx = Arc::new(SessionBuildContext { + iii: iii.clone(), + local_url: cli.url.clone(), + sets, + bridge_subscribers: bridges, + config_cell, + }); + + // Build the initial runtime. An invalid bridge `config` (e.g. a self- + // referential url) is fatal here — a misconfigured bridge never silently + // falls back to a local fs store. + let runtime = build_runtime(&cfg, &ctx) + .map_err(anyhow::Error::msg) + .context("building the session-manager storage runtime")?; + let state = AppState::new(runtime, ctx); + + // Raw store protocol (served only while in fs mode), then the 14 public + // functions (each reads the live runtime per call), then the config-change + // trigger and the config-status surface. + store_protocol::register_store_protocol(&iii, state.clone()); + functions::register_all(&iii, &state); + configuration::register_config_trigger(&iii, state.clone()) + .context("registering the configuration change trigger")?; + configuration::register_config_status(&iii, state.clone()); + + tracing::info!( + "session-manager ready: 14 session::* functions + 6 custom trigger types (adapter hot-reloadable)" + ); tokio::signal::ctrl_c().await?; tracing::info!("session-manager shutting down"); - if let Some(remote) = remote_handle { + // Shut down the remote bridge connection too, if a bridge runtime is live. + if let Some(remote) = state.runtime.read().await.bridge_remote.clone() { remote.shutdown_async().await; } iii.shutdown_async().await; diff --git a/session-manager/src/manifest.rs b/session-manager/src/manifest.rs index d4dee9419..681394062 100644 --- a/session-manager/src/manifest.rs +++ b/session-manager/src/manifest.rs @@ -19,11 +19,13 @@ pub fn build_manifest() -> ModuleManifest { "Durable, reactive, branching store of typed conversation entries with six emitted trigger types." .to_string(), // Mirrors config::WorkerConfig::default() field-for-field, - // with backend_config spelled out in its resolved fs shape. + // with the adapter spelled out in its resolved fs shape. default_config: serde_json::json!({ - "backend": "fs", - "backend_config": { - "data_dir": "~/.iii/data/session-manager", + "adapter": { + "name": "fs", + "config": { + "data_dir": "~/.iii/data/session-manager", + }, }, "default_list_limit": 50, "max_list_limit": 500, @@ -55,9 +57,9 @@ mod tests { fn default_config_mirrors_worker_config_default() { let m = build_manifest(); let cfg = WorkerConfig::default(); - assert_eq!(m.default_config["backend"], serde_json::json!("fs")); + assert_eq!(m.default_config["adapter"]["name"], serde_json::json!("fs")); assert_eq!( - m.default_config["backend_config"]["data_dir"], + m.default_config["adapter"]["config"]["data_dir"], serde_json::json!(crate::config::default_data_dir()) ); assert_eq!( @@ -68,5 +70,8 @@ mod tests { m.default_config["max_list_limit"], serde_json::json!(cfg.max_list_limit) ); + // The manifest's hand-written default must stay byte-for-byte the + // serialized default config (catches adapter-shape drift). + assert_eq!(m.default_config, cfg.to_json()); } } diff --git a/session-manager/src/resync.rs b/session-manager/src/resync.rs new file mode 100644 index 000000000..199352ec1 --- /dev/null +++ b/session-manager/src/resync.rs @@ -0,0 +1,286 @@ +//! Post-reload trigger resync. +//! +//! Subscribers (the console, the harness, ...) hold session and transcript +//! state they reconciled from the six `session::*` triggers — they do not poll. +//! When an adapter change swaps the [`SessionStore`] under them, nothing has +//! re-emitted those triggers, so an open view would stay stale until the next +//! mutation even though new events would now flow correctly. +//! +//! [`resync_triggers`] walks the NEW store read-only and replays its current +//! state through the local [`Emitter`]: `session::created` for every live +//! session, then one snapshot event per entry (`message-added` for a fresh +//! entry, `message-updated` for one that had been edited). Sessions that were +//! present before the swap but are gone after it are signalled with +//! `session::deleted`. Consumers reconcile by entry id and highest revision, so +//! a single snapshot per entry is enough to converge an open view. +//! +//! Delivery always goes through the local emitter (never a `RemotePublisher`): +//! a replay is local state, and on an fs-main instance the emitter still fans +//! each envelope out to attached bridges, so they resync too. + +use std::collections::HashSet; + +use crate::events::{ + EmittableEvent, Emitter, MessageAddedEvent, MessageUpdatedEvent, SessionDeletedEvent, + SessionEvent, +}; +use crate::service::{created_event, Clock, SystemClock}; +use crate::store::SessionStore; +use crate::types::{CustomPayload, JsonMap, SessionEntry, SessionMeta}; + +/// What a resync replayed, for an operator-visible log line. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct ResyncStats { + /// Live sessions re-announced via `session::created`. + pub sessions: usize, + /// Entries replayed via `session::message-added` / `message-updated`. + pub entries: usize, + /// Sessions signalled gone via `session::deleted`. + pub deleted: usize, + /// Total events emitted (`sessions + entries + deleted`). + pub events: usize, +} + +/// Replay the new store's current state through `emitter`, plus a +/// `session::deleted` for every id in `old_metas` that is absent from the new +/// store. Read-only: it never mutates the store. +pub async fn resync_triggers( + old_metas: &[SessionMeta], + store: &dyn SessionStore, + emitter: &Emitter, +) -> Result { + let new_metas = store + .list_metas() + .await + .map_err(|e| format!("resync: list_metas on the new store failed: {e}"))?; + let new_ids: HashSet<&str> = new_metas.iter().map(|m| m.session_id.as_str()).collect(); + + let mut events: Vec = Vec::new(); + let mut stats = ResyncStats::default(); + + // 1. Sessions that existed before the swap but not after. + let now = SystemClock.now_ms(); + for old in old_metas { + if !new_ids.contains(old.session_id.as_str()) { + events.push(EmittableEvent { + event: SessionEvent::Deleted(SessionDeletedEvent { + session_id: old.session_id.clone(), + timestamp: now, + }), + session_metadata: old.metadata.clone(), + }); + stats.deleted += 1; + } + } + + // 2/3. Recreate every current session, then replay its entries so open + // transcripts reconcile in place. `created` precedes the session's + // entries because `emit_all` preserves order. + for meta in &new_metas { + events.push(created_event(meta)); + stats.sessions += 1; + let entries = store + .list_entries(&meta.session_id) + .await + .map_err(|e| format!("resync: list_entries({}) failed: {e}", meta.session_id))?; + for entry in &entries { + events.push(entry_event(&meta.session_id, entry, meta.metadata.clone())); + stats.entries += 1; + } + } + + stats.events = events.len(); + emitter.emit_all(&events).await; + Ok(stats) +} + +/// The single snapshot event that replays one stored entry. +fn entry_event( + session_id: &str, + entry: &SessionEntry, + session_metadata: Option, +) -> EmittableEvent { + let event = match entry { + // A message edited since creation: replay only the latest snapshot + // (consumers keep the highest revision per entry). + SessionEntry::Message { + id, + timestamp, + revision, + origin, + message, + .. + } if *revision > 0 => SessionEvent::MessageUpdated(MessageUpdatedEvent { + session_id: session_id.to_string(), + entry_id: id.clone(), + message: message.clone(), + revision: *revision, + origin: origin.clone(), + timestamp: *timestamp, + }), + // A freshly-added message (revision 0). + SessionEntry::Message { + id, + parent_id, + timestamp, + origin, + message, + .. + } => SessionEvent::MessageAdded(MessageAddedEvent { + session_id: session_id.to_string(), + entry_id: id.clone(), + parent_id: parent_id.clone(), + message: Some(message.clone()), + custom: None, + origin: origin.clone(), + timestamp: *timestamp, + }), + // Custom bookkeeping entry: always replayed as an add (update-message + // never applies to custom entries, so revision stays 0). + SessionEntry::Custom { + id, + parent_id, + timestamp, + origin, + custom_type, + data, + .. + } => SessionEvent::MessageAdded(MessageAddedEvent { + session_id: session_id.to_string(), + entry_id: id.clone(), + parent_id: parent_id.clone(), + message: None, + custom: Some(CustomPayload { + custom_type: custom_type.clone(), + data: data.clone(), + }), + origin: origin.clone(), + timestamp: *timestamp, + }), + }; + EmittableEvent { + event, + session_metadata, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + use async_trait::async_trait; + use iii_sdk::TriggerConfig; + use serde_json::{json, Value}; + + use crate::events::{EventDeliverer, EventKind, TriggerSets}; + use crate::store::FsStore; + use crate::types::{AgentMessage, SessionStatus}; + + /// Records the (trigger_type, payload) of every delivery for assertions. + struct RecordingDeliverer { + seen: Mutex>, + } + + #[async_trait] + impl EventDeliverer for RecordingDeliverer { + async fn deliver(&self, trigger_type: &str, _function_id: &str, payload: Value) { + self.seen + .lock() + .unwrap() + .push((trigger_type.to_string(), payload)); + } + } + + fn bind_all(sets: &TriggerSets) { + for kind in EventKind::all() { + sets.for_kind(kind) + .add(TriggerConfig { + id: format!("b-{}", kind.trigger_type()), + function_id: "test::recv".to_string(), + config: json!({}), + metadata: None, + }) + .unwrap(); + } + } + + fn meta(session_id: &str) -> SessionMeta { + SessionMeta { + session_id: session_id.to_string(), + title: "t".to_string(), + description: "d".to_string(), + status: SessionStatus::Idle, + status_reason: None, + metadata: None, + forked_from: None, + created_at: 1, + updated_at: 1, + message_count: 0, + } + } + + fn message(id: &str, revision: u64) -> SessionEntry { + SessionEntry::Message { + id: id.to_string(), + parent_id: None, + timestamp: 2, + revision, + origin: None, + message: AgentMessage::User { + content: vec![], + timestamp: 2, + }, + } + } + + #[tokio::test] + async fn replays_created_added_updated_and_deleted() { + let tmp = tempfile::tempdir().unwrap(); + let store = FsStore::new(tmp.path()).unwrap(); + + // New store: one session with a fresh entry (rev 0) and an edited one (rev 3). + store.put_meta(&meta("s1")).await.unwrap(); + store.put_entry("s1", &message("e1", 0)).await.unwrap(); + store.put_entry("s1", &message("e2", 3)).await.unwrap(); + + let recorder = Arc::new(RecordingDeliverer { + seen: Mutex::new(Vec::new()), + }); + let sets = TriggerSets::new(); + bind_all(&sets); + let emitter = Emitter::new(sets, recorder.clone()); + + // Pre-swap there was also s0, which is gone from the new store. + let old = vec![meta("s0"), meta("s1")]; + let stats = resync_triggers(&old, &store, &emitter).await.unwrap(); + + assert_eq!(stats.deleted, 1, "s0 vanished across the swap"); + assert_eq!(stats.sessions, 1, "s1 re-announced"); + assert_eq!(stats.entries, 2); + assert_eq!(stats.events, 4); + + let seen = recorder.seen.lock().unwrap(); + let kinds: Vec<&str> = seen.iter().map(|(t, _)| t.as_str()).collect(); + assert!(kinds.contains(&"session::deleted")); + assert!(kinds.contains(&"session::created")); + assert!(kinds.contains(&"session::message-added")); + assert!(kinds.contains(&"session::message-updated")); + } + + #[tokio::test] + async fn empty_store_with_no_old_sessions_emits_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let store = FsStore::new(tmp.path()).unwrap(); + let recorder = Arc::new(RecordingDeliverer { + seen: Mutex::new(Vec::new()), + }); + let sets = TriggerSets::new(); + bind_all(&sets); + let emitter = Emitter::new(sets, recorder.clone()); + + let stats = resync_triggers(&[], &store, &emitter).await.unwrap(); + assert_eq!(stats.events, 0); + assert!(recorder.seen.lock().unwrap().is_empty()); + } +} diff --git a/session-manager/src/runtime.rs b/session-manager/src/runtime.rs new file mode 100644 index 000000000..54fc6b088 --- /dev/null +++ b/session-manager/src/runtime.rs @@ -0,0 +1,241 @@ +//! The hot-swappable storage + event-fan-out runtime. +//! +//! Everything the worker derives from the `adapter` half of the config — +//! the [`SessionStore`], the [`SessionService`] over it, the event +//! [`EventSink`], and (bridge mode) the remote connection plus its relay — +//! lives in one [`SessionRuntime`]. [`build_runtime`] constructs it from a +//! [`WorkerConfig`]; [`configuration`](crate::configuration) keeps the live +//! one behind an `Arc>` and swaps a freshly-built one in when the +//! authoritative config changes, so an adapter change (fs <-> bridge, a new +//! `data_dir`, a bridge url/timeout) hot-reloads without a worker restart. +//! +//! Handlers read the live runtime per call (see +//! [`functions::register_all`](crate::functions::register_all)): an in-flight +//! request finishes on the runtime it started with; the next call observes +//! the swap. + +use std::sync::Arc; + +use iii_sdk::{register_worker, InitOptions, WorkerMetadata, III}; + +use crate::config::{StorageAdapter, WorkerConfig}; +use crate::configuration::ConfigCell; +use crate::events::{ + attach_bridge_relay, BridgeSubscribers, Emitter, EventSink, IiiDeliverer, RemotePublisher, + TriggerSets, +}; +use crate::service::SessionService; +use crate::store::{BridgeStore, FsStore, SessionStore}; + +/// Which storage topology a runtime was built for. Gates the internal +/// `session::store::*` protocol: only an authoritative fs-mode instance serves +/// it (a bridge forwarding to itself would recurse). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AdapterMode { + /// Authoritative local fs store; serves the raw store protocol and is the + /// single event fan-out point for any attached bridges. + FsMain, + /// Defers storage and event publishing to a main instance over `remote`. + Bridge, +} + +/// The live storage + event plumbing, rebuilt and swapped on an adapter change. +pub struct SessionRuntime { + /// The config this runtime was built from (its `adapter` half is what the + /// reload path compares to decide rebuild-vs-tune). + pub config: Arc, + /// Domain logic over `store`; what the 14 `session::*` handlers call. + pub service: Arc, + /// Raw store the `session::store::*` protocol serves (fs mode only). + pub store: Arc, + /// Where a mutation's events go: the local [`Emitter`] (fs) or a + /// [`RemotePublisher`] to the main (bridge). + pub sink: Arc, + /// Local trigger fan-out. In fs mode this IS the `sink`; in bridge mode the + /// relay feeds it and the post-reload resync replays through it. + pub local_emitter: Arc, + pub mode: AdapterMode, + /// The remote engine connection (bridge mode only); shut down when this + /// runtime is retired. + pub bridge_remote: Option>, +} + +/// The boot-time wiring `build_runtime` reuses on every rebuild: the engine +/// handle, the six subscriber sets, the bridge relay registry, and the shared +/// config snapshot the service reads list limits from. +pub struct SessionBuildContext { + pub iii: Arc, + /// This instance's own engine url; a bridge whose `url` equals it would + /// defer storage to itself, so that is rejected. + pub local_url: String, + pub sets: TriggerSets, + pub bridge_subscribers: BridgeSubscribers, + pub config_cell: ConfigCell, +} + +/// Worker identity advertised on both the local and (bridge mode) remote +/// connections. +pub fn worker_metadata() -> WorkerMetadata { + WorkerMetadata { + runtime: "rust".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + name: "session-manager".to_string(), + os: std::env::consts::OS.to_string(), + pid: Some(std::process::id()), + telemetry: None, + ..WorkerMetadata::default() + } +} + +/// Build the storage + event runtime for `cfg`'s adapter. Pure of any global +/// state, so the reload path can build a candidate and only swap it in on +/// success (a failed build leaves the running runtime untouched). +pub fn build_runtime( + cfg: &WorkerConfig, + ctx: &SessionBuildContext, +) -> Result { + match cfg.resolve_adapter() { + StorageAdapter::Fs(fs_cfg) => { + let data_dir = fs_cfg.resolved_data_dir(); + let store: Arc = Arc::new( + FsStore::new(&data_dir) + .map_err(|e| format!("open data_dir {}: {e}", data_dir.display()))?, + ); + + // Main mode: the local emitter fans out to subscribers AND to every + // attached bridge, and doubles as the mutation sink. + let emitter = Arc::new(Emitter::with_bridges( + ctx.sets.clone(), + Arc::new(IiiDeliverer::new(ctx.iii.clone())), + ctx.bridge_subscribers.clone(), + )); + let service = Arc::new(SessionService::with_config_cell( + store.clone(), + ctx.config_cell.clone(), + )); + let sink: Arc = emitter.clone(); + tracing::info!(data_dir = %data_dir.display(), "adapter: fs (main)"); + Ok(SessionRuntime { + config: Arc::new(cfg.clone()), + service, + store, + sink, + local_emitter: emitter, + mode: AdapterMode::FsMain, + bridge_remote: None, + }) + } + StorageAdapter::Bridge(bridge_cfg) => { + // Exact match is unambiguous misconfiguration: the bridge would + // defer storage to itself and hang on the first call. (Aliased URLs + // of the same engine can still slip through — best-effort guard.) + if bridge_cfg.url == ctx.local_url { + return Err(format!( + "bridge url {} equals the local engine url {} — this instance would defer \ + to itself; fix the bridge adapter `config.url`", + bridge_cfg.url, ctx.local_url + )); + } + let remote = Arc::new(register_worker( + &bridge_cfg.url, + InitOptions { + metadata: Some(worker_metadata()), + ..InitOptions::default() + }, + )); + + let store: Arc = + Arc::new(BridgeStore::new(remote.clone(), bridge_cfg.timeout_ms)); + + // Local emitter serves local subscribers; it is fed by the relay + // (events coming back from the main), never directly by the handlers. + let local_emitter = Arc::new(Emitter::new( + ctx.sets.clone(), + Arc::new(IiiDeliverer::new(ctx.iii.clone())), + )); + let relay = attach_bridge_relay(&remote, local_emitter.clone()); + + let service = Arc::new(SessionService::with_config_cell( + store.clone(), + ctx.config_cell.clone(), + )); + let sink: Arc = + Arc::new(RemotePublisher::new(remote.clone(), bridge_cfg.timeout_ms)); + tracing::info!(main = %bridge_cfg.url, relay = %relay, "adapter: bridge"); + Ok(SessionRuntime { + config: Arc::new(cfg.clone()), + service, + store, + sink, + local_emitter, + mode: AdapterMode::Bridge, + bridge_remote: Some(remote), + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{BridgeBackendConfig, FsBackendConfig}; + + // register_worker returns immediately without connecting (a background + // thread retries silently), so build_runtime is unit-testable without a + // live engine. + fn ctx(local_url: &str) -> SessionBuildContext { + let iii = Arc::new(register_worker(local_url, InitOptions::default())); + let config_cell = Arc::new(tokio::sync::RwLock::new(Arc::new(WorkerConfig::default()))); + SessionBuildContext { + iii, + local_url: local_url.to_string(), + sets: TriggerSets::new(), + bridge_subscribers: BridgeSubscribers::new(), + config_cell, + } + } + + #[test] + fn build_fs_runtime_opens_data_dir() { + let tmp = tempfile::tempdir().unwrap(); + let cfg = WorkerConfig { + adapter: StorageAdapter::Fs(FsBackendConfig { + data_dir: tmp.path().display().to_string(), + }), + ..WorkerConfig::default() + }; + let rt = build_runtime(&cfg, &ctx("ws://127.0.0.1:59621")).expect("fs runtime builds"); + assert_eq!(rt.mode, AdapterMode::FsMain); + assert!(rt.bridge_remote.is_none()); + } + + #[test] + fn build_bridge_runtime_rejects_self_reference() { + let cfg = WorkerConfig { + adapter: StorageAdapter::Bridge(BridgeBackendConfig { + url: "ws://127.0.0.1:59622".to_string(), + timeout_ms: 5000, + }), + ..WorkerConfig::default() + }; + // A bridge whose url equals the local engine would defer to itself. + let err = build_runtime(&cfg, &ctx("ws://127.0.0.1:59622")) + .err() + .expect("self-referential bridge is rejected"); + assert!(err.contains("defer to itself"), "unexpected error: {err}"); + } + + #[test] + fn build_bridge_runtime_to_distinct_url() { + let cfg = WorkerConfig { + adapter: StorageAdapter::Bridge(BridgeBackendConfig { + url: "ws://127.0.0.1:59624".to_string(), + timeout_ms: 1234, + }), + ..WorkerConfig::default() + }; + let rt = build_runtime(&cfg, &ctx("ws://127.0.0.1:59623")).expect("bridge runtime builds"); + assert_eq!(rt.mode, AdapterMode::Bridge); + assert!(rt.bridge_remote.is_some()); + } +} diff --git a/session-manager/src/service.rs b/session-manager/src/service.rs index 4be45a221..d099eaab4 100644 --- a/session-manager/src/service.rs +++ b/session-manager/src/service.rs @@ -18,9 +18,11 @@ use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine as _; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; use uuid::Uuid; use crate::config::WorkerConfig; +use crate::configuration::ConfigCell; use crate::error::SessionError; use crate::events::{ EmittableEvent, MessageAddedEvent, MessageUpdatedEvent, MetaUpdatedEvent, SessionCreatedEvent, @@ -85,12 +87,20 @@ impl Clock for SystemClock { type ServiceResult = Result<(T, Vec), SessionError>; +/// Wrap a config value in a fresh, standalone [`ConfigCell`] (not shared with +/// a configuration-change trigger). Used by the non-production constructors. +fn new_config_cell(cfg: &WorkerConfig) -> ConfigCell { + Arc::new(RwLock::new(Arc::new(cfg.clone()))) +} + pub struct SessionService { store: Arc, ids: Arc, clock: Arc, - default_list_limit: usize, - max_list_limit: usize, + /// Hot-swappable config snapshot shared with the configuration-change + /// trigger; `list` / `messages` read the current list limits per call so + /// a `configuration::set` of the limits applies without a restart. + config: ConfigCell, /// Per-session mutation locks. Entries are kept for the worker /// lifetime (tens of bytes per touched session) — never removed, so /// two waiters can never end up serialized on different locks. @@ -99,22 +109,38 @@ pub struct SessionService { impl SessionService { pub fn new(store: Arc, cfg: &WorkerConfig) -> Self { - Self::with_parts(store, Arc::new(UuidIds), Arc::new(SystemClock), cfg) + Self::with_config_cell(store, new_config_cell(cfg)) } - /// Full-injection constructor used by tests. + /// Production constructor: shares the hot-swappable [`ConfigCell`] with the + /// configuration-change trigger, so a live `configuration::set` of the + /// list limits is picked up without a restart. + pub fn with_config_cell(store: Arc, config: ConfigCell) -> Self { + Self::with_parts_cell(store, Arc::new(UuidIds), Arc::new(SystemClock), config) + } + + /// Full-injection constructor used by tests. The config is wrapped in a + /// private cell (tests don't exercise hot-reload). pub fn with_parts( store: Arc, ids: Arc, clock: Arc, cfg: &WorkerConfig, + ) -> Self { + Self::with_parts_cell(store, ids, clock, new_config_cell(cfg)) + } + + fn with_parts_cell( + store: Arc, + ids: Arc, + clock: Arc, + config: ConfigCell, ) -> Self { Self { store, ids, clock, - default_list_limit: cfg.default_list_limit, - max_list_limit: cfg.max_list_limit, + config, locks: Mutex::new(HashMap::new()), } } @@ -139,10 +165,11 @@ impl SessionService { .ok_or_else(|| SessionError::NotFound(format!("session {session_id} does not exist"))) } - fn clamp_limit(&self, limit: Option) -> usize { + async fn clamp_limit(&self, limit: Option) -> usize { + let cfg = self.config.read().await; limit - .unwrap_or(self.default_list_limit) - .clamp(1, self.max_list_limit) + .unwrap_or(cfg.default_list_limit) + .clamp(1, cfg.max_list_limit) } // ----------------------------------------------------------------- @@ -228,7 +255,7 @@ impl SessionService { pub async fn list(&self, req: ListRequest) -> ServiceResult { let order = req.order.unwrap_or_default(); - let limit = self.clamp_limit(req.limit); + let limit = self.clamp_limit(req.limit).await; let mut metas = self.store.list_metas().await?; if let Some(status) = req.status { @@ -614,7 +641,7 @@ impl SessionService { } = entry else { return Err(SessionError::InvalidEntryKind(format!( - "entry {} is a custom entry; session::update_message only applies to messages", + "entry {} is a custom entry; session::update-message only applies to messages", req.entry_id ))); }; @@ -747,7 +774,7 @@ impl SessionService { } }; - let limit = self.clamp_limit(req.limit); + let limit = self.clamp_limit(req.limit).await; let end = (start + limit).min(filtered.len()); let page = &filtered[start..end]; let next_cursor = if end < filtered.len() { @@ -973,7 +1000,10 @@ fn active_path<'a>( Ok(path) } -fn created_event(meta: &SessionMeta) -> EmittableEvent { +/// The `session::created` event for a session's metadata. Shared with the +/// post-reload [`resync`](crate::resync) replay so a swap re-announces sessions +/// through the exact event shape a real create fires. +pub(crate) fn created_event(meta: &SessionMeta) -> EmittableEvent { EmittableEvent { event: SessionEvent::Created(SessionCreatedEvent { session_id: meta.session_id.clone(), diff --git a/session-manager/src/store/bridge.rs b/session-manager/src/store/bridge.rs index d60120882..abb4b0571 100644 --- a/session-manager/src/store/bridge.rs +++ b/session-manager/src/store/bridge.rs @@ -187,7 +187,7 @@ mod tests { let store = BridgeStore::new(remote.clone(), 300); let err = store.get_meta("s_x").await.unwrap_err(); assert!( - err.0.contains("session::store::get_meta"), + err.0.contains("session::store::get-meta"), "error should name the failing protocol call: {}", err.0 ); diff --git a/session-manager/tests/common/bridge.rs b/session-manager/tests/common/bridge.rs index fab26f74f..7040b1963 100644 --- a/session-manager/tests/common/bridge.rs +++ b/session-manager/tests/common/bridge.rs @@ -1,6 +1,6 @@ //! In-process "bridged instance" stacks for @engine bridge scenarios. //! -//! Each stack is exactly what a `backend: bridge` binary wires up: +//! Each stack is exactly what a bridge-adapter binary wires up: //! a `BridgeStore` + `RemotePublisher` over the (shared test) engine //! connection, a local `Emitter` fed by a relay attached to the main's //! `session::store::events` feed — except the "local bus" deliverer is diff --git a/session-manager/tests/common/workers.rs b/session-manager/tests/common/workers.rs index de7a4f243..f3fed373a 100644 --- a/session-manager/tests/common/workers.rs +++ b/session-manager/tests/common/workers.rs @@ -13,16 +13,15 @@ use std::sync::Arc; use std::time::Duration; use iii_sdk::{TriggerRequest, III}; -use tokio::sync::OnceCell; +use tokio::sync::{OnceCell, RwLock}; -use session_manager::config::WorkerConfig; +use session_manager::config::{FsBackendConfig, StorageAdapter, WorkerConfig}; +use session_manager::configuration::{AppState, ConfigCell}; use session_manager::events::{ - register_store_events_type, register_trigger_types, BridgeSubscribers, Emitter, EventSink, - IiiDeliverer, + register_store_events_type, register_trigger_types, BridgeSubscribers, }; -use session_manager::functions::{self, store_protocol, Deps}; -use session_manager::service::SessionService; -use session_manager::store::{FsStore, SessionStore}; +use session_manager::functions::{self, store_protocol}; +use session_manager::runtime::{build_runtime, SessionBuildContext}; pub struct Shared { /// The main (fs-mode) instance's data directory; steps read the @@ -40,35 +39,39 @@ static SHARED: OnceCell> = OnceCell::const_new(); pub async fn register_all(iii: &Arc) -> Arc { SHARED .get_or_init(|| async { - let cfg = WorkerConfig::default(); - // Leaked tempdir that lives for the test binary lifetime. let tmp = tempfile::tempdir().expect("create main data_dir tempdir"); let data_dir = tmp.keep(); + let cfg = WorkerConfig { + adapter: StorageAdapter::Fs(FsBackendConfig { + data_dir: data_dir.display().to_string(), + }), + ..WorkerConfig::default() + }; + // Same boot order as src/main.rs in fs mode. let sets = register_trigger_types(iii); let bridges = register_store_events_type(iii); - let emitter = Arc::new(Emitter::with_bridges( + let config_cell: ConfigCell = Arc::new(RwLock::new(Arc::new(cfg.clone()))); + let ctx = Arc::new(SessionBuildContext { + iii: iii.clone(), + local_url: super::engine::ws_url(), sets, - Arc::new(IiiDeliverer::new(iii.clone())), - bridges.clone(), - )); - - let store: Arc = - Arc::new(FsStore::new(&data_dir).expect("open main FsStore")); - store_protocol::register_store_protocol(iii, store.clone(), emitter.clone()); + bridge_subscribers: bridges.clone(), + config_cell, + }); + let runtime = build_runtime(&cfg, &ctx).expect("build main fs runtime"); + let state = AppState::new(runtime, ctx); - let service = Arc::new(SessionService::new(store, &cfg)); - let sink: Arc = emitter; - let deps = Arc::new(Deps { service, sink }); - functions::register_all(iii, &deps); + store_protocol::register_store_protocol(iii, state.clone()); + functions::register_all(iii, &state); // Block until the engine can route to the surface registered // above: registrations flow over one connection in boot // order, so the *last* one being routable means the batch // landed. No fixed sleep — slow runners just poll longer. - wait_until_routable(iii, "session::set_active_leaf").await; + wait_until_routable(iii, "session::set-active-leaf").await; Arc::new(Shared { data_dir, bridges }) }) diff --git a/session-manager/tests/common/world.rs b/session-manager/tests/common/world.rs index 7d052ec32..b3d238b81 100644 --- a/session-manager/tests/common/world.rs +++ b/session-manager/tests/common/world.rs @@ -253,16 +253,16 @@ pub async fn dispatch(deps: &Deps, function: &str, payload: Value) -> Result out(ensure::handle(deps, parse(payload)?).await), "session::get" => out(get::handle(deps, parse(payload)?).await), "session::list" => out(list::handle(deps, parse(payload)?).await), - "session::set_meta" => out(set_meta::handle(deps, parse(payload)?).await), - "session::set_status" => out(set_status::handle(deps, parse(payload)?).await), + "session::set-meta" => out(set_meta::handle(deps, parse(payload)?).await), + "session::set-status" => out(set_status::handle(deps, parse(payload)?).await), "session::delete" => out(delete::handle(deps, parse(payload)?).await), "session::append" => out(append::handle(deps, parse(payload)?).await), - "session::append_many" => out(append_many::handle(deps, parse(payload)?).await), - "session::update_message" => out(update_message::handle(deps, parse(payload)?).await), + "session::append-many" => out(append_many::handle(deps, parse(payload)?).await), + "session::update-message" => out(update_message::handle(deps, parse(payload)?).await), "session::messages" => out(messages::handle(deps, parse(payload)?).await), - "session::get_message" => out(get_message::handle(deps, parse(payload)?).await), + "session::get-message" => out(get_message::handle(deps, parse(payload)?).await), "session::fork" => out(fork::handle(deps, parse(payload)?).await), - "session::set_active_leaf" => out(set_active_leaf::handle(deps, parse(payload)?).await), + "session::set-active-leaf" => out(set_active_leaf::handle(deps, parse(payload)?).await), other => Err(format!("unknown function {other}")), } } diff --git a/session-manager/tests/features/append.feature b/session-manager/tests/features/append.feature index 406afde1a..515794a29 100644 --- a/session-manager/tests/features/append.feature +++ b/session-manager/tests/features/append.feature @@ -61,7 +61,7 @@ Feature: session::append — append one entry, idempotent on entry_id And the response field "entry_id" is "turn1-user" And the response field "parent_id" is null And function "ui::recv" received 1 "session::message-added" delivery - When I call "session::get_message" with: + When I call "session::get-message" with: """ { "session_id": "s_001", "entry_id": "turn1-user" } """ @@ -189,7 +189,7 @@ Feature: session::append — append one entry, idempotent on entry_id "content": [{ "type": "text", "text": "README.md" }], "is_error": false } ], "stop_reason": "end", "model": "m1", "provider": "p1", "timestamp": 2 } } """ - And I call "session::get_message" with: + And I call "session::get-message" with: """ { "session_id": "s_001", "entry_id": "e_001" } """ diff --git a/session-manager/tests/features/append_many.feature b/session-manager/tests/features/append_many.feature index b80b41a16..713df2f9b 100644 --- a/session-manager/tests/features/append_many.feature +++ b/session-manager/tests/features/append_many.feature @@ -1,7 +1,7 @@ @pure -Feature: session::append_many — append several message entries in order +Feature: session::append-many — append several message entries in order - Contract (session-manager.md § session::append_many): messages are + Contract (session-manager.md § session::append-many): messages are appended in request order, each chained to the previous; one session::message-added fires per entry, in order; the response carries every entry id plus last_entry_id; the batch is NOT idempotent (use @@ -17,7 +17,7 @@ Feature: session::append_many — append several message entries in order """ {} """ - When I call "session::append_many" with: + When I call "session::append-many" with: """ { "session_id": "s_001", "messages": [ { "role": "user", "content": [{ "type": "text", "text": "one" }], "timestamp": 1 }, @@ -46,13 +46,13 @@ Feature: session::append_many — append several message entries in order # chaining onto the active leaf. Scenario: a batch chains onto the current active leaf Given a user message "root" appended to "s_001" - When I call "session::append_many" with: + When I call "session::append-many" with: """ { "session_id": "s_001", "messages": [ { "role": "user", "content": [], "timestamp": 2 } ] } """ - And I call "session::get_message" with: + And I call "session::get-message" with: """ { "session_id": "s_001", "entry_id": "e_002" } """ @@ -60,7 +60,7 @@ Feature: session::append_many — append several message entries in order # Prevents: a batch landing under a parent that does not exist. Scenario: a batch under an unknown parent is rejected - When I call "session::append_many" with: + When I call "session::append-many" with: """ { "session_id": "s_001", "parent_id": "ghost", "messages": [ { "role": "user", "content": [], "timestamp": 1 } @@ -70,7 +70,7 @@ Feature: session::append_many — append several message entries in order # Prevents: an empty batch fabricating a last_entry_id out of thin air. Scenario: an empty batch is rejected - When I call "session::append_many" with: + When I call "session::append-many" with: """ { "session_id": "s_001", "messages": [] } """ @@ -79,13 +79,13 @@ Feature: session::append_many — append several message entries in order # Prevents: anyone assuming append_many dedupes — it must not; the # documented redelivery-safe path is append with entry_id. Scenario: append_many is not idempotent by design - When I call "session::append_many" with: + When I call "session::append-many" with: """ { "session_id": "s_001", "messages": [ { "role": "user", "content": [{ "type": "text", "text": "dup" }], "timestamp": 1 } ] } """ - And I call "session::append_many" with: + And I call "session::append-many" with: """ { "session_id": "s_001", "messages": [ { "role": "user", "content": [{ "type": "text", "text": "dup" }], "timestamp": 1 } diff --git a/session-manager/tests/features/branching.feature b/session-manager/tests/features/branching.feature index 36f95348a..f55ff0c72 100644 --- a/session-manager/tests/features/branching.feature +++ b/session-manager/tests/features/branching.feature @@ -1,7 +1,7 @@ @pure -Feature: session::set_active_leaf — branch switching within a session +Feature: session::set-active-leaf — branch switching within a session - Contract (session-manager.md § session::set_active_leaf): moves the + Contract (session-manager.md § session::set-active-leaf): moves the active path to end at a given entry (switching to a non-leaf makes the chain above it the active path). Subsequent session::append without parent_id chains from there. Entries on abandoned branches @@ -16,7 +16,7 @@ Feature: session::set_active_leaf — branch switching within a session # Prevents: branch switches silently landing on the wrong entry. Scenario: switching to a non-leaf truncates the active path - When I call "session::set_active_leaf" with: + When I call "session::set-active-leaf" with: """ { "session_id": "s_001", "entry_id": "e_001" } """ @@ -32,7 +32,7 @@ Feature: session::set_active_leaf — branch switching within a session # Prevents: appends after a branch switch chaining from the OLD leaf, # which would silently merge the branches back together. Scenario: append after a switch creates a sibling branch - Given I call "session::set_active_leaf" with: + Given I call "session::set-active-leaf" with: """ { "session_id": "s_001", "entry_id": "e_001" } """ @@ -50,12 +50,12 @@ Feature: session::set_active_leaf — branch switching within a session # Prevents: abandoned branches being garbage-collected or hidden from # explicit reads — forks and audits depend on them. Scenario: the abandoned branch stays fully readable - Given I call "session::set_active_leaf" with: + Given I call "session::set-active-leaf" with: """ { "session_id": "s_001", "entry_id": "e_001" } """ And a user message "two-alt" appended to "s_001" - When I call "session::get_message" with: + When I call "session::get-message" with: """ { "session_id": "s_001", "entry_id": "e_003" } """ @@ -69,7 +69,7 @@ Feature: session::set_active_leaf — branch switching within a session # Prevents: pointing the active path at entries that don't exist. Scenario: switching to an unknown entry is rejected - When I call "session::set_active_leaf" with: + When I call "session::set-active-leaf" with: """ { "session_id": "s_001", "entry_id": "ghost" } """ @@ -86,7 +86,7 @@ Feature: session::set_active_leaf — branch switching within a session """ {} """ - When I call "session::set_active_leaf" with: + When I call "session::set-active-leaf" with: """ { "session_id": "s_001", "entry_id": "e_001" } """ diff --git a/session-manager/tests/features/delete.feature b/session-manager/tests/features/delete.feature index 541dbdc3d..0925a5711 100644 --- a/session-manager/tests/features/delete.feature +++ b/session-manager/tests/features/delete.feature @@ -29,7 +29,7 @@ Feature: session::delete — delete a session and its entries { "session_id": "s_001" } """ Then the response is null - When I call "session::get_message" with: + When I call "session::get-message" with: """ { "session_id": "s_001", "entry_id": "e_001" } """ diff --git a/session-manager/tests/features/engine_bridge.feature b/session-manager/tests/features/engine_bridge.feature index acc637a7b..7b1655b57 100644 --- a/session-manager/tests/features/engine_bridge.feature +++ b/session-manager/tests/features/engine_bridge.feature @@ -3,7 +3,7 @@ Feature: bridge backend — storage deferral and event propagation A bridged instance keeps all domain logic locally but stores through the main instance's session::store::* protocol, publishes its events - to the main (session::store::publish_events), and receives EVERY + to the main (session::store::publish-events), and receives EVERY participant's events back through the main's session::store::events feed via its relay. The main is the single fan-out point: with multiple bridged instances attached, a mutation made anywhere reaches @@ -158,7 +158,7 @@ Feature: bridge backend — storage deferral and event propagation """ { "session_id": "${S1}" } """ - When over the engine I call "session::set_status" with: + When over the engine I call "session::set-status" with: """ { "session_id": "${S1}", "status": "working" } """ diff --git a/session-manager/tests/features/engine_reactivity.feature b/session-manager/tests/features/engine_reactivity.feature index ea9ea8720..f95777697 100644 --- a/session-manager/tests/features/engine_reactivity.feature +++ b/session-manager/tests/features/engine_reactivity.feature @@ -34,7 +34,7 @@ Feature: engine reactivity — real trigger bindings receive filtered events { "title": "live turn", "metadata": { "test_run": "${M1}" } } """ And over the engine I alias the response field "session_id" as "S1" - And over the engine I call "session::set_status" with: + And over the engine I call "session::set-status" with: """ { "session_id": "${S1}", "status": "working" } """ @@ -49,17 +49,17 @@ Feature: engine reactivity — real trigger bindings receive filtered events "message": { "role": "assistant", "content": [], "stop_reason": "end", "model": "m", "provider": "p", "timestamp": 2 } } """ - And over the engine I call "session::update_message" with: + And over the engine I call "session::update-message" with: """ { "session_id": "${S1}", "entry_id": "reply-1", "content": [{ "type": "text", "text": "It" }] } """ - And over the engine I call "session::update_message" with: + And over the engine I call "session::update-message" with: """ { "session_id": "${S1}", "entry_id": "reply-1", "content": [{ "type": "text", "text": "It works." }] } """ - And over the engine I call "session::set_status" with: + And over the engine I call "session::set-status" with: """ { "session_id": "${S1}", "status": "done" } """ diff --git a/session-manager/tests/features/engine_roundtrip.feature b/session-manager/tests/features/engine_roundtrip.feature index e7a69dc6c..f96396be1 100644 --- a/session-manager/tests/features/engine_roundtrip.feature +++ b/session-manager/tests/features/engine_roundtrip.feature @@ -28,14 +28,14 @@ Feature: engine roundtrip — every function over the bus, persisted as JSONL And the main store file for session "${S1}" exists And the latest meta record for session "${S1}" has "title" = "engine roundtrip" - When over the engine I call "session::set_meta" with: + When over the engine I call "session::set-meta" with: """ { "session_id": "${S1}", "title": "renamed live" } """ Then the engine call succeeds And the latest meta record for session "${S1}" has "title" = "renamed live" - When over the engine I call "session::set_status" with: + When over the engine I call "session::set-status" with: """ { "session_id": "${S1}", "status": "working" } """ @@ -88,7 +88,7 @@ Feature: engine roundtrip — every function over the bus, persisted as JSONL And the latest record for entry "idem-1" of session "${S1}" has "message.content.0.text" = "hello engine" And the latest meta record for session "${S1}" has "message_count" = 1 - When over the engine I call "session::update_message" with: + When over the engine I call "session::update-message" with: """ { "session_id": "${S1}", "entry_id": "idem-1", "content": [{ "type": "text", "text": "edited" }], "expected_revision": 0 } @@ -105,7 +105,7 @@ Feature: engine roundtrip — every function over the bus, persisted as JSONL Then the engine response field "messages" has length 1 And the engine response field "messages.0.message.content.0.text" is "edited" - When over the engine I call "session::get_message" with: + When over the engine I call "session::get-message" with: """ { "session_id": "${S1}", "entry_id": "idem-1" } """ @@ -167,7 +167,7 @@ Feature: engine roundtrip — every function over the bus, persisted as JSONL # (callers branch on the session/* prefix programmatically). Scenario: bus errors carry the stable session/* codes Given the iii engine is reachable - When over the engine I call "session::set_status" with: + When over the engine I call "session::set-status" with: """ { "session_id": "definitely-not-a-session", "status": "working" } """ diff --git a/session-manager/tests/features/errors.feature b/session-manager/tests/features/errors.feature index 3dc87e73f..1d8066050 100644 --- a/session-manager/tests/features/errors.feature +++ b/session-manager/tests/features/errors.feature @@ -10,22 +10,22 @@ Feature: error codes — stable session/* codes on the bus # Prevents: unknown-session errors mutating into panics, silent # successes, or code drift across the mutation surface. Scenario: every mutation on an unknown session reports session/not_found - When I call "session::set_meta" with: + When I call "session::set-meta" with: """ { "session_id": "ghost", "title": "x" } """ Then the call fails with code "session/not_found" - When I call "session::set_status" with: + When I call "session::set-status" with: """ { "session_id": "ghost", "status": "working" } """ Then the call fails with code "session/not_found" - When I call "session::append_many" with: + When I call "session::append-many" with: """ { "session_id": "ghost", "messages": [ { "role": "user", "content": [], "timestamp": 1 } ] } """ Then the call fails with code "session/not_found" - When I call "session::update_message" with: + When I call "session::update-message" with: """ { "session_id": "ghost", "entry_id": "e", "content": [] } """ @@ -40,7 +40,7 @@ Feature: error codes — stable session/* codes on the bus { "session_id": "ghost", "entry_id": "e" } """ Then the call fails with code "session/not_found" - When I call "session::set_active_leaf" with: + When I call "session::set-active-leaf" with: """ { "session_id": "ghost", "entry_id": "e" } """ @@ -55,7 +55,7 @@ Feature: error codes — stable session/* codes on the bus Then the call succeeds And the response is null Given a bare session - When I call "session::get_message" with: + When I call "session::get-message" with: """ { "session_id": "s_001", "entry_id": "ghost" } """ @@ -79,7 +79,7 @@ Feature: error codes — stable session/* codes on the bus # Prevents: unknown enum values (status, role, order) being coerced. Scenario: unknown enum values are rejected Given a bare session - When I call "session::set_status" with: + When I call "session::set-status" with: """ { "session_id": "s_001", "status": "halted" } """ diff --git a/session-manager/tests/features/fork.feature b/session-manager/tests/features/fork.feature index f3492ecb7..d05c00e9d 100644 --- a/session-manager/tests/features/fork.feature +++ b/session-manager/tests/features/fork.feature @@ -39,7 +39,7 @@ Feature: session::fork — copy history up to an entry into a new session And the response field "messages.0.entry_id" is "e_004" And the response field "messages.0.message.content.0.text" is "one" And the response field "messages.1.entry_id" is "e_005" - When I call "session::get_message" with: + When I call "session::get-message" with: """ { "session_id": "s_002", "entry_id": "e_005" } """ @@ -88,7 +88,7 @@ Feature: session::fork — copy history up to an entry into a new session { "session_id": "s_001", "entry_id": "e_002" } """ When I append a user message "only in source" to "s_001" - And I call "session::update_message" with: + And I call "session::update-message" with: """ { "session_id": "s_002", "entry_id": "e_004", "content": [{ "type": "text", "text": "edited only in fork" }] } @@ -99,7 +99,7 @@ Feature: session::fork — copy history up to an entry into a new session """ Then the response field "messages" has length 2 And the response field "messages.0.message.content.0.text" is "edited only in fork" - When I call "session::get_message" with: + When I call "session::get-message" with: """ { "session_id": "s_001", "entry_id": "e_001" } """ @@ -131,7 +131,7 @@ Feature: session::fork — copy history up to an entry into a new session # Prevents: forking from a mid-path entry dragging along entries from # sibling branches that are not on the root -> entry path. Scenario: only the path to the fork point is copied - Given I call "session::set_active_leaf" with: + Given I call "session::set-active-leaf" with: """ { "session_id": "s_001", "entry_id": "e_001" } """ @@ -151,7 +151,7 @@ Feature: session::fork — copy history up to an entry into a new session # Prevents: forks copying live revision counters — fresh copies start # a fresh revision space at 0. Scenario: copied entries restart their revision at 0 - Given I call "session::update_message" with: + Given I call "session::update-message" with: """ { "session_id": "s_001", "entry_id": "e_002", "content": [{ "type": "text", "text": "rev1" }] } @@ -160,7 +160,7 @@ Feature: session::fork — copy history up to an entry into a new session """ { "session_id": "s_001", "entry_id": "e_002" } """ - And I call "session::get_message" with: + And I call "session::get-message" with: """ { "session_id": "s_002", "entry_id": "e_005" } """ diff --git a/session-manager/tests/features/list.feature b/session-manager/tests/features/list.feature index f73db776e..5e6c49d2c 100644 --- a/session-manager/tests/features/list.feature +++ b/session-manager/tests/features/list.feature @@ -42,7 +42,7 @@ Feature: session::list — pagination, ordering and filters Scenario: status filters to exactly that status Given a bare session And a bare session - And I call "session::set_status" with: + And I call "session::set-status" with: """ { "session_id": "s_002", "status": "working" } """ diff --git a/session-manager/tests/features/manifest.feature b/session-manager/tests/features/manifest.feature index 22e34b38b..497803da2 100644 --- a/session-manager/tests/features/manifest.feature +++ b/session-manager/tests/features/manifest.feature @@ -10,7 +10,7 @@ Feature: --manifest registry contract When I run the worker binary with --manifest Then the manifest has every required registry field And the response field "name" is "session-manager" - And the response field "default_config.backend" is "fs" - And the response field "default_config.backend_config.data_dir" is "~/.iii/data/session-manager" + And the response field "default_config.adapter.name" is "fs" + And the response field "default_config.adapter.config.data_dir" is "~/.iii/data/session-manager" And the response field "default_config.default_list_limit" is 50 And the response field "default_config.max_list_limit" is 500 diff --git a/session-manager/tests/features/meta.feature b/session-manager/tests/features/meta.feature index ce4b35055..34e8c8d0e 100644 --- a/session-manager/tests/features/meta.feature +++ b/session-manager/tests/features/meta.feature @@ -1,7 +1,7 @@ @pure -Feature: session::set_meta — update title, description and metadata +Feature: session::set-meta — update title, description and metadata - Contract (session-manager.md § session::set_meta): updates + Contract (session-manager.md § session::set-meta): updates title/description/metadata (e.g. once a titling worker generates them from the first exchange). Does not change status or messages. Fires session::meta-updated so consumers render new titles live instead of @@ -21,7 +21,7 @@ Feature: session::set_meta — update title, description and metadata {} """ Given the clock advances by 250 ms - When I call "session::set_meta" with: + When I call "session::set-meta" with: """ { "session_id": "s_001", "title": "Weather question", "description": "asks about rain" } """ @@ -38,7 +38,7 @@ Feature: session::set_meta — update title, description and metadata # Prevents: metadata being merged instead of replaced — stale tenancy # keys surviving a replace is a security hazard. Scenario: a supplied metadata object replaces the stored one wholesale - When I call "session::set_meta" with: + When I call "session::set-meta" with: """ { "session_id": "s_001", "metadata": { "owner": "u_2" } } """ @@ -47,7 +47,7 @@ Feature: session::set_meta — update title, description and metadata # Prevents: partial updates clobbering fields that were not supplied. Scenario: omitted fields keep their current values - When I call "session::set_meta" with: + When I call "session::set-meta" with: """ { "session_id": "s_001", "title": "only title changed" } """ @@ -61,7 +61,7 @@ Feature: session::set_meta — update title, description and metadata """ {} """ - When I call "session::set_meta" with: + When I call "session::set-meta" with: """ { "session_id": "s_001" } """ @@ -71,12 +71,12 @@ Feature: session::set_meta — update title, description and metadata # Prevents: set_meta bleeding into status or messages. Scenario: set_meta never touches status or messages - Given I call "session::set_status" with: + Given I call "session::set-status" with: """ { "session_id": "s_001", "status": "working" } """ And a user message "hi" appended to "s_001" - When I call "session::set_meta" with: + When I call "session::set-meta" with: """ { "session_id": "s_001", "title": "T2" } """ @@ -91,7 +91,7 @@ Feature: session::set_meta — update title, description and metadata """ { "metadata": { "owner": "u_2" } } """ - When I call "session::set_meta" with: + When I call "session::set-meta" with: """ { "session_id": "s_001", "metadata": { "owner": "u_2" } } """ @@ -100,7 +100,7 @@ Feature: session::set_meta — update title, description and metadata # Prevents: updating sessions that don't exist. Scenario: set_meta on an unknown session is rejected - When I call "session::set_meta" with: + When I call "session::set-meta" with: """ { "session_id": "s_404", "title": "x" } """ diff --git a/session-manager/tests/features/persistence.feature b/session-manager/tests/features/persistence.feature index e3acadb8d..3ef7ce9c5 100644 --- a/session-manager/tests/features/persistence.feature +++ b/session-manager/tests/features/persistence.feature @@ -29,16 +29,16 @@ Feature: fs backend persistence — one JSONL file per session, replayed on rest """ And a user message "one" appended to "s_001" And an empty assistant message appended to "s_001" - And I call "session::update_message" with: + And I call "session::update-message" with: """ { "session_id": "s_001", "entry_id": "e_002", "content": [{ "type": "text", "text": "streamed" }] } """ - And I call "session::set_status" with: + And I call "session::set-status" with: """ { "session_id": "s_001", "status": "done" } """ - And I call "session::set_active_leaf" with: + And I call "session::set-active-leaf" with: """ { "session_id": "s_001", "entry_id": "e_001" } """ @@ -53,7 +53,7 @@ Feature: fs backend persistence — one JSONL file per session, replayed on rest And the response field "meta.status" is "done" And the response field "meta.message_count" is 2 And the response field "meta.metadata.owner" is "u_1" - When I call "session::get_message" with: + When I call "session::get-message" with: """ { "session_id": "s_001", "entry_id": "e_002" } """ diff --git a/session-manager/tests/features/status.feature b/session-manager/tests/features/status.feature index 291c6d0a0..ef876d99f 100644 --- a/session-manager/tests/features/status.feature +++ b/session-manager/tests/features/status.feature @@ -1,7 +1,7 @@ @pure -Feature: session::set_status — the coarse lifecycle status +Feature: session::set-status — the coarse lifecycle status - Contract (session-manager.md § Session status / session::set_status): + Contract (session-manager.md § Session status / session::set-status): idle -> working -> done/error is driven by the harness; consumers render it directly (spinner, done badge, list filter). set_status is a NO-OP (no event) when the status is unchanged. reason is stored as @@ -18,7 +18,7 @@ Feature: session::set_status — the coarse lifecycle status """ {} """ - When I call "session::set_status" with: + When I call "session::set-status" with: """ { "session_id": "s_001", "status": "working" } """ @@ -37,11 +37,11 @@ Feature: session::set_status — the coarse lifecycle status """ {} """ - Given I call "session::set_status" with: + Given I call "session::set-status" with: """ { "session_id": "s_001", "status": "working" } """ - When I call "session::set_status" with: + When I call "session::set-status" with: """ { "session_id": "s_001", "status": "working" } """ @@ -58,11 +58,11 @@ Feature: session::set_status — the coarse lifecycle status """ {} """ - Given I call "session::set_status" with: + Given I call "session::set-status" with: """ { "session_id": "s_001", "status": "error", "reason": "rate limited" } """ - When I call "session::set_status" with: + When I call "session::set-status" with: """ { "session_id": "s_001", "status": "error", "reason": "DIFFERENT" } """ @@ -80,7 +80,7 @@ Feature: session::set_status — the coarse lifecycle status """ {} """ - When I call "session::set_status" with: + When I call "session::set-status" with: """ { "session_id": "s_001", "status": "error", "reason": "provider quota exhausted" } """ @@ -95,11 +95,11 @@ Feature: session::set_status — the coarse lifecycle status # Prevents: stale error reasons surviving recovery — a session back at # work must not still advertise last week's failure. Scenario: leaving error clears the stored reason - Given I call "session::set_status" with: + Given I call "session::set-status" with: """ { "session_id": "s_001", "status": "error", "reason": "boom" } """ - When I call "session::set_status" with: + When I call "session::set-status" with: """ { "session_id": "s_001", "status": "working" } """ @@ -117,11 +117,11 @@ Feature: session::set_status — the coarse lifecycle status """ {} """ - When I call "session::set_status" with: + When I call "session::set-status" with: """ { "session_id": "s_001", "status": "working" } """ - And I call "session::set_status" with: + And I call "session::set-status" with: """ { "session_id": "s_001", "status": "done" } """ @@ -132,7 +132,7 @@ Feature: session::set_status — the coarse lifecycle status # Prevents: setting status on sessions that don't exist. Scenario: set_status on an unknown session is rejected - When I call "session::set_status" with: + When I call "session::set-status" with: """ { "session_id": "s_404", "status": "working" } """ diff --git a/session-manager/tests/features/streaming.feature b/session-manager/tests/features/streaming.feature index ff65b3095..36e05feee 100644 --- a/session-manager/tests/features/streaming.feature +++ b/session-manager/tests/features/streaming.feature @@ -39,27 +39,27 @@ Feature: the reactivity model — streaming a reply end to end { "title": "Weather question" } """ And I append a user message "What's the weather?" to "s_001" - And I call "session::set_status" with: + And I call "session::set-status" with: """ { "session_id": "s_001", "status": "working" } """ And I append an empty assistant message to "s_001" - And I call "session::update_message" with: + And I call "session::update-message" with: """ { "session_id": "s_001", "entry_id": "e_002", "content": [{ "type": "text", "text": "It" }] } """ - And I call "session::update_message" with: + And I call "session::update-message" with: """ { "session_id": "s_001", "entry_id": "e_002", "content": [{ "type": "text", "text": "It looks" }] } """ - And I call "session::update_message" with: + And I call "session::update-message" with: """ { "session_id": "s_001", "entry_id": "e_002", "content": [{ "type": "text", "text": "It looks sunny." }] } """ - And I call "session::set_status" with: + And I call "session::set-status" with: """ { "session_id": "s_001", "status": "done" } """ diff --git a/session-manager/tests/features/update_message.feature b/session-manager/tests/features/update_message.feature index 08b7a6788..3c6110857 100644 --- a/session-manager/tests/features/update_message.feature +++ b/session-manager/tests/features/update_message.feature @@ -1,7 +1,7 @@ @pure -Feature: session::update_message — streaming deltas and edited output +Feature: session::update-message — streaming deltas and edited output - Contract (session-manager.md § session::update_message): replaces the + Contract (session-manager.md § session::update-message): replaces the content (and optionally details) of an existing message entry. Each successful update increments the entry's revision (echoed on the event, monotonic per entry — consumers keep the highest). With @@ -21,14 +21,14 @@ Feature: session::update_message — streaming deltas and edited output """ {} """ - When I call "session::update_message" with: + When I call "session::update-message" with: """ { "session_id": "s_001", "entry_id": "e_001", "content": [{ "type": "text", "text": "Hel" }] } """ Then the response field "updated" is true And the response field "revision" is 1 - When I call "session::update_message" with: + When I call "session::update-message" with: """ { "session_id": "s_001", "entry_id": "e_001", "content": [{ "type": "text", "text": "Hello world" }] } @@ -39,7 +39,7 @@ Feature: session::update_message — streaming deltas and edited output And delivery 0 to "ui::recv" has "message.content.0.text" = "Hel" And delivery 1 to "ui::recv" has "revision" = 2 And delivery 1 to "ui::recv" has "message.content.0.text" = "Hello world" - When I call "session::get_message" with: + When I call "session::get-message" with: """ { "session_id": "s_001", "entry_id": "e_001" } """ @@ -51,12 +51,12 @@ Feature: session::update_message — streaming deltas and edited output # update time. Scenario: an update never changes the entry's creation timestamp Given the clock advances by 5000 ms - When I call "session::update_message" with: + When I call "session::update-message" with: """ { "session_id": "s_001", "entry_id": "e_001", "content": [{ "type": "text", "text": "x" }] } """ - And I call "session::get_message" with: + And I call "session::get-message" with: """ { "session_id": "s_001", "entry_id": "e_001" } """ @@ -70,12 +70,12 @@ Feature: session::update_message — streaming deltas and edited output """ {} """ - Given I call "session::update_message" with: + Given I call "session::update-message" with: """ { "session_id": "s_001", "entry_id": "e_001", "content": [{ "type": "text", "text": "winner" }], "expected_revision": 0 } """ - When I call "session::update_message" with: + When I call "session::update-message" with: """ { "session_id": "s_001", "entry_id": "e_001", "content": [{ "type": "text", "text": "loser" }], "expected_revision": 0 } @@ -84,7 +84,7 @@ Feature: session::update_message — streaming deltas and edited output And the response field "updated" is false And the response field "revision" is 1 And function "ui::recv" received 1 "session::message-updated" delivery - When I call "session::get_message" with: + When I call "session::get-message" with: """ { "session_id": "s_001", "entry_id": "e_001" } """ @@ -100,14 +100,14 @@ Feature: session::update_message — streaming deltas and edited output "content": [{ "type": "text", "text": "long output" }], "details": { "full": true }, "is_error": false, "timestamp": 5 } } """ - When I call "session::update_message" with: + When I call "session::update-message" with: """ { "session_id": "s_001", "entry_id": "e_002", "content": [{ "type": "text", "text": "[pruned]" }], "details": { "compacted_at": 1000123 } } """ Then the response field "updated" is true - When I call "session::get_message" with: + When I call "session::get-message" with: """ { "session_id": "s_001", "entry_id": "e_002" } """ @@ -118,7 +118,7 @@ Feature: session::update_message — streaming deltas and edited output # field (they would vanish on the next read). Scenario: details on a user message are rejected Given a user message "plain" appended to "s_001" - When I call "session::update_message" with: + When I call "session::update-message" with: """ { "session_id": "s_001", "entry_id": "e_002", "content": [], "details": { "x": 1 } } @@ -129,7 +129,7 @@ Feature: session::update_message — streaming deltas and edited output # surface. Scenario: updating a custom entry is rejected Given a custom entry of type "compaction" appended to "s_001" - When I call "session::update_message" with: + When I call "session::update-message" with: """ { "session_id": "s_001", "entry_id": "e_002", "content": [] } """ @@ -138,12 +138,12 @@ Feature: session::update_message — streaming deltas and edited output # Prevents: updates against missing entries/sessions failing silently # or fabricating entries. Scenario: unknown entry and unknown session are rejected - When I call "session::update_message" with: + When I call "session::update-message" with: """ { "session_id": "s_001", "entry_id": "ghost", "content": [] } """ Then the call fails with code "session/entry_not_found" - When I call "session::update_message" with: + When I call "session::update-message" with: """ { "session_id": "s_404", "entry_id": "e_001", "content": [] } """ diff --git a/tech-specs/2026-06-agentic/harness.md b/tech-specs/2026-06-agentic/harness.md index aa1d04d76..ea166c493 100644 --- a/tech-specs/2026-06-agentic/harness.md +++ b/tech-specs/2026-06-agentic/harness.md @@ -40,7 +40,7 @@ sequenceDiagram H->>S: session::append (user message) H-->>C: {session_id, turn_id} Note over H: enqueue harness::turn (durable) - H->>S: session::set_status working + H->>S: session::set-status working H->>S: session::messages H->>X: context::assemble opt assemble compacted the head @@ -48,7 +48,7 @@ sequenceDiagram end H->>R: router::chat (over channel) R-->>H: AssistantMessageEvent frames - H->>S: session::append (assistant) then session::update_message (stream deltas) + H->>S: session::append (assistant) then session::update-message (stream deltas) alt assistant requested function calls H->>F: iii.trigger(function_id, args) F-->>H: result @@ -57,7 +57,7 @@ sequenceDiagram else pending dispatch (e.g. harness::spawn) Note over H: park turn — child session runs its own loop;
harness::function::resolve re-enqueues else no function calls - H->>S: session::set_status done + H->>S: session::set-status done end ``` @@ -71,10 +71,10 @@ with the call held open until the turn ends; [`harness::spawn`](#harnessspawn) s session through the same CAS. The loop runs as durable enqueued steps so a crash or restart resumes mid-turn (see [Durability & idempotency](#durability--idempotency)). Every `session::append` / -`session::update_message` the loop issues carries `origin: { turn_id }`, so session events are +`session::update-message` the loop issues carries `origin: { turn_id }`, so session events are attributable to a turn. One `harness::turn` step does: -1. Mark working: `session::set_status working` and emit +1. Mark working: `session::set-status working` and emit [`harness::turn_started`](#trigger-types-emitted) (first step of a turn), then run the `pre_turn` [hook chain](#hooks) — a `deny` ends the turn (`failed`, with the hook's reason) before any model spend. @@ -97,7 +97,7 @@ attributable to a turn. One `harness::turn` step does: the turn record as `stream_request_id` for [`harness::stop`](#harnessstop)) and — when the [output contract](#output-contract) rides provider-native structured output — `response_format`; `session::append` an - assistant message, then `session::update_message` as deltas arrive (each fires + assistant message, then `session::update-message` as deltas arrive (each fires `session::message-updated`). Deltas may be batched to throttle update frequency; the final update writes the complete `AssistantMessage`. After the final update, run the read-only `post_generate` [hook chain](#hooks) (usage accounting, safety logging). @@ -120,7 +120,7 @@ attributable to a turn. One `harness::turn` step does: `watermark_entry_id` (see [Concurrency & steering](#concurrency--steering)); if present, continue with another generate step. Otherwise finalise: resolve the turn `result` per the [output contract](#output-contract) (a schema-bearing contract with no valid result yet nudges - instead, bounded), mark the turn `completed`, `session::set_status done`, emit + instead, bounded), mark the turn `completed`, `session::set-status done`, emit [`harness::turn_completed`](#trigger-types-emitted), and — for a sub-agent turn — resolve the parent's pending call (see [Sub-agents](#sub-agents-harnessspawn)). @@ -129,7 +129,7 @@ is cooperative *between* steps and explicit *during* generation: `harness::stop` the next step checks, and when a stream is in flight it also calls [`router::abort`](llm-router.md#routerabort) with the `stream_request_id` recorded on the turn record. The generate step then finalises the partial assistant message (`stop_reason: "aborted"`), -records `TurnStatus` `cancelled`, and sets `session::set_status done`. When the turn has live +records `TurnStatus` `cancelled`, and sets `session::set-status done`. When the turn has live spawned children, the stop cascades to them before the turn finalises (see [Sub-agents](#sub-agents-harnessspawn)). @@ -178,7 +178,7 @@ step must tolerate it. The rules: message of a generate step is `e___assistant`; a `function_result` is `e__`. A redelivered step therefore writes into the same entries instead of duplicating them: if the deterministic assistant entry already exists, the resumed - generate step streams into it via `session::update_message` rather than appending a second + generate step streams into it via `session::update-message` rather than appending a second message — a crash never yields two assistant messages. - **Per-call checkpoints.** The turn record carries `calls: Record - session::update_message + session::update-message revision 7 — one write, no publish step diff --git a/tech-specs/2026-06-agentic/presentation/src/content/workers.ts b/tech-specs/2026-06-agentic/presentation/src/content/workers.ts index df9e9899b..48b7c6ef4 100644 --- a/tech-specs/2026-06-agentic/presentation/src/content/workers.ts +++ b/tech-specs/2026-06-agentic/presentation/src/content/workers.ts @@ -59,9 +59,9 @@ export const WORKERS: Record = { install: 'iii worker add session-manager', functions: [ { id: 'session::append', desc: 'append one entry; fires session::message-added.' }, - { id: 'session::update_message', desc: 'stream deltas into an entry; fires session::message-updated.' }, + { id: 'session::update-message', desc: 'stream deltas into an entry; fires session::message-updated.' }, { id: 'session::messages', desc: 'load the active path, oldest first.' }, - { id: 'session::set_status', desc: 'idle / working / done / error; fires session::status-changed.' }, + { id: 'session::set-status', desc: 'idle / working / done / error; fires session::status-changed.' }, { id: 'session::fork', desc: 'branch history into a new session.' }, { id: 'session::create / get / list / delete', desc: 'lifecycle, pagination, tenancy filters.' }, ], diff --git a/tech-specs/2026-06-agentic/presentation/src/pages/ConsolePage.tsx b/tech-specs/2026-06-agentic/presentation/src/pages/ConsolePage.tsx index 80f5a8fcd..f4d6ef038 100644 --- a/tech-specs/2026-06-agentic/presentation/src/pages/ConsolePage.tsx +++ b/tech-specs/2026-06-agentic/presentation/src/pages/ConsolePage.tsx @@ -22,7 +22,7 @@ const STEPS: SeqStep[] = [ { from: 'harness', to: 'session', - label: 'session::update_message', + label: 'session::update-message', title: 'the transcript streams', desc: 'deltas persist as they arrive; the console renders snapshots last-write-wins by revision. thinking content renders as a collapsible block, function calls as live cards.', event: 'session::message-updated', @@ -45,7 +45,7 @@ const STEPS: SeqStep[] = [ { from: 'harness', to: 'session', - label: 'session::set_status done', + label: 'session::set-status done', title: 'the turn closes, fully traceable', desc: 'the spinner stops on the status event, and the session id deep-links into the trace explorer — every hop of the turn (send, assemble, generate, dispatch) is a span you can open.', event: 'harness::turn_completed', diff --git a/tech-specs/2026-06-agentic/presentation/src/pages/TelegramPage.tsx b/tech-specs/2026-06-agentic/presentation/src/pages/TelegramPage.tsx index f17adbbef..1da340c9e 100644 --- a/tech-specs/2026-06-agentic/presentation/src/pages/TelegramPage.tsx +++ b/tech-specs/2026-06-agentic/presentation/src/pages/TelegramPage.tsx @@ -44,7 +44,7 @@ const STEPS: SeqStep[] = [ { from: 'harness', to: 'session', - label: 'session::update_message', + label: 'session::update-message', title: 'the agent streams into the store', desc: 'the loop persists deltas as they arrive — and each write emits the event the worker is already bound to.', event: 'session::message-updated', diff --git a/tech-specs/2026-06-agentic/presentation/src/sections/TurnSection.tsx b/tech-specs/2026-06-agentic/presentation/src/sections/TurnSection.tsx index 2278c0321..00bd21cbe 100644 --- a/tech-specs/2026-06-agentic/presentation/src/sections/TurnSection.tsx +++ b/tech-specs/2026-06-agentic/presentation/src/sections/TurnSection.tsx @@ -29,7 +29,7 @@ const STEPS: SeqStep[] = [ { from: 'harness', to: 'session', - label: 'session::set_status working', + label: 'session::set-status working', title: 'the session goes live', desc: 'status flips to working and the turn-started event fires. every bound consumer shows a spinner without asking anyone — the write itself is the notification.', event: 'harness::turn_started', @@ -58,7 +58,7 @@ const STEPS: SeqStep[] = [ { from: 'harness', to: 'session', - label: 'session::update_message', + label: 'session::update-message', title: 'stream to everyone', desc: 'every delta is persisted as it arrives, and every write emits an event. the chat ui, the telegram bridge, and any dashboard all render the same write, live — one stream in, any number of surfaces out.', event: 'session::message-updated', @@ -80,7 +80,7 @@ const STEPS: SeqStep[] = [ { from: 'harness', to: 'session', - label: 'session::set_status done', + label: 'session::set-status done', title: 'the turn completes', desc: 'the turn-completed event carries the terminal status and the typed result — the orchestration surface for anything that reacts to outcomes: chain a follow-up, notify a channel, or settle a parent agent.', event: 'harness::turn_completed', diff --git a/tech-specs/2026-06-agentic/session-manager.md b/tech-specs/2026-06-agentic/session-manager.md index 3e48306c4..ed4aefec6 100644 --- a/tech-specs/2026-06-agentic/session-manager.md +++ b/tech-specs/2026-06-agentic/session-manager.md @@ -36,7 +36,7 @@ badge, a list filter): `session::create` starts a session at `idle`. The driver (typically the [harness](harness.md)) sets `working` when a turn starts, `done` when it completes or is cancelled, and `error` when it fails, -via [`session::set_status`](#sessionset_status), which fires +via [`session::set-status`](#sessionset-status), which fires [`session::status-changed`](#trigger-types-emitted). ## Standalone use @@ -64,7 +64,7 @@ types; a consumer binds handlers with the standard two-step pattern (see Streaming an assistant reply uses the same primitives as everything else: the driver appends an (initially empty) assistant message — which fires `session::message-added` — then calls -`session::update_message` as tokens arrive — each firing `session::message-updated`. Updates may be +`session::update-message` as tokens arrive — each firing `session::message-updated`. Updates may be batched/throttled by the driver. Consumers render the growing message from those updates. Each update carries a server-assigned monotonic `revision`; trigger deliveries may arrive out of order, so consumers keep the highest revision per entry (last-write-wins on full-message snapshots). @@ -77,15 +77,15 @@ sequenceDiagram UI->>S: bind created / message-added / message-updated / status-changed / meta-updated / deleted H->>S: session::create (title, description) S-->>UI: session::created - H->>S: session::set_status working + H->>S: session::set-status working S-->>UI: session::status-changed (working) H->>S: session::append (assistant message, empty) S-->>UI: session::message-added loop streaming deltas - H->>S: session::update_message (grow content) + H->>S: session::update-message (grow content) S-->>UI: session::message-updated end - H->>S: session::set_status done + H->>S: session::set-status done S-->>UI: session::status-changed (done) ``` @@ -98,25 +98,25 @@ Lifecycle: - `session::ensure` — Idempotently ensure a session with a given id exists. - `session::get` — Read one session's metadata. - `session::list` — List sessions with pagination/ordering. -- `session::set_meta` — Update a session's `title`/`description`/`metadata` (e.g. an auto-generated +- `session::set-meta` — Update a session's `title`/`description`/`metadata` (e.g. an auto-generated title); fires `session::meta-updated`. -- `session::set_status` — Set status `idle`/`working`/`done`/`error`; fires `session::status-changed`. +- `session::set-status` — Set status `idle`/`working`/`done`/`error`; fires `session::status-changed`. - `session::delete` — Delete a session and its entries; fires `session::deleted`. Messages: - `session::append` — Append one message entry; fires `session::message-added`. -- `session::append_many` — Append several message entries; fires `session::message-added` per entry. -- `session::update_message` — Replace the content of a message entry; fires `session::message-updated`. +- `session::append-many` — Append several message entries; fires `session::message-added` per entry. +- `session::update-message` — Replace the content of a message entry; fires `session::message-updated`. - `session::messages` — Load the active-path `AgentMessage[]` (with entry ids), oldest first; supports pagination and role filtering. -- `session::get_message` — Read a single entry by id. +- `session::get-message` — Read a single entry by id. Branching: - `session::fork` — Copy history up to an entry into a new session (copy-on-fork: fresh entry ids); fires `session::created` for the new session. -- `session::set_active_leaf` — Move the active path to end at a given entry (branch switch). +- `session::set-active-leaf` — Move the active path to end at a given entry (branch switch). ## Triggers @@ -254,7 +254,7 @@ type SessionMeta = { ### `session::create` Create a session at status `idle`. `title`/`description` may be supplied up front (e.g. derived from -the opening message) and refined later with `session::set_meta`. `metadata` is persisted onto +the opening message) and refined later with `session::set-meta`. `metadata` is persisted onto `SessionMeta` — it is the tenancy hook (e.g. `{ owner: "u_1" }`) that `session::list` and every trigger config can filter on. Fires `session::created`. @@ -318,7 +318,7 @@ type ListRequest = { type ListResponse = { sessions: SessionMeta[]; next_cursor?: string }; ``` -### `session::set_meta` +### `session::set-meta` Update `title`/`description`/`metadata` (e.g. once a titling worker generates them from the first exchange). Does not change status or messages. Fires `session::meta-updated`, so consumers render @@ -337,7 +337,7 @@ type SetMetaRequest = { type SetMetaResponse = { meta: SessionMeta }; ``` -### `session::set_status` +### `session::set-status` Set the session status. Fires `session::status-changed`. No-op (no event) if the status is unchanged. `reason` is stored as `status_reason` (typically set with `error`, cleared on any other status). @@ -398,7 +398,7 @@ Example: { "entry_id": "e_001", "parent_id": null, "timestamp": 1717800000000 } ``` -### `session::append_many` +### `session::append-many` - Invocation: **sync**. Fires `session::message-added` for each appended entry, in order. Not idempotent — use `session::append` with `entry_id` where redelivery is possible. @@ -413,7 +413,7 @@ type AppendManyRequest = { type AppendManyResponse = { entry_ids: string[]; last_entry_id: string }; ``` -### `session::update_message` +### `session::update-message` Replace the content (and optionally `details`) of an existing message entry. Used for streaming assistant deltas and for edited function output. Fires `session::message-updated`. Each successful @@ -464,7 +464,7 @@ type MessagesResponse = { }; ``` -### `session::get_message` +### `session::get-message` - Invocation: **sync** @@ -488,7 +488,7 @@ type ForkRequest = { session_id: string; entry_id: string; title?: string }; type ForkResponse = { session_id: string; meta: SessionMeta }; ``` -### `session::set_active_leaf` +### `session::set-active-leaf` Switch the active path to end at `entry_id` (switching to a non-leaf makes the chain above it the active path). Subsequent `session::append` without `parent_id` chains from here. Appending with an @@ -532,10 +532,10 @@ future SQL/blob backend can implement the same interface. Deny-by-default for in-run agents (see [README § Security model](README.md#security-model)). An agent that can write here can rewrite its own transcript, flip session status, or destroy history: -- **Deny:** `session::create`, `session::ensure`, `session::append`, `session::append_many`, - `session::update_message`, `session::set_status`, `session::set_meta`, `session::set_active_leaf`, +- **Deny:** `session::create`, `session::ensure`, `session::append`, `session::append-many`, + `session::update-message`, `session::set-status`, `session::set-meta`, `session::set-active-leaf`, `session::fork`, `session::delete`. -- **Allow with care:** `session::get`, `session::list`, `session::messages`, `session::get_message` +- **Allow with care:** `session::get`, `session::list`, `session::messages`, `session::get-message` — read-only, but in multi-tenant deployments they leak other owners' sessions; deny unless the deployment is single-tenant.