diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index fcb818c..a9d1112 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -1544,14 +1544,17 @@ private function registerAbilities(): void { ), 'session' => array( 'type' => 'object', - 'description' => 'Captured session identifiers (kimaki/opencode). Fields default to null when the corresponding env was not present at worktree creation.', + 'description' => 'Captured session identifiers in a runtime-agnostic envelope. `primary_id` is the single renderer-friendly identifier downstream surfaces display. `ids` is a free-form map keyed by runtime ID (a string the integration layer chooses, e.g. via the `datamachine_code_worktree_runtime_signatures` filter); each entry is a string-map of subkeys (e.g. session_id, thread_id, thread_url, run_id) the integration chose to capture. DMC enumerates no runtime IDs and no subkeys.', 'properties' => array( - 'primary_id' => array( 'type' => array( 'string', 'null' ) ), - 'kimaki_session_id' => array( 'type' => array( 'string', 'null' ) ), - 'kimaki_thread_id' => array( 'type' => array( 'string', 'null' ) ), - 'kimaki_thread_url' => array( 'type' => array( 'string', 'null' ) ), - 'opencode_session_id' => array( 'type' => array( 'string', 'null' ) ), - 'opencode_run_id' => array( 'type' => array( 'string', 'null' ) ), + 'primary_id' => array( 'type' => array( 'string', 'null' ) ), + 'ids' => array( + 'type' => 'object', + 'description' => 'Map of runtime-id => { subkey => string|null }. Keys are opaque; DMC does not validate against a closed set.', + 'additionalProperties' => array( + 'type' => 'object', + 'additionalProperties' => array( 'type' => array( 'string', 'null' ) ), + ), + ), ), ), 'task' => array( diff --git a/inc/Environment.php b/inc/Environment.php index d2b08cb..016754b 100644 --- a/inc/Environment.php +++ b/inc/Environment.php @@ -4,10 +4,10 @@ * * Public signal that a co-located coding agent runtime exists on this host. * - * Data Machine Code is the bridge between WordPress and an external coding - * agent runtime (Claude Code, OpenCode, kimaki, etc.). Its mere activation - * is the declarative answer to "is there a coding agent here?" — there is - * no separate marker file or constant to declare. + * Data Machine Code is the bridge between WordPress and an external + * coding-agent runtime. Its mere activation is the declarative answer to + * "is there a coding agent here?" — there is no separate marker file or + * constant to declare. * * Other plugins that ship disk-side artifacts for a coding agent (e.g. * Intelligence's SKILL.md sync, MEMORY.md disk writes, MCP bridges) should diff --git a/inc/Workspace/WorktreeContextInjector.php b/inc/Workspace/WorktreeContextInjector.php index 1bf611c..3a30551 100644 --- a/inc/Workspace/WorktreeContextInjector.php +++ b/inc/Workspace/WorktreeContextInjector.php @@ -25,6 +25,44 @@ * (plugin removed, agent layer absent), injection and refresh become * graceful no-ops. * + * == Session-attribution layering == + * + * Captured session identifiers live in a runtime-agnostic envelope: + * + * array( + * 'primary_id' => '', + * 'ids' => array( + * '' => array( + * 'session_id' => '', + * 'thread_id' => '', + * 'thread_url' => '', + * 'run_id' => '', + * // ...integration-defined subkeys + * ), + * ), + * ) + * + * DMC does NOT enumerate runtime IDs and does NOT hardcode any vendor-specific + * field names. Integration layers (e.g. wp-coding-agents) describe which env + * vars to sniff and how to project them into the envelope through the + * `datamachine_code_worktree_runtime_signatures` filter: + * + * add_filter( 'datamachine_code_worktree_runtime_signatures', function ( array $signatures ): array { + * $signatures[''] = array( + * 'session_id' => '', + * 'thread_id' => '', + * 'thread_url' => '', + * 'run_id' => '', + * // ...integration-defined subkey => env var + * ); + * return $signatures; + * } ); + * + * `primary_id` resolution scans registered runtimes in registration order and + * picks the first non-empty `session_id`, falling back to the first non-empty + * value of any subkey within each runtime. The integration layer therefore + * controls precedence by registration order. + * * @package DataMachineCode\Workspace * @since 0.8.0 */ @@ -397,32 +435,219 @@ public static function summarize_owner( ?array $metadata ): array { /** * Summarize the session side of persisted metadata for listing surfaces. * - * Returns the recorded runtime IDs along with a single stable - * `primary_id` field renderers can show in narrow tables. + * Returns the runtime-agnostic envelope (`primary_id` + `ids`) described + * in this file's header. `primary_id` resolution: + * + * 1. If `origin_session.primary_id` is already set, use it. + * 2. Otherwise, scan registered runtimes (in registration order via the + * `datamachine_code_worktree_runtime_signatures` filter) and pick the + * first non-empty `session_id` for any registered runtime. + * 3. If no runtime declares `session_id`, fall back to the first + * non-empty subkey of the first registered runtime that has data. + * + * Legacy rows stored under brand-named top-level keys (pre-#416) are + * transparently normalized into the new envelope on read via + * {@see self::migrate_legacy_origin_session()}. * * @param array|null $metadata Persisted metadata. - * @return array{primary_id:?string,kimaki_session_id:?string,kimaki_thread_id:?string,kimaki_thread_url:?string,opencode_session_id:?string,opencode_run_id:?string} + * @return array{primary_id:?string,ids:array>} */ public static function summarize_session( ?array $metadata ): array { - $session = is_array( $metadata['origin_session'] ?? null ) ? $metadata['origin_session'] : array(); - - $kimaki_session_id = isset( $session['kimaki_session_id'] ) ? (string) $session['kimaki_session_id'] : null; - $kimaki_thread_id = isset( $session['kimaki_thread_id'] ) ? (string) $session['kimaki_thread_id'] : null; - $kimaki_thread_url = isset( $session['kimaki_thread_url'] ) ? (string) $session['kimaki_thread_url'] : null; - $opencode_session_id = isset( $session['opencode_session_id'] ) ? (string) $session['opencode_session_id'] : null; - $opencode_run_id = isset( $session['opencode_run_id'] ) ? (string) $session['opencode_run_id'] : null; + $session = is_array( $metadata['origin_session'] ?? null ) ? (array) $metadata['origin_session'] : array(); + $session = self::migrate_legacy_origin_session( $session ); + + $ids = array(); + if ( isset( $session['ids'] ) && is_array( $session['ids'] ) ) { + foreach ( $session['ids'] as $runtime_id => $entry ) { + if ( ! is_string( $runtime_id ) || '' === $runtime_id || ! is_array( $entry ) ) { + continue; + } + $normalized = array(); + foreach ( $entry as $subkey => $value ) { + if ( ! is_string( $subkey ) || '' === $subkey ) { + continue; + } + $normalized[ $subkey ] = self::normalize_session_value( $value ); + } + if ( ! empty( $normalized ) ) { + $ids[ $runtime_id ] = $normalized; + } + } + } - // Pick the most descriptive identifier as the renderer-friendly primary. - $primary = $kimaki_session_id ?? $opencode_session_id ?? $opencode_run_id ?? $kimaki_thread_id; + $primary = self::resolve_primary_id( $session, $ids ); return array( - 'primary_id' => '' === $primary ? null : $primary, - 'kimaki_session_id' => '' === $kimaki_session_id ? null : $kimaki_session_id, - 'kimaki_thread_id' => '' === $kimaki_thread_id ? null : $kimaki_thread_id, - 'kimaki_thread_url' => '' === $kimaki_thread_url ? null : $kimaki_thread_url, - 'opencode_session_id' => '' === $opencode_session_id ? null : $opencode_session_id, - 'opencode_run_id' => '' === $opencode_run_id ? null : $opencode_run_id, + 'primary_id' => $primary, + 'ids' => $ids, + ); + } + + /** + * Resolve the renderer-friendly primary identifier from a normalized + * session envelope. + * + * Precedence: + * 1. Explicit `primary_id` on the stored envelope (already chosen by a + * caller or persisted from an earlier resolution). + * 2. First non-empty `session_id` across registered runtimes, in the + * order returned by the `datamachine_code_worktree_runtime_signatures` + * filter. + * 3. First non-empty value of any subkey across registered runtimes, in + * registration order, in array iteration order within a runtime. + * 4. Null when nothing resolves. + * + * @param array $session Raw envelope (may contain `primary_id`). + * @param array> $ids Normalized ids map. + */ + private static function resolve_primary_id( array $session, array $ids ): ?string { + $explicit = isset( $session['primary_id'] ) ? self::normalize_session_value( $session['primary_id'] ) : null; + if ( null !== $explicit ) { + return $explicit; + } + + $signatures = self::runtime_signatures(); + + // Pass 1: session_id across registered runtimes, in registration order. + foreach ( array_keys( $signatures ) as $runtime_id ) { + if ( isset( $ids[ $runtime_id ]['session_id'] ) && null !== $ids[ $runtime_id ]['session_id'] ) { + return $ids[ $runtime_id ]['session_id']; + } + } + + // Pass 2: any subkey across registered runtimes, in registration order. + foreach ( array_keys( $signatures ) as $runtime_id ) { + if ( ! isset( $ids[ $runtime_id ] ) || ! is_array( $ids[ $runtime_id ] ) ) { + continue; + } + foreach ( $ids[ $runtime_id ] as $value ) { + if ( null !== $value ) { + return $value; + } + } + } + + // Pass 3: no registered runtimes — fall back to any captured runtime. + foreach ( $ids as $entry ) { + foreach ( $entry as $value ) { + if ( null !== $value ) { + return $value; + } + } + } + + return null; + } + + /** + * Normalize a captured session value: strings are trimmed, empty values + * become null, non-strings are coerced via string cast then re-checked. + * + * @param mixed $value Raw value. + */ + private static function normalize_session_value( $value ): ?string { + if ( null === $value ) { + return null; + } + if ( is_string( $value ) ) { + $trimmed = trim( $value ); + return '' === $trimmed ? null : $trimmed; + } + if ( is_scalar( $value ) ) { + $str = trim( (string) $value ); + return '' === $str ? null : $str; + } + return null; + } + + /** + * Migrate legacy brand-named top-level keys into the generic envelope. + * + * Legacy migration only — pre-#416 rows persisted vendor-specific fields + * (`_`) directly on `origin_session`. We infer the runtime + * ID from the prefix and the subkey from the suffix so existing inventory + * rows keep rendering correctly after the schema generalization. + * + * This helper is the single approved location where legacy brand-shaped + * keys are referenced. The mapping is structural (prefix/suffix split on + * the first underscore), not an enumerated allowlist, so adding a new + * runtime never requires touching this code. The block can be deleted in + * a follow-up once all known stores are confirmed migrated; gate the + * deletion on the `datamachine_code_worktree_attribution_legacy_migrated_v2` + * site option (set true once a backfill task completes). + * + * @param array $session Raw stored envelope (may be legacy shape). + * @return array Envelope guaranteed to expose `primary_id` and `ids`. + */ + private static function migrate_legacy_origin_session( array $session ): array { + $ids = array(); + if ( isset( $session['ids'] ) && is_array( $session['ids'] ) ) { + $ids = (array) $session['ids']; + } + + // legacy migration only: split top-level `_` keys into + // the runtime-keyed envelope. Skip canonical envelope keys. + $canonical_top_level = array( + 'primary_id' => true, + 'ids' => true, ); + foreach ( $session as $key => $value ) { + if ( ! is_string( $key ) || isset( $canonical_top_level[ $key ] ) ) { + continue; + } + $underscore = strpos( $key, '_' ); + if ( false === $underscore || 0 === $underscore || strlen( $key ) - 1 === $underscore ) { + continue; + } + $runtime_id = substr( $key, 0, $underscore ); + $subkey = substr( $key, $underscore + 1 ); + if ( '' === $runtime_id || '' === $subkey ) { + continue; + } + if ( ! isset( $ids[ $runtime_id ] ) || ! is_array( $ids[ $runtime_id ] ) ) { + $ids[ $runtime_id ] = array(); + } + // Don't overwrite a value already present in the canonical envelope. + if ( ! array_key_exists( $subkey, $ids[ $runtime_id ] ) ) { + $ids[ $runtime_id ][ $subkey ] = $value; + } + } + + $session['ids'] = $ids; + return $session; + } + + /** + * Resolve the registered runtime signatures. + * + * @return array> Map of runtime-id => { subkey => env-var-name }. + */ + private static function runtime_signatures(): array { + if ( ! function_exists( 'apply_filters' ) ) { + return array(); + } + $signatures = apply_filters( 'datamachine_code_worktree_runtime_signatures', array() ); + if ( ! is_array( $signatures ) ) { + return array(); + } + + $out = array(); + foreach ( $signatures as $runtime_id => $entry ) { + if ( ! is_string( $runtime_id ) || '' === $runtime_id || ! is_array( $entry ) ) { + continue; + } + $subkeys = array(); + foreach ( $entry as $subkey => $env_var ) { + if ( ! is_string( $subkey ) || '' === $subkey || ! is_string( $env_var ) || '' === $env_var ) { + continue; + } + $subkeys[ $subkey ] = $env_var; + } + if ( ! empty( $subkeys ) ) { + $out[ $runtime_id ] = $subkeys; + } + } + return $out; } /** @@ -1126,57 +1351,78 @@ private static function resolve_origin_agent(): ?string { /** * Resolve non-sensitive runtime/session hints from the creating process. * - * Reads identifiers exposed by the surrounding agent runtime (OpenCode, - * Kimaki/Discord) and the originating site URL. Cross-machine federation - * is intentionally out of scope: only the env that the creator process - * exposes is captured. Missing fields stay missing — never invent IDs. + * Reads identifiers exposed by the surrounding agent runtime via the + * `datamachine_code_worktree_runtime_signatures` filter (see this file's + * header for the contract). DMC enumerates no runtime IDs and no env-var + * names; integration layers (e.g. wp-coding-agents) declare both. + * + * Cross-machine federation is intentionally out of scope: only env vars + * the creator process exposes are captured. Missing fields stay missing + * — never invent IDs. + * + * The resulting envelope: + * + * array( + * 'primary_id' => '', + * 'ids' => array( + * '' => array( '' => '', ... ), + * ), + * ) + * + * Subkeys named `thread_url` (or any subkey ending in `_url`) are + * validated as `http(s)://...` URLs; non-conforming values are dropped. + * No other subkey-specific validation is performed. * * @return array|null */ private static function resolve_origin_session(): ?array { - $session = array(); - - $opencode_run_id = getenv( 'OPENCODE_RUN_ID' ); - if ( is_string( $opencode_run_id ) && '' !== trim( $opencode_run_id ) ) { - $session['opencode_run_id'] = trim( $opencode_run_id ); - } - - $opencode_session_id = getenv( 'OPENCODE_SESSION_ID' ); - if ( is_string( $opencode_session_id ) && '' !== trim( $opencode_session_id ) ) { - $session['opencode_session_id'] = trim( $opencode_session_id ); - } - - $opencode_pid = getenv( 'OPENCODE_PID' ); - if ( is_string( $opencode_pid ) && ctype_digit( $opencode_pid ) ) { - $session['opencode_pid'] = (int) $opencode_pid; - } - - $kimaki_session_id = getenv( 'KIMAKI_SESSION_ID' ); - if ( is_string( $kimaki_session_id ) && '' !== trim( $kimaki_session_id ) ) { - $session['kimaki_session_id'] = trim( $kimaki_session_id ); + $signatures = self::runtime_signatures(); + if ( empty( $signatures ) ) { + return null; } - $kimaki_thread_id = getenv( 'KIMAKI_THREAD_ID' ); - if ( is_string( $kimaki_thread_id ) && '' !== trim( $kimaki_thread_id ) ) { - $session['kimaki_thread_id'] = trim( $kimaki_thread_id ); + $ids = array(); + foreach ( $signatures as $runtime_id => $subkeys ) { + $entry = array(); + foreach ( $subkeys as $subkey => $env_var ) { + $raw = getenv( $env_var ); + if ( ! is_string( $raw ) ) { + continue; + } + $trimmed = trim( $raw ); + if ( '' === $trimmed ) { + continue; + } + // Generic URL-shape validation for url-suffixed subkeys. + if ( self::is_url_subkey( $subkey ) && ! preg_match( '#^https?://#i', $trimmed ) ) { + continue; + } + $entry[ $subkey ] = $trimmed; + } + if ( ! empty( $entry ) ) { + $ids[ $runtime_id ] = $entry; + } } - $kimaki_channel_id = getenv( 'KIMAKI_CHANNEL_ID' ); - if ( is_string( $kimaki_channel_id ) && '' !== trim( $kimaki_channel_id ) ) { - $session['kimaki_channel_id'] = trim( $kimaki_channel_id ); + if ( empty( $ids ) ) { + return null; } - $kimaki_guild_id = getenv( 'KIMAKI_GUILD_ID' ); - if ( is_string( $kimaki_guild_id ) && '' !== trim( $kimaki_guild_id ) ) { - $session['kimaki_guild_id'] = trim( $kimaki_guild_id ); - } + $session = array( + 'primary_id' => null, + 'ids' => $ids, + ); + $session['primary_id'] = self::resolve_primary_id( $session, $ids ); - $kimaki_thread_url = getenv( 'KIMAKI_THREAD_URL' ); - if ( is_string( $kimaki_thread_url ) && preg_match( '#^https?://#', (string) $kimaki_thread_url ) ) { - $session['kimaki_thread_url'] = trim( $kimaki_thread_url ); - } + return $session; + } - return empty( $session ) ? null : $session; + /** + * Whether the given subkey conventionally holds a URL. Generic suffix check + * — does not enumerate runtimes. + */ + private static function is_url_subkey( string $subkey ): bool { + return str_ends_with( $subkey, '_url' ) || 'url' === $subkey; } /** @@ -1311,7 +1557,7 @@ private static function get_inventory_metadata( string $handle ): ?array { } foreach ( $repository->list() as $row ) { - if ( $handle === (string) ( $row['handle'] ?? '' ) && is_array( $row['metadata'] ?? null ) ) { + if ( (string) ( $row['handle'] ?? '' ) === $handle && is_array( $row['metadata'] ?? null ) ) { return (array) $row['metadata']; } } diff --git a/tests/smoke-worktree-agent-session-lifecycle.php b/tests/smoke-worktree-agent-session-lifecycle.php index f4af1a6..7276c65 100644 --- a/tests/smoke-worktree-agent-session-lifecycle.php +++ b/tests/smoke-worktree-agent-session-lifecycle.php @@ -88,6 +88,10 @@ function update_option( string $name, $value, $autoload = null ): bool { if ( ! function_exists( 'apply_filters' ) ) { function apply_filters( string $hook_name, $value, ...$args ) { + global $datamachine_code_test_filters; + if ( isset( $datamachine_code_test_filters[ $hook_name ] ) && is_callable( $datamachine_code_test_filters[ $hook_name ] ) ) { + return $datamachine_code_test_filters[ $hook_name ]( $value, ...$args ); + } return $value; } } @@ -144,16 +148,32 @@ function wp_mkdir_p( string $path ): bool { echo "=== smoke-worktree-agent-session-lifecycle ===\n"; - // Reset option state for this run. + // Reset option + filter state for this run. $GLOBALS['datamachine_code_test_options'] = array(); + $GLOBALS['datamachine_code_test_filters'] = array(); + + // Register two synthetic test runtimes via the public filter contract. + // DMC has zero knowledge of these IDs or env-var names — the integration + // layer (here, the test) owns both. + $GLOBALS['datamachine_code_test_filters']['datamachine_code_worktree_runtime_signatures'] = function ( array $signatures ): array { + $signatures['alpha-runtime'] = array( + 'session_id' => 'DMC_SMOKE_ALPHA_SESSION_ID', + 'thread_id' => 'DMC_SMOKE_ALPHA_THREAD_ID', + 'thread_url' => 'DMC_SMOKE_ALPHA_THREAD_URL', + ); + $signatures['beta-runtime'] = array( + 'session_id' => 'DMC_SMOKE_BETA_SESSION_ID', + 'run_id' => 'DMC_SMOKE_BETA_RUN_ID', + ); + return $signatures; + }; // --- 1) build_lifecycle_metadata captures env-driven session + task fields --- - putenv( 'OPENCODE_SESSION_ID=ses_smoke_42' ); - putenv( 'OPENCODE_RUN_ID=run-smoke-1' ); - putenv( 'KIMAKI_SESSION_ID=kim-ses-99' ); - putenv( 'KIMAKI_THREAD_ID=thr_111' ); - putenv( 'KIMAKI_CHANNEL_ID=chan_222' ); - putenv( 'KIMAKI_THREAD_URL=https://discord.com/channels/1/2/3' ); + putenv( 'DMC_SMOKE_BETA_SESSION_ID=ses_smoke_42' ); + putenv( 'DMC_SMOKE_BETA_RUN_ID=run-smoke-1' ); + putenv( 'DMC_SMOKE_ALPHA_SESSION_ID=alpha-ses-99' ); + putenv( 'DMC_SMOKE_ALPHA_THREAD_ID=thr_111' ); + putenv( 'DMC_SMOKE_ALPHA_THREAD_URL=https://example.test/threads/1/2/3' ); putenv( 'DATAMACHINE_TASK_URL=https://github.com/Extra-Chill/data-machine-code/issues/221' ); putenv( 'DATAMACHINE_TASK_REF=Extra-Chill/data-machine-code#221' ); @@ -171,13 +191,26 @@ function wp_mkdir_p( string $path ): bool { $assert( 'Intelligence', $built['origin_site'] ?? null, 'origin site name recorded' ); $assert( 'https://intelligence.example.test', $built['origin_site_url'] ?? null, 'origin site URL recorded' ); $assert( 'chris', $built['origin_user']['login'] ?? null, 'origin user login recorded' ); - $assert( 'ses_smoke_42', $built['origin_session']['opencode_session_id'] ?? null, 'opencode session id captured' ); - $assert( 'kim-ses-99', $built['origin_session']['kimaki_session_id'] ?? null, 'kimaki session id captured' ); - $assert( 'chan_222', $built['origin_session']['kimaki_channel_id'] ?? null, 'kimaki channel id captured' ); - $assert( 'https://discord.com/channels/1/2/3', $built['origin_session']['kimaki_thread_url'] ?? null, 'kimaki thread URL captured' ); + $assert( 'ses_smoke_42', $built['origin_session']['ids']['beta-runtime']['session_id'] ?? null, 'beta runtime session id captured under ids envelope' ); + $assert( 'run-smoke-1', $built['origin_session']['ids']['beta-runtime']['run_id'] ?? null, 'beta runtime run id captured under ids envelope' ); + $assert( 'alpha-ses-99', $built['origin_session']['ids']['alpha-runtime']['session_id'] ?? null, 'alpha runtime session id captured under ids envelope' ); + $assert( 'thr_111', $built['origin_session']['ids']['alpha-runtime']['thread_id'] ?? null, 'alpha runtime thread id captured under ids envelope' ); + $assert( 'https://example.test/threads/1/2/3', $built['origin_session']['ids']['alpha-runtime']['thread_url'] ?? null, 'alpha runtime thread URL captured under ids envelope' ); + $assert( 'alpha-ses-99', $built['origin_session']['primary_id'] ?? null, 'primary_id resolves to first registered runtime session_id' ); $assert( 'https://github.com/Extra-Chill/data-machine-code/issues/221', $built['origin_task']['task_url'] ?? null, 'task URL captured from env' ); $assert( 'Extra-Chill/data-machine-code#221', $built['origin_task']['task_ref'] ?? null, 'task ref captured from env' ); + // URL-suffixed subkeys must look like http(s) URLs to be captured. + putenv( 'DMC_SMOKE_ALPHA_THREAD_URL=not-a-url' ); + $built_bad_url = \DataMachineCode\Workspace\WorktreeContextInjector::build_lifecycle_metadata( array( + 'handle' => 'demo@bad-url', + 'path' => '/tmp/demo@bad-url', + 'repo' => 'demo', + 'branch' => 'bad/url', + ) ); + $assert( null, $built_bad_url['origin_session']['ids']['alpha-runtime']['thread_url'] ?? null, 'non-URL value for *_url subkey is dropped' ); + putenv( 'DMC_SMOKE_ALPHA_THREAD_URL=https://example.test/threads/1/2/3' ); + // Caller-supplied task_url/task_ref override env. putenv( 'DATAMACHINE_TASK_URL=https://github.com/some/other/issues/9999' ); $built_explicit = \DataMachineCode\Workspace\WorktreeContextInjector::build_lifecycle_metadata( array( @@ -189,12 +222,11 @@ function wp_mkdir_p( string $path ): bool { $assert( 'EC/dmc#221', $built_explicit['origin_task']['task_ref'] ?? null, 'explicit task_ref wins over env' ); // Clear env so subsequent assertions get unknown-safe defaults. - putenv( 'OPENCODE_SESSION_ID' ); - putenv( 'OPENCODE_RUN_ID' ); - putenv( 'KIMAKI_SESSION_ID' ); - putenv( 'KIMAKI_THREAD_ID' ); - putenv( 'KIMAKI_CHANNEL_ID' ); - putenv( 'KIMAKI_THREAD_URL' ); + putenv( 'DMC_SMOKE_BETA_SESSION_ID' ); + putenv( 'DMC_SMOKE_BETA_RUN_ID' ); + putenv( 'DMC_SMOKE_ALPHA_SESSION_ID' ); + putenv( 'DMC_SMOKE_ALPHA_THREAD_ID' ); + putenv( 'DMC_SMOKE_ALPHA_THREAD_URL' ); putenv( 'DATAMACHINE_TASK_URL' ); putenv( 'DATAMACHINE_TASK_REF' ); @@ -289,11 +321,40 @@ function wp_mkdir_p( string $path ): bool { $session_unknown = \DataMachineCode\Workspace\WorktreeContextInjector::summarize_session( null ); $assert( null, $session_unknown['primary_id'], 'summarize_session primary_id null on missing metadata' ); - $assert( null, $session_unknown['kimaki_session_id'], 'summarize_session kimaki id null on missing metadata' ); + $assert( array(), $session_unknown['ids'], 'summarize_session ids is empty map on missing metadata' ); $session_filled = \DataMachineCode\Workspace\WorktreeContextInjector::summarize_session( $built ); - $assert( 'kim-ses-99', $session_filled['primary_id'], 'summarize_session prefers kimaki session id as primary' ); - $assert( 'ses_smoke_42', $session_filled['opencode_session_id'], 'summarize_session exposes opencode session id' ); + $assert( 'alpha-ses-99', $session_filled['primary_id'], 'summarize_session primary_id follows registered runtime order' ); + $assert( 'ses_smoke_42', $session_filled['ids']['beta-runtime']['session_id'] ?? null, 'summarize_session exposes beta runtime session id under ids envelope' ); + $assert( 'alpha-ses-99', $session_filled['ids']['alpha-runtime']['session_id'] ?? null, 'summarize_session exposes alpha runtime session id under ids envelope' ); + + // --- 4b) Legacy-shape migration (pre-#416 stored rows). --- + // Stored rows that persisted vendor-specific top-level keys must normalize + // transparently into the generic envelope without losing data. + $legacy_metadata = array( + 'origin_session' => array( + 'alpha-runtime_session_id' => 'legacy-alpha-ses', + 'alpha-runtime_thread_id' => 'legacy-alpha-thr', + 'beta-runtime_run_id' => 'legacy-beta-run', + ), + ); + $legacy_view = \DataMachineCode\Workspace\WorktreeContextInjector::summarize_session( $legacy_metadata ); + $assert( 'legacy-alpha-ses', $legacy_view['ids']['alpha-runtime']['session_id'] ?? null, 'legacy migration projects _session_id into ids envelope' ); + $assert( 'legacy-alpha-thr', $legacy_view['ids']['alpha-runtime']['thread_id'] ?? null, 'legacy migration projects _thread_id into ids envelope' ); + $assert( 'legacy-beta-run', $legacy_view['ids']['beta-runtime']['run_id'] ?? null, 'legacy migration projects _run_id into ids envelope' ); + $assert( 'legacy-alpha-ses', $legacy_view['primary_id'], 'legacy-migrated rows still resolve a primary_id via runtime precedence' ); + + // Mixed envelope: explicit primary_id wins over runtime-derived precedence. + $mixed_view = \DataMachineCode\Workspace\WorktreeContextInjector::summarize_session( array( + 'origin_session' => array( + 'primary_id' => 'explicit-primary', + 'ids' => array( + 'alpha-runtime' => array( 'session_id' => 'alpha-ses' ), + 'beta-runtime' => array( 'session_id' => 'beta-ses' ), + ), + ), + ) ); + $assert( 'explicit-primary', $mixed_view['primary_id'], 'explicit primary_id on stored envelope overrides runtime scan' ); // --- 5) Duplicate task ownership detection --- $rows = array( diff --git a/tests/smoke-worktree-inventory-store.php b/tests/smoke-worktree-inventory-store.php index 6c5cc19..0d951b5 100644 --- a/tests/smoke-worktree-inventory-store.php +++ b/tests/smoke-worktree-inventory-store.php @@ -91,6 +91,16 @@ function update_option( string $name, $value, $autoload = null ): bool { } } + if ( ! function_exists( 'apply_filters' ) ) { + function apply_filters( string $hook_name, $value, ...$args ) { + global $datamachine_code_test_filters; + if ( isset( $datamachine_code_test_filters[ $hook_name ] ) && is_callable( $datamachine_code_test_filters[ $hook_name ] ) ) { + return $datamachine_code_test_filters[ $hook_name ]( $value, ...$args ); + } + return $value; + } + } + require __DIR__ . '/../inc/Workspace/WorktreeContextInjector.php'; require __DIR__ . '/../inc/Storage/WorktreeInventoryRepository.php'; @@ -114,6 +124,17 @@ function update_option( string $name, $value, $autoload = null ): bool { $GLOBALS['wpdb'] = new Datamachine_Code_Test_Wpdb(); $GLOBALS['datamachine_code_test_options'] = array(); + $GLOBALS['datamachine_code_test_filters'] = array(); + + // Register a synthetic test runtime so primary_id resolution has a + // registered runtime to scan. DMC enumerates no runtime IDs itself. + $GLOBALS['datamachine_code_test_filters']['datamachine_code_worktree_runtime_signatures'] = function ( array $signatures ): array { + $signatures['test-runtime'] = array( + 'session_id' => 'DMC_SMOKE_TEST_SESSION_ID', + 'thread_id' => 'DMC_SMOKE_TEST_THREAD_ID', + ); + return $signatures; + }; $metadata = array( 'handle' => 'demo@agent-session-lifecycle', @@ -124,8 +145,13 @@ function update_option( string $name, $value, $autoload = null ): bool { 'origin_site' => 'Intelligence', 'origin_agent' => 'franklin', 'origin_session' => array( - 'opencode_session_id' => 'ses_123', - 'kimaki_thread_id' => 'thread_456', + 'primary_id' => 'ses_123', + 'ids' => array( + 'test-runtime' => array( + 'session_id' => 'ses_123', + 'thread_id' => 'thread_456', + ), + ), ), 'origin_task' => array( 'task_url' => 'https://github.com/Extra-Chill/data-machine-code/issues/221', @@ -148,7 +174,8 @@ function update_option( string $name, $value, $autoload = null ): bool { // Prove get_metadata can be DB-backed by clearing the option fallback. $GLOBALS['datamachine_code_test_options'] = array(); $loaded = \DataMachineCode\Workspace\WorktreeContextInjector::get_metadata( 'demo@agent-session-lifecycle' ); - $assert( 'ses_123', $loaded['origin_session']['opencode_session_id'] ?? null, 'get_metadata reads origin session from DB inventory when option is absent' ); + $assert( 'ses_123', $loaded['origin_session']['ids']['test-runtime']['session_id'] ?? null, 'get_metadata reads origin session ids envelope from DB inventory when option is absent' ); + $assert( 'thread_456', $loaded['origin_session']['ids']['test-runtime']['thread_id'] ?? null, 'get_metadata exposes ids subkeys from DB inventory' ); $assert( 'Extra-Chill/data-machine-code#221', $loaded['origin_task']['task_ref'] ?? null, 'get_metadata reads task ref from DB inventory when option is absent' ); \DataMachineCode\Workspace\WorktreeContextInjector::record_heartbeat( 'demo@agent-session-lifecycle', '2026-05-04T13:00:00Z' ); diff --git a/tests/smoke-worktree-lifecycle-metadata.php b/tests/smoke-worktree-lifecycle-metadata.php index 5cf0f68..6d4d3b2 100644 --- a/tests/smoke-worktree-lifecycle-metadata.php +++ b/tests/smoke-worktree-lifecycle-metadata.php @@ -276,8 +276,16 @@ function get_userdata( int $user_id ): object { $run( 'git add README.md', $primary ); $run( 'git commit -m initial', $primary ); $run( 'git remote add origin https://github.com/acme/demo.git', $primary ); - putenv( 'OPENCODE_RUN_ID=smoke-run-123' ); - putenv( 'OPENCODE_PID=12345' ); + // Synthetic runtime registration so the env-driven session capture has a + // runtime to scan. DMC enumerates no runtime IDs itself — the integration + // layer (here, the test) declares them via the public filter. + $GLOBALS['datamachine_code_test_filters']['datamachine_code_worktree_runtime_signatures'] = function ( array $signatures ): array { + $signatures['smoke-runtime'] = array( + 'run_id' => 'DMC_SMOKE_LIFECYCLE_RUN_ID', + ); + return $signatures; + }; + putenv( 'DMC_SMOKE_LIFECYCLE_RUN_ID=smoke-run-123' ); $ws = new \DataMachineCode\Workspace\Workspace(); $GLOBALS['wpdb'] = new DatamachineCodeLifecycleInventoryWpdb();