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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/adr/0018-unified-event-journal.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ redaction discipline, and sink (inventoried 2026-07-24):
`phaseCounts` tally, and debug-mode live streaming to the per-request ndjson file (after
`createRequestExecutionScope` rebinds `logPath`), `daemon.log`, or stderr. The
`traceLogPath` scope option is dead: no call site ever sets it.
2. **Session event log** (`src/daemon/session-event-log.ts`). Append-only per-session
2. **Session event log** (`@agent-device/session-journal/session-event-log`). Append-only per-session
`events.ndjson` with kinds `request.started`/`request.finished`/`action.recorded`, written from
three request-lifecycle points plus `SessionStore.recordAction`, read only by the public
`events` command. `action.recorded` is already a projection of `session.actions` pushes — the
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@
"check:unit": "pnpm test:unit && pnpm check:tmpdir-leaks && pnpm test:smoke",
"check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit",
"prepack": "pnpm check:mcp-metadata && pnpm package:npm",
"typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/host-kit packages/capture-kit packages/managed-allocation packages/provision-kit packages/platform-apple packages/platform-android packages/platform-harmonyos packages/platform-vega packages/platform-linux packages/platform-web packages/ad-script packages/selectors packages/command-registry packages/ad-replay packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json",
"typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/host-kit packages/capture-kit packages/managed-allocation packages/provision-kit packages/platform-apple packages/platform-android packages/platform-harmonyos packages/platform-vega packages/platform-linux packages/platform-web packages/ad-script packages/selectors packages/command-registry packages/session-journal packages/ad-replay packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json",
"test-app:install": "pnpm install --dir examples/test-app",
"test-app:start": "pnpm --dir examples/test-app start",
"test-app:ios": "pnpm --dir examples/test-app ios",
Expand Down Expand Up @@ -294,6 +294,7 @@
"@agent-device/provision-kit": "workspace:*",
"@agent-device/replay-test": "workspace:*",
"@agent-device/selectors": "workspace:*",
"@agent-device/session-journal": "workspace:*",
"@agent-device/xml": "workspace:*",
"@arethetypeswrong/cli": "^0.18.5",
"@chenglou/freerange": "^0.0.4",
Expand Down
23 changes: 23 additions & 0 deletions packages/host-kit/src/code-signature.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,26 @@ test('the daemon source graph stamps the command descriptor registry package', (
assert.ok(labels.includes(owned), `${owned} is missing from the daemon code graph`);
}
});

/**
* The same claim for the ADR 0018 event journal (#2341). The daemon writes and reads
* `events.ndjson` only through this package's workspace subpaths, so a walk that stopped at the
* package boundary would report an unchanged signature after an entry-shape or retention-window
* edit, and a client would keep reusing a daemon writing the superseded journal. The manifest is
* asserted beside the sources because its `exports` map is what chose them.
*/
test('the daemon source graph stamps the session event journal package', () => {
const repoRoot = path.resolve(import.meta.dirname, '..', '..', '..');
const entryPath = path.join(repoRoot, 'src', 'daemon.ts');
if (!fs.existsSync(entryPath)) return;

const labels = labelsOf(entryPath, repoRoot);
for (const owned of [
'packages/session-journal/src/session-event-log.ts',
'packages/session-journal/src/session-event-log-window.ts',
'packages/session-journal/src/session-event-action.ts',
'packages/session-journal/package.json',
]) {
assert.ok(labels.includes(owned), `${owned} is missing from the daemon code graph`);
}
});
44 changes: 44 additions & 0 deletions packages/session-journal/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "@agent-device/session-journal",
"version": "0.0.0",
"private": true,
"sideEffects": false,
"type": "module",
"description": "The ADR 0018 unified request event journal: the append-only per-session event log, its rotation window and line scanner, and the presentation projections that turn a dispatched request or action into a journal entry.",
"dependencies": {
"@agent-device/command-registry": "workspace:*",
"@agent-device/contracts": "workspace:*",
"@agent-device/host-kit": "workspace:*",
"@agent-device/kernel": "workspace:*"
},
"exports": {
"./session-event-log": {
"types": "./src/session-event-log.ts",
"default": "./src/session-event-log.ts"
},
"./session-event-log-lines": {
"types": "./src/session-event-log-lines.ts",
"default": "./src/session-event-log-lines.ts"
},
"./session-event-log-window": {
"types": "./src/session-event-log-window.ts",
"default": "./src/session-event-log-window.ts"
},
"./session-event-request": {
"types": "./src/session-event-request.ts",
"default": "./src/session-event-request.ts"
},
"./session-event-action": {
"types": "./src/session-event-action.ts",
"default": "./src/session-event-action.ts"
},
"./session-event-action-presentation": {
"types": "./src/session-event-action-presentation.ts",
"default": "./src/session-event-action-presentation.ts"
},
"./keyboard-actions": {
"types": "./src/keyboard-actions.ts",
"default": "./src/keyboard-actions.ts"
}
}
}
150 changes: 150 additions & 0 deletions packages/session-journal/src/__tests__/journal-request-input.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import type { DeviceInfo } from '@agent-device/kernel/device';
import type { DeviceLease } from '@agent-device/contracts/device';
import { expectTypeOf, test } from 'vitest';
import { buildActionEventResult } from '../session-event-action-presentation.ts';
import {
buildRequestFinishedEvent,
buildRequestStartedEvent,
shouldRecordEventForRequest,
} from '../session-event-log.ts';
import {
buildRequestSuccessEventPresentation,
readRequestedScreenshotFileName,
} from '../session-event-request.ts';

/**
* Every request-shaped parameter this package accepts, read off the real signatures rather than
* restated. Widening any one of them back to the daemon's own `DaemonRequest` — the type that
* carries `internal` with its `SessionState` callbacks and admitted `DeviceLease` — changes this
* union, and the assertions below stop holding.
*/
type JournalRequestInput =
| Parameters<typeof buildRequestStartedEvent>[0]['req']
| Parameters<typeof buildRequestFinishedEvent>[0]['req']
| Parameters<typeof shouldRecordEventForRequest>[0]
| Parameters<typeof buildRequestSuccessEventPresentation>[0]
| Parameters<typeof readRequestedScreenshotFileName>[0]
| Parameters<typeof buildActionEventResult>[0];

type Primitive = string | number | boolean | bigint | symbol | null | undefined;

/**
* Recursion budget. Six is not asserted to be deep enough by inspection: the last assertion in
* this file walks again at twelve and requires the two answers to be identical, so a budget that
* stopped short of some path would show up as a type the deeper walk found and this one did not.
*/
type Budget = [0, 0, 0, 0, 0, 0];

/** Twice the budget, for the saturation check. */
type DoubleBudget = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];

/**
* `T`'s own declared keys, with index signatures dropped. Keeping `string` would silently absorb
* every literal beside it in a union (`string | 'internal'` reduces to `string`), which would make
* the assertion below vacuous the moment a `Record<string, unknown>` appears on a path — and one
* does, at `flags`. A record that could hold an `internal` property is not a declared route to
* session state; a declared `internal` key is.
*/
type DeclaredKeys<T> = keyof { [K in keyof T as string extends K ? never : K]: 0 };

/** Every declared property NAME reachable from `T`, arrays entered through their element type. */
type ReachableKeys<
T,
Cap extends readonly unknown[],
Depth extends readonly unknown[] = [],
> = Depth['length'] extends Cap['length']
? never
: [T] extends [Primitive]
? never
: [T] extends [(...args: never[]) => unknown]
? never
: T extends readonly (infer Element)[]
? ReachableKeys<Element, Cap, [...Depth, 0]>
: T extends object
?
| DeclaredKeys<T>
| { [K in keyof T]-?: ReachableKeys<NonNullable<T[K]>, Cap, [...Depth, 0]> }[keyof T]
: never;

/**
* Every property TYPE reachable from `T`. Arrays are entered through their element type, and a
* function through its PARAMETERS: that is how the daemon's `internal` actually hands out a live
* session record (`beforeDispatch?: (session: SessionState) => …`), so a walk that treated a
* callback as a leaf would never see the very route this file exists to reject.
*/
type ReachableTypes<
T,
Cap extends readonly unknown[],
Depth extends readonly unknown[] = [],
> = Depth['length'] extends Cap['length']
? never
: [T] extends [Primitive]
? never
: [T] extends [(...args: never[]) => unknown]
? ReachableTypes<Parameters<T>, Cap, [...Depth, 0]>
: T extends readonly (infer Element)[]
? Element | ReachableTypes<Element, Cap, [...Depth, 0]>
: T extends object
? {
[K in keyof T]-?:
| NonNullable<T[K]>
| ReachableTypes<NonNullable<T[K]>, Cap, [...Depth, 0]>;
}[keyof T]
: never;

/**
* The structural minimum of `src/daemon/session-state.ts`'s `SessionState`. The package may not
* import that module (R11), so the live session record is named by the three required fields no
* other shape in the public request vocabulary carries together. Anything typed as `SessionState`
* is assignable to this, which is what `Extract` needs.
*/
type LiveSessionRecord = { name: string; device: DeviceInfo; createdAt: number };

/** A callback of any arity — the form `SessionState` takes when it reaches a request. */
type AnyCallback = (...args: never[]) => unknown;

test('the journal reads a request shape with no route to daemon-private session state', () => {
// ADR 0018's journal is durable and rendered outside the daemon. Its input is the public
// request vocabulary of `@agent-device/kernel/contracts`: `command`, `flags`, `meta.requestId`
// and `meta.clientArtifactPaths`. The daemon's own request type adds `internal`, whose members
// hand out `SessionState` and an admitted `DeviceLease`; taking it here would put a live
// session record on a path the journal could serialize.
expectTypeOf<
Extract<ReachableKeys<JournalRequestInput, Budget>, 'internal'>
>().toEqualTypeOf<never>();
expectTypeOf<
Extract<ReachableTypes<JournalRequestInput, Budget>, LiveSessionRecord>
>().toEqualTypeOf<never>();
expectTypeOf<
Extract<ReachableTypes<JournalRequestInput, Budget>, DeviceLease>
>().toEqualTypeOf<never>();
expectTypeOf<
Extract<ReachableTypes<JournalRequestInput, Budget>, AnyCallback>
>().toEqualTypeOf<never>();
});

/**
* The budget is a fixed point, not a guess. Walking again at twice the depth must reveal no key
* and no type the six-deep walk missed; if the public request vocabulary ever grows a path deeper
* than the budget, this fails here rather than quietly narrowing the assertions above.
*
* What the walk deliberately does not see: a route through an index signature. `flags` and `input`
* are `Record<string, unknown>`, so a value stashed under a record key is invisible to a type-level
* walk by construction — `DeclaredKeys` drops index signatures precisely so their `string` cannot
* absorb the literal keys beside it. The claim is about DECLARED routes, which is what a type can
* carry; a record that could hold anything is not one.
*/
test('the six-deep walk is saturated: twice the budget finds nothing more', () => {
expectTypeOf<
Exclude<
ReachableKeys<JournalRequestInput, DoubleBudget>,
ReachableKeys<JournalRequestInput, Budget>
>
>().toEqualTypeOf<never>();
expectTypeOf<
Exclude<
ReachableTypes<JournalRequestInput, DoubleBudget>,
ReachableTypes<JournalRequestInput, Budget>
>
>().toEqualTypeOf<never>();
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { AppError } from '@agent-device/kernel/errors';
import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts';
import { mkdtempForTestSync } from './test-utils/tmp-dir.ts';
import {
appendSessionEvent,
flushSessionEventLogWrites,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { buildRequestFinishedEvent, buildRequestStartedEvent } from '../session-event-log.ts';
import type { DaemonRequest, DaemonResponseData } from '../daemon-request.ts';
import type { DaemonRequest, DaemonResponseData } from '@agent-device/kernel/contracts';

function buildSuccessEvent(
command: string,
Expand Down
13 changes: 13 additions & 0 deletions packages/session-journal/src/__tests__/test-utils/tmp-dir.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

/**
* Creates a fresh scratch directory for one test. Cleanup is automatic: the
* unit suite redirects TMPDIR to a per-run directory (scripts/vitest-tmpdir-global-setup.ts)
* that gets removed in one recursive rm after every worker finishes, so
* individual tests never need their own afterEach/afterAll for this.
*/
export function mkdtempForTestSync(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { DEVICE_ROTATIONS } from '@agent-device/contracts/device';
import { BACK_MODES } from '@agent-device/contracts/back-mode';
import { TV_REMOTE_BUTTONS } from '@agent-device/contracts/tv-remote';
import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog';
import { isKeyboardAction } from '../core/keyboard-actions.ts';
import { isKeyboardAction } from './keyboard-actions.ts';
import {
compactSessionEventDetails as compactDetails,
readSessionEventBoolean as readBoolean,
Expand All @@ -14,7 +14,7 @@ import {
readSessionEventNumber,
readSessionEventString,
} from './session-event-request.ts';
import type { DaemonRequest } from './daemon-request.ts';
import type { DaemonRequest } from '@agent-device/kernel/contracts';

const SCROLL_DIRECTIONS = ['up', 'down', 'left', 'right'] as const;
const SCROLL_EDGES = ['top', 'bottom'] as const;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { AppError } from '@agent-device/kernel/errors';
import { redactDiagnosticData } from '@agent-device/kernel/redaction';
import { emitDiagnostic, getDiagnosticsMeta } from '@agent-device/host-kit/diagnostics';
import { isRecord } from '@agent-device/kernel/record';
import type { DaemonRequest, DaemonResponse } from './daemon-request.ts';
import type { DaemonRequest, DaemonResponse } from '@agent-device/kernel/contracts';
import { buildActionDetails, buildActionSummary } from './session-event-action.ts';
import { buildRequestSuccessEventPresentation } from './session-event-request.ts';
import { scanEventLogLines } from './session-event-log-lines.ts';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog';
import { isRecord } from '@agent-device/kernel/record';
import type { DaemonRequest, DaemonResponseData } from './daemon-request.ts';
import type { DaemonRequest, DaemonResponseData } from '@agent-device/kernel/contracts';

const EVENT_LIST_PREVIEW_LIMIT = 5;
const EVENT_LIST_SUMMARY_LIMIT = 3;
Expand Down
15 changes: 15 additions & 0 deletions packages/session-journal/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,
"declarationDir": "./dist-types",
"rootDir": "./src"
},
// The command-registry sources this package imports read the `__OWNER_FILES__` build-time flag
// declared beside them. The root and examples projects already carry that declaration for the
// same reason; a package program that reaches registry.ts needs it too.
"include": ["src", "../command-registry/src/global.d.ts"]
}
18 changes: 18 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions scripts/layering/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const TARGET_DAG_RANK = new Map([
['request', 1],
['screenshot-diff', 1],
['selectors', 1],
['session-journal', 1],
['snapshot', 1],
['core', 2],
['cli-schema', 3],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from '../commands/family/registry.ts';
import type { SettleCapableClientOptionCommands } from '../commands/post-action-observation-client-options.ts';
import { getCliCommandSchema } from '../cli-schema/command-schema.ts';
import { buildActionDetails } from '../daemon/session-event-action.ts';
import { buildActionDetails } from '@agent-device/session-journal/session-event-action';
import { COMMAND_OUTPUT_SCHEMAS } from '../mcp/command-output-schemas.ts';
import {
commandDescriptors,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/system/runtime/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { BackMode } from '@agent-device/contracts/back-mode';
import { parseTvRemoteButton } from '@agent-device/contracts/tv-remote';
import { AppError } from '@agent-device/kernel/errors';
import { successText } from '@agent-device/kernel/success-text';
import { isKeyboardAction } from '../../../core/keyboard-actions.ts';
import { isKeyboardAction } from '@agent-device/session-journal/keyboard-actions';
import { requireIntInRange } from '../../../core/validation.ts';
import {
toBackendResult,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
skipWhenLoopbackUnavailable,
} from '../../__tests__/test-utils/loopback.ts';
import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts';
import { flushSessionEventLogWrites } from '../session-event-log.ts';
import { flushSessionEventLogWrites } from '@agent-device/session-journal/session-event-log';

const TOKEN = 'save-script-transport-token';
const SESSION = 'save-script-transport';
Expand Down
Loading
Loading