diff --git a/backend/__tests__/unit/routes/registry.pod-agents-seat-runtime.test.js b/backend/__tests__/unit/routes/registry.pod-agents-seat-runtime.test.js new file mode 100644 index 0000000000..4162553483 --- /dev/null +++ b/backend/__tests__/unit/routes/registry.pod-agents-seat-runtime.test.js @@ -0,0 +1,79 @@ +// TASK-569: both pod-agent read routes must hand the seat's newest identity to +// the payload. The helper suite (registry.seat-runtime-identity.test.js) proves +// the resolver; this one fails if either route stops passing it through. +const request = require('supertest'); +const express = require('express'); +const mongoose = require('mongoose'); + +const mockViewer = '6a0000000000000000000001'; +jest.mock('../../../middleware/auth', () => (req, res, next) => { + req.userId = mockViewer; + req.user = { id: mockViewer }; + next(); +}); +jest.mock('../../../services/dmService', () => ({ canViewPod: jest.fn().mockResolvedValue(true) })); + +const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../../utils/testUtils'); +const { AgentInstallation } = require('../../../models/AgentRegistry'); +const Pod = require('../../../models/Pod'); +const podAgentsRouter = require('../../../routes/registry/pod-agents'); + +const app = express(); +app.use(express.json()); +app.use('/api/registry', podAgentsRouter); + +const viewer = new mongoose.Types.ObjectId(mockViewer); +const podA = new mongoose.Types.ObjectId(); +const podB = new mongoose.Types.ObjectId(); + +const install = (podId, runtime, at) => AgentInstallation.create({ + agentName: 'sprint-review', + instanceId: 'default', + podId, + version: '1.0.0', + installedBy: viewer, + status: 'active', + config: runtime ? { runtime } : {}, +}).then(async (doc) => { + if (at) await AgentInstallation.collection.updateOne({ _id: doc._id }, { $set: { runtimeIdentityAt: new Date(at) } }); + return doc; +}); + +const IDENTITY = { + runtimeType: 'webhook', host: 'byo', adapter: 'claude', wrappedCli: 'claude', +}; +// sanitizeRuntimeConfig does not echo wrappedCli on this shape; the other three +// keys reach the payload, and none of pod A's stale codex group survives. +const expectNewest = (runtime) => { + expect(runtime).toMatchObject({ runtimeType: 'webhook', host: 'byo', adapter: 'claude' }); + expect(JSON.stringify(runtime)).not.toContain('codex'); +}; + +beforeAll(setupMongoDb); +afterAll(closeMongoDb); +beforeEach(async () => { + await Pod.collection.insertMany([podA, podB].map((_id) => ({ + _id, name: 'p', type: 'chat', createdBy: viewer, members: [viewer], + }))); + // Older identity in pod A, newer one in pod B: pod A must read pod B's. + await install(podA, { + runtimeType: 'codex', host: 'cloud', adapter: 'codex', wrappedCli: 'codex', + }, '2026-10-20T00:00:00Z'); + await install(podB, IDENTITY, '2026-10-25T00:00:00Z'); +}); +afterEach(clearMongoDb); + +describe('pod-agent routes read the seat\'s newest runtime identity', () => { + it('GET /pods/:podId/agents', async () => { + const res = await request(app).get(`/api/registry/pods/${podA}/agents`); + expect(res.status).toBe(200); + const agent = res.body.agents.find((a) => a.name === 'sprint-review' || a.agentName === 'sprint-review'); + expectNewest(agent.runtime); + }); + + it('GET /pods/:podId/agents/:name', async () => { + const res = await request(app).get(`/api/registry/pods/${podA}/agents/sprint-review`); + expect(res.status).toBe(200); + expectNewest(res.body.agent.runtime); + }); +}); diff --git a/backend/__tests__/unit/routes/registry.seat-runtime-identity.test.js b/backend/__tests__/unit/routes/registry.seat-runtime-identity.test.js new file mode 100644 index 0000000000..22e8440f58 --- /dev/null +++ b/backend/__tests__/unit/routes/registry.seat-runtime-identity.test.js @@ -0,0 +1,187 @@ +// TASK-569 (D55): attach writes a seat's runtime identity only onto the install +// where it ran, so the same seat showed a chip in one pod and none in another. +// Every pod now reads the seat's identity from its most recently attached +// install, never the first match, so a harness swap shows everywhere. "Most +// recent" is runtimeIdentityAt, which only identity writes move: updatedAt +// moved on a skill-load report and revived a stale harness (Sable on #2795). +const mongoose = require('mongoose'); +const { setupMongoDb, closeMongoDb, clearMongoDb } = require('../../utils/testUtils'); +const { AgentInstallation } = require('../../../models/AgentRegistry'); +const { + resolveSeatRuntimeIdentities, + buildAgentInstallationPayload, +} = require('../../../routes/registry/helpers'); + +const owner = new mongoose.Types.ObjectId(); +const podA = new mongoose.Types.ObjectId(); +const podB = new mongoose.Types.ObjectId(); +const podC = new mongoose.Types.ObjectId(); + +const install = (podId, runtime, extra = {}) => AgentInstallation.create({ + agentName: 'sprint-review', + instanceId: 'default', + podId, + version: '1.0.0', + installedBy: owner, + status: 'active', + config: runtime ? { runtime } : {}, + ...extra, +}); + +// Rewrites the identity clock directly so the order of attaches is explicit. +const stamp = (doc, iso) => AgentInstallation.collection.updateOne( + { _id: doc._id }, { $set: { runtimeIdentityAt: new Date(iso) } }, +); +const raw = (doc) => AgentInstallation.collection.findOne({ _id: doc._id }); + +const runtimeIn = async (doc) => { + const fresh = await AgentInstallation.findById(doc._id).lean(); + const identities = await resolveSeatRuntimeIdentities([fresh]); + return buildAgentInstallationPayload(fresh, { + runtimeIdentity: identities.get('sprint-review:default') || null, + }).runtime; +}; + +beforeAll(setupMongoDb); +afterAll(closeMongoDb); +afterEach(clearMongoDb); + +describe('seat runtime identity across pods', () => { + it('fills an install that declares no runtime from the seat\'s other install', async () => { + const declared = await install(podA, { runtimeType: 'claude-code', host: 'byo', adapter: 'claude' }); + const bare = await install(podB, null); + expect(await runtimeIn(bare)).toMatchObject({ runtimeType: 'claude-code', host: 'byo', adapter: 'claude' }); + expect(await runtimeIn(declared)).toMatchObject({ runtimeType: 'claude-code', adapter: 'claude' }); + }); + + it('a seat attached as codex in pod A, then re-attached as claude in pod B, shows claude in both', async () => { + const a = await install(podA, { runtimeType: 'webhook', host: 'byo', adapter: 'codex' }); + const b = await install(podB, { runtimeType: 'claude-code', host: 'byo' }); + await stamp(a, '2020-01-20T00:00:00Z'); + await stamp(b, '2020-01-25T00:00:00Z'); + const inA = await runtimeIn(a); + const inB = await runtimeIn(b); + // The group moves whole: pod A's older adapter does not outlive pod B's type. + expect(inA.runtimeType).toBe('claude-code'); + expect(inA.adapter).toBeUndefined(); + expect(inB.runtimeType).toBe('claude-code'); + }); + + it('picks the most recent install, not the first one created', async () => { + const first = await install(podA, { runtimeType: 'claude-code', host: 'byo' }); + const second = await install(podB, { runtimeType: 'codex', host: 'byo' }); + const bare = await install(podC, null); + await stamp(first, '2020-01-25T00:00:00Z'); + await stamp(second, '2020-01-21T00:00:00Z'); + expect((await runtimeIn(bare)).runtimeType).toBe('claude-code'); + expect((await runtimeIn(second)).runtimeType).toBe('claude-code'); + }); + + it('ignores uninstalled installs and installs of another instance', async () => { + await install(podA, { runtimeType: 'codex', host: 'byo' }, { status: 'uninstalled' }); + await install(podB, { runtimeType: 'codex', host: 'byo' }, { instanceId: 'other' }); + const bare = await install(podC, null); + const identities = await resolveSeatRuntimeIdentities([bare]); + expect(identities.size).toBe(0); + }); + + it('keeps per-install fields and the hosted type', async () => { + const hosted = await install(podA, { runtimeType: 'hosted', host: 'cloud', model: 'm-1' }); + const bare = await install(podB, { model: 'm-2' }); + await stamp(hosted, '2020-01-25T00:00:00Z'); + const runtime = await runtimeIn(bare); + expect(runtime).toMatchObject({ runtimeType: 'hosted', host: 'cloud', model: 'm-2' }); + }); + + it('a skill-load report on a stale install does not bring its old harness back', async () => { + const a = await install(podA, { runtimeType: 'webhook', host: 'byo', adapter: 'codex' }); + const b = await install(podB, { runtimeType: 'claude-code', host: 'byo' }); + await stamp(a, '2020-01-20T00:00:00Z'); + await stamp(b, '2020-01-25T00:00:00Z'); + // The exact write agentsRuntime.ts makes for POST .../skill-load. + await AgentInstallation.updateOne( + { _id: a._id, status: 'active' }, + { $set: { skillLoad: { at: new Date(), mounted: ['x'], conflicted: [], status: 'available' } } }, + ); + expect((await runtimeIn(a)).runtimeType).toBe('claude-code'); + expect((await runtimeIn(b)).runtimeType).toBe('claude-code'); + }); + + it('a settings save that keeps the identity does not move the clock', async () => { + const a = await install(podA, { runtimeType: 'webhook', host: 'byo', adapter: 'codex' }); + const b = await install(podB, { runtimeType: 'claude-code', host: 'byo' }); + await stamp(a, '2020-01-20T00:00:00Z'); + await stamp(b, '2020-01-25T00:00:00Z'); + // The manage-settings.ts shape: the whole config map rewritten, runtime unchanged. + const doc = await AgentInstallation.findById(a._id); + const config = Object.fromEntries(doc.config); + doc.config = new Map(Object.entries({ ...config, wakeOnMessage: { enabled: true } })); + await doc.save(); + expect((await raw(a)).runtimeIdentityAt.toISOString()).toBe('2020-01-20T00:00:00.000Z'); + expect((await runtimeIn(b)).runtimeType).toBe('claude-code'); + }); + + it('changing an install\'s identity, or reactivating it, makes it the newest', async () => { + const a = await install(podA, { runtimeType: 'webhook', host: 'byo', adapter: 'codex' }); + const b = await install(podB, { runtimeType: 'claude-code', host: 'byo' }); + await stamp(a, '2020-01-20T00:00:00Z'); + await stamp(b, '2020-01-25T00:00:00Z'); + const doc = await AgentInstallation.findById(a._id); + doc.config = new Map(Object.entries({ runtime: { runtimeType: 'codex', host: 'byo' } })); + await doc.save(); + expect((await runtimeIn(b)).runtimeType).toBe('codex'); + + // b starts older than a, so only the reactivation stamp can make it win. + await stamp(a, '2020-01-20T00:00:00Z'); + await stamp(b, '2020-01-10T00:00:00Z'); + expect((await runtimeIn(a)).runtimeType).toBe('codex'); + await AgentInstallation.updateOne({ _id: b._id }, { $set: { status: 'uninstalled' } }); + const back = await AgentInstallation.findById(b._id); + back.status = 'active'; + await back.save(); + expect((await runtimeIn(a)).runtimeType).toBe('claude-code'); + }); + + it('stamps the clock when an install is created with an identity, and not without one', async () => { + const declared = await install(podA, { runtimeType: 'claude-code', host: 'byo' }); + const bare = await install(podB, null); + expect((await raw(declared)).runtimeIdentityAt).toBeInstanceOf(Date); + expect((await raw(bare)).runtimeIdentityAt).toBeUndefined(); + }); + + it('a stamped install beats rows from before the clock, which fall back to creation order', async () => { + const legacyOld = await install(podA, { runtimeType: 'codex', host: 'byo' }); + const legacyNew = await install(podB, { runtimeType: 'webhook', host: 'byo', adapter: 'codex' }); + await AgentInstallation.collection.updateMany({}, { $unset: { runtimeIdentityAt: '' } }); + await AgentInstallation.collection.updateOne({ _id: legacyOld._id }, { $set: { createdAt: new Date('2026-09-01T00:00:00Z') } }); + await AgentInstallation.collection.updateOne({ _id: legacyNew._id }, { $set: { createdAt: new Date('2026-09-02T00:00:00Z') } }); + expect((await runtimeIn(legacyOld)).adapter).toBe('codex'); + expect((await runtimeIn(legacyOld)).runtimeType).toBe('webhook'); + const attached = await install(podC, { runtimeType: 'claude-code', host: 'byo' }); + await AgentInstallation.collection.updateOne({ _id: attached._id }, { $set: { createdAt: new Date('2026-08-01T00:00:00Z') } }); + expect((await runtimeIn(legacyNew)).runtimeType).toBe('claude-code'); + }); + + it('a re-attach through upsert onto an existing row makes it the newest', async () => { + const a = await install(podA, { runtimeType: 'claude-code', host: 'byo' }); + const b = await install(podB, { runtimeType: 'webhook', host: 'byo', adapter: 'codex' }); + await stamp(a, '2020-01-20T00:00:00Z'); + await stamp(b, '2020-01-25T00:00:00Z'); + await AgentInstallation.upsert('sprint-review', podA, { + version: '1.0.0', installedBy: owner, config: { runtime: { runtimeType: 'claude-code', host: 'byo' } }, + }); + expect((await runtimeIn(a)).runtimeType).toBe('claude-code'); + expect((await runtimeIn(b)).runtimeType).toBe('claude-code'); + }); + + it('an upsert with no runtime (mention auto-join) leaves the clock alone', async () => { + const a = await install(podA, { runtimeType: 'claude-code', host: 'byo' }); + await stamp(a, '2020-01-20T00:00:00Z'); + await AgentInstallation.upsert('sprint-review', podB, { + version: '1.0.0', installedBy: owner, config: { heartbeat: { enabled: false }, autoJoinSource: 'mention-resolution' }, + }); + const joined = await AgentInstallation.findOne({ podId: podB }).lean(); + expect(joined.runtimeIdentityAt).toBeUndefined(); + expect((await runtimeIn(joined)).runtimeType).toBe('claude-code'); + }); +}); diff --git a/backend/models/AgentRegistry.ts b/backend/models/AgentRegistry.ts index 38e980e859..e64c08dc02 100644 --- a/backend/models/AgentRegistry.ts +++ b/backend/models/AgentRegistry.ts @@ -1,4 +1,5 @@ import mongoose, { Document, Model, Schema, Types } from 'mongoose'; +import { declaredRuntimeIdentity } from '../utils/runtimeIdentity'; export type RegistryType = 'commonly-official' | 'commonly-community' | 'private'; export type RegistryStatus = 'active' | 'deprecated' | 'unpublished' | 'pending-review'; @@ -210,6 +211,8 @@ export interface IAgentInstallationRegistry extends Document { status: AgentInstallationStatus; errorMessage?: string; staleSince?: Date; + /** Moves only when this install's runtime identity is written or changes. */ + runtimeIdentityAt?: Date; lastFoundWorkAt?: Date | null; installedBy: Types.ObjectId; skillLoad?: { at: Date; mounted: string[]; conflicted: string[]; status?: 'available' | 'unavailable' }; @@ -249,6 +252,10 @@ const AgentInstallationSchema = new Schema( status: { type: String, enum: ['active', 'paused', 'uninstalled', 'error', 'stale'], default: 'active' }, errorMessage: String, staleSince: { type: Date }, + // The clock a seat's runtime chip is read by (TASK-569). updatedAt moves on + // every write, including the skill-load report, so it cannot say which + // install was attached last; this one moves only with the identity. + runtimeIdentityAt: { type: Date }, // CAS throttle for the kernel's unclaimed-work nudge, per seat and pod. lastFoundWorkAt: { type: Date, default: null }, installedBy: { type: Schema.Types.ObjectId, ref: 'User', required: true }, @@ -273,6 +280,22 @@ const AgentInstallationSchema = new Schema( { timestamps: true }, ); +const identitySignature = (config: unknown): string => JSON.stringify(declaredRuntimeIdentity(config)); + +AgentInstallationSchema.post('init', function (this: any) { + this.$locals.runtimeIdentitySignature = identitySignature(this.config); +}); +AgentInstallationSchema.pre('save', function (this: any) { + const signature = identitySignature(this.config); + const changed = this.isNew + ? signature !== 'null' + : this.isModified('config') && signature !== 'null' && signature !== this.$locals.runtimeIdentitySignature; + // A reactivated install is a re-attach even when its identity is unchanged. + const reattached = !this.isNew && this.isModified('status') && this.status === 'active' && signature !== 'null'; + if (changed || reattached) this.runtimeIdentityAt = new Date(); + this.$locals.runtimeIdentitySignature = signature; +}); + AgentInstallationSchema.index({ agentName: 1, podId: 1, instanceId: 1 }, { unique: true }); AgentInstallationSchema.index({ podId: 1, status: 1 }); // First-run connection status polls active installs by their human owner. @@ -351,6 +374,10 @@ AgentInstallationSchema.statics.upsert = async function (agentName: string, podI const update = { $setOnInsert: { agentName: agentName.toLowerCase(), podId, instanceId, installedBy, version, displayName }, $set: { + // An upsert that declares an identity is an attach, onto a new row or an + // existing one, so it moves the clock either way. findOneAndUpdate skips + // the pre('save') stamp, so it is set here (Sable on #2795). + ...(declaredRuntimeIdentity(config) ? { runtimeIdentityAt: new Date() } : {}), ...(config ? { config } : {}), ...(scopes ? { scopes } : {}), status: 'active', diff --git a/backend/routes/registry/helpers.ts b/backend/routes/registry/helpers.ts index fd24cf4a52..5b52226dc3 100644 --- a/backend/routes/registry/helpers.ts +++ b/backend/routes/registry/helpers.ts @@ -2,6 +2,7 @@ const User = require('../../models/User'); const Gateway = require('../../models/Gateway'); const { AgentInstallation } = require('../../models/AgentRegistry'); +const { RUNTIME_IDENTITY_KEYS, declaredRuntimeIdentity } = require('../../utils/runtimeIdentity'); const { isK8sMode } = require('../../services/agentProvisionerService'); const AgentIdentityService = require('../../services/agentIdentityService').default; const { resolveAgentDisplayLabel } = require('../../services/agentIdentityService'); @@ -367,6 +368,52 @@ const composeInstallIntro = ({ // accepted for exactly one release. An explicit `config.internal: true` on // the installation wins; the name patterns cover the seats scripts already // create without the flag. +// A seat is one runtime, but attach writes its runtime identity only onto the +// install where it ran, so the seat's installs in other pods keep an older +// identity or none (TASK-569: Sprint Review read claude-code in Sharpen and +// nothing in Core runtime). Every pod therefore reads the seat's identity from +// its most recently written install that declares one. Most recent, never the +// first match, so a harness swap made in one pod shows in all of them (D55). +// The four keys move as a group: an older install's adapter must not outlive +// a newer install's runtimeType, which would bring back the stale chip. +const runtimeIdentityKey = (agentName: any, instanceId: any) => `${agentName}:${instanceId || 'default'}`; + +const resolveSeatRuntimeIdentities = async (installations: any[]): Promise>> => { + const resolved = new Map>(); + const seats = new Map(); + for (const i of installations || []) { + if (!i?.agentName) continue; + seats.set(runtimeIdentityKey(i.agentName, i.instanceId), { + agentName: i.agentName, instanceId: i.instanceId || 'default', + }); + } + if (seats.size === 0) return resolved; + const rows = await AgentInstallation.find({ + status: 'active', + $or: Array.from(seats.values()), + }) + // Newest attach wins (TASK-569). Not updatedAt: the skill-load report and + // every settings save move it, which revived a stale harness in other pods. + // Rows from before runtimeIdentityAt sort after it, by their creation. + .select('agentName instanceId config.runtime runtimeIdentityAt createdAt') + .sort({ runtimeIdentityAt: -1, createdAt: -1, _id: -1 }) + .lean(); + for (const row of rows as any[]) { + const key = runtimeIdentityKey(row.agentName, row.instanceId); + if (resolved.has(key)) continue; + const identity = declaredRuntimeIdentity(row.config); + if (identity) resolved.set(key, identity); + } + return resolved; +}; + +const withSeatRuntimeIdentity = (runtime: any, identity: Record | null | undefined) => { + if (!identity) return runtime; + const base = runtime && typeof runtime === 'object' ? { ...runtime } : {}; + RUNTIME_IDENTITY_KEYS.forEach((key: string) => { delete base[key]; }); + return { ...base, ...identity }; +}; + const INTERNAL_SEAT_EXACT = new Set(['hosted-smoke', 'run-here-smoke', 'commonly-summarizer']); const INTERNAL_SEAT_PREFIXES = ['smoke-', 'recorder-', 'adr-', 'demo-']; const isInternalSeat = (agentName: any, normalizedConfig: any): boolean => { @@ -392,6 +439,7 @@ const buildAgentInstallationPayload = (installation: any, { user = null, lastMessage = null, revealWebhookUrl = false, + runtimeIdentity = null, }: { profile?: any; iconUrl?: string; lastHeartbeatAt?: any; lastActiveAt?: any; user?: any; lastMessage?: { content?: any; createdAt?: any } | null; @@ -401,11 +449,13 @@ const buildAgentInstallationPayload = (installation: any, { * query string (`?secret=`), so it is withheld unless the caller opts in. */ revealWebhookUrl?: boolean; + /** The seat's identity from resolveSeatRuntimeIdentities, over this install's own. */ + runtimeIdentity?: Record | null; } = {}) => { if (!installation) return null; const normalizedConfig = normalizeConfigMap(installation.config); const sanitizedRuntime = sanitizeRuntimeConfig( - normalizedConfig?.runtime || installation.config?.runtime || null, + withSeatRuntimeIdentity(normalizedConfig?.runtime || installation.config?.runtime || null, runtimeIdentity), installation.agentName, ); const runtimeConfig = (() => { @@ -707,6 +757,7 @@ module.exports = { sanitizeRuntimeConfig, buildOpenClawIntegrationChannels, buildAgentInstallationPayload, + resolveSeatRuntimeIdentities, isInternalSeat, toSnippet, normalizePluginIdentifier, diff --git a/backend/routes/registry/pod-agents.ts b/backend/routes/registry/pod-agents.ts index 5eb3112cce..8f809dc5a3 100644 --- a/backend/routes/registry/pod-agents.ts +++ b/backend/routes/registry/pod-agents.ts @@ -23,6 +23,7 @@ const { resolveInstallation, buildAgentProfileId, buildAgentInstallationPayload, + resolveSeatRuntimeIdentities, } = require('./helpers'); const podAgentsRouter = express.Router(); @@ -330,6 +331,15 @@ podAgentsRouter.get('/pods/:podId/agents', auth, async (req: any, res: any) => { console.warn('[pod-agents] caller-spoke lookup failed:', (spokeErr as Error).message); } + // A seat's runtime identity comes from its newest install in any pod + // (TASK-569). Advisory: on a failed read each install shows its own. + let seatRuntimeIdentities = new Map>(); + try { + seatRuntimeIdentities = await resolveSeatRuntimeIdentities(installations); + } catch (identityErr) { + console.warn('[pod-agents] runtime identity lookup failed:', (identityErr as Error).message); + } + // A push webhook's URL can carry a credential in its query string, so only // its installer and instance admins see it (Cofounder's hardening ruling, // 2026-10-21). The admin lookup runs only when the roster holds a URL the @@ -360,6 +370,7 @@ podAgentsRouter.get('/pods/:podId/agents', auth, async (req: any, res: any) => { user, lastMessage: (user && lastMessageByUserId.get(String(user._id))) || null, revealWebhookUrl: viewerIsAdmin || installedByViewer(i), + runtimeIdentity: seatRuntimeIdentities.get(instanceKey) || null, }); const runtimeType = payload?.runtime?.runtimeType; // A provisioned in-process seat needs no external connection. Its @@ -453,9 +464,18 @@ podAgentsRouter.get('/pods/:podId/agents/:name', auth, async (req: any, res: any || templateCandidates[0] )?.iconUrl || ''; + let runtimeIdentity = null; + try { + const identities = await resolveSeatRuntimeIdentities([installation]); + runtimeIdentity = identities.values().next().value || null; + } catch (identityErr) { + console.warn('[pod-agents] runtime identity lookup failed:', (identityErr as Error).message); + } + return res.json({ agent: buildAgentInstallationPayload(installation, { profile, + runtimeIdentity, iconUrl: templateIcon || registryEntry?.iconUrl || '', revealWebhookUrl: installedBy === String(userId) || (Boolean(normalizeConfigMap(installation.config)?.runtime?.webhookUrl) diff --git a/backend/utils/runtimeIdentity.ts b/backend/utils/runtimeIdentity.ts new file mode 100644 index 0000000000..cb9fb6ab6e --- /dev/null +++ b/backend/utils/runtimeIdentity.ts @@ -0,0 +1,16 @@ +/** + * A seat's runtime identity on one install: the four keys that say what runs + * it, moving as one group (TASK-569). Null when the install declares no type, + * adapter or wrapped CLI, i.e. a projection written without its runtime. + */ +export const RUNTIME_IDENTITY_KEYS = ['runtimeType', 'host', 'adapter', 'wrappedCli']; + +export const declaredRuntimeIdentity = (config: unknown): Record | null => { + const map = config instanceof Map ? Object.fromEntries(config) : config as Record | null; + const runtime = map?.runtime; + if (!runtime || typeof runtime !== 'object') return null; + if (!runtime.runtimeType && !runtime.adapter && !runtime.wrappedCli) return null; + return Object.fromEntries(RUNTIME_IDENTITY_KEYS + .filter((key) => runtime[key] !== undefined && runtime[key] !== null && runtime[key] !== '') + .map((key) => [key, runtime[key]])); +};