mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 23:01:29 -10:00
fix(cli): hold managed session rows to the registry's identity standard
Both blockers on this listing are the same mistake in two places: the merge joins two sources that disagree about what identifies a session, and each half trusted its own spelling. Pids. `liveWorkerPid` gated durable recorded pids with a bare `isPidAlive`, and the worker file recorded no process identity at all. Nothing reaps that file while no supervisor runs — `clearAgentViewWorkerPids` does not get to run after a SIGKILL or a reboot — so once the OS recycles the number, `kill(pid, 0)` answers "alive" about an unrelated process and `qwen sessions ps` prints it next to a row still reading `working`; a script reading `--json` kills a stranger. A `~/.qwen` shared between machines or namespaces (an NFS home, a devcontainer with the home mounted) needs no recycling to reach the same place. The interactive rows in the very same table are verified with `isSameProcess` plus namespace and boot-id guards, so the two halves answered to different evidence standards. The supervisor now records the start token for each pid it writes, plus its own PID namespace, and the reader checks `isSameProcess` against them. All three fields are optional: `AgentViewWorkerFile` is a durable `schemaVersion: 1` record, and a file written before they existed carries none, which `isSameProcess` reads as "no identity recorded" and degrades to exactly the liveness check this command did before. The namespace guard fires only on a known disagreement, so an unreadable `/proc` on either side never blanks a real worker's pid. Ids. `mergeSessionRows` deduped with a case-sensitive `Set.has`, but the supervisor store reports the sanitized, lowercased directory name it files a session under while the registry keeps the raw spelling the worker registered with — adoption keeps both on purpose, because the native session store is case-sensitive. A managed session whose id contains an uppercase letter therefore slipped the filter and listed twice, once with its real state and once as `interactive`, with `--json` emitting two different spellings for one session. Both sides now canonicalize through `sanitizeSessionId`. That sanitizer moved from `supervisor-store` to `protocol`, which is where it belongs — it defines the identity two readers must agree on, not a detail of writing files — and it keeps the pure row module free of a dependency on the filesystem store. `supervisor-store` re-exports it, so no caller changes. Tests pin the two arms liveness cannot see: a live pid whose recorded token no longer matches, and a foreign-namespace worker file. Two more pin the degradations, since silently blanking every pre-identity pid would be the worse regression. The mock mirrors the real `isSameProcess` contract rather than stubbing a verdict, so the existing dead-pid cases still exercise the fall-through they were written for. Typecheck, lint and tests were not run locally; CI is the authority on this branch.
This commit is contained in:
1 parent
cd68e10375
commit
853d57bba9
5 files changed
+281
-31
No files matched your search
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
|
||||
export const AGENT_VIEW_PROTOCOL_VERSION = 1;
|
||||
|
||||
export type AgentViewOwnership =
|
||||
@@ -115,6 +117,28 @@ export interface AgentViewWorkerFile {
|
||||
schemaVersion: 1;
|
||||
hostPid?: number;
|
||||
workerPid?: number;
|
||||
/**
|
||||
* Process start tokens for the pids above, and the PID namespace they
|
||||
* were recorded in.
|
||||
*
|
||||
* A pid number does not identify a process. Nothing reaps this store
|
||||
* while no supervisor runs, so a crash or a reboot leaves recorded pids
|
||||
* behind that the OS later recycles to unrelated processes; and a
|
||||
* shared `~/.qwen` — an NFS home, a devcontainer with the home mounted
|
||||
* — puts another machine's pids in front of a reader who would resolve
|
||||
* them in its own namespace. A reader pairs these with `isSameProcess`
|
||||
* to answer the question the live-session registry already answers for
|
||||
* its own records.
|
||||
*
|
||||
* All three are optional on purpose: this is a durable
|
||||
* `schemaVersion: 1` record, and a file written before these fields
|
||||
* existed must stay readable. A reader that finds them absent degrades
|
||||
* to a bare liveness check — the same rule `isSameProcess` applies on a
|
||||
* platform that has no start token.
|
||||
*/
|
||||
hostProcStart?: string | null;
|
||||
workerProcStart?: string | null;
|
||||
pidNs?: number | null;
|
||||
endpoint?: string;
|
||||
hostEndpoint?: string;
|
||||
hostAuthToken?: string;
|
||||
@@ -232,3 +256,25 @@ export type AgentViewWorkerAnswerOutcome =
|
||||
| 'modify_with_editor'
|
||||
| 'restore_previous'
|
||||
| 'cancel';
|
||||
|
||||
/**
|
||||
* The directory name a session id is filed under.
|
||||
*
|
||||
* This lives with the on-disk shapes rather than with the store because
|
||||
* it *is* one: it defines the identity two readers must agree on. The
|
||||
* store files a session under this name and reports it back as the
|
||||
* session's id, while the live-session registry keeps the raw spelling
|
||||
* the worker registered with — adoption deliberately keeps both, because
|
||||
* the native session store is case-sensitive. Anything that joins the two
|
||||
* sources has to canonicalize through this one function, or a mixed-case
|
||||
* session is two sessions to whichever half is comparing raw strings.
|
||||
*/
|
||||
export function sanitizeSessionId(sessionId: string): string {
|
||||
const safe = path
|
||||
.basename(sessionId.replace(/\\/g, '/'))
|
||||
.toLowerCase()
|
||||
.replace(/^\.+/g, '_')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[<>:"|?*\x00-\x1F]/g, '_');
|
||||
return safe || '_';
|
||||
}
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
DEFAULT_AGENT_VIEW_ATTACH_LEASE_TTL_MS,
|
||||
} from './attach-lease.js';
|
||||
import type { AgentViewAttachLease } from './attach-lease.js';
|
||||
import {
|
||||
readPidNamespaceId,
|
||||
readProcStartToken,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { AGENT_VIEW_PROTOCOL_VERSION } from './protocol.js';
|
||||
import type {
|
||||
AgentViewActivityFile,
|
||||
@@ -80,6 +84,40 @@ import {
|
||||
getAgentViewActivityInputState,
|
||||
} from './presentation.js';
|
||||
|
||||
/**
|
||||
* The pid fields of a worker file, with the identity that makes them
|
||||
* trustworthy to a later reader.
|
||||
*
|
||||
* Recording the numbers alone is not enough. Nothing reaps this store
|
||||
* while no supervisor runs — `clearAgentViewWorkerPids` does not get to
|
||||
* run after a SIGKILL or a reboot — so the durable file keeps naming pids
|
||||
* the OS is free to hand to unrelated processes, and a `~/.qwen` shared
|
||||
* between machines or namespaces offers pids that mean nothing here. A
|
||||
* reader pairing these tokens with `isSameProcess` gets the same evidence
|
||||
* standard the live-session registry already holds its own records to,
|
||||
* instead of a bare `kill(pid, 0)`.
|
||||
*
|
||||
* Reading the token can fail (the process exits between spawn and this
|
||||
* call, `/proc` is unreadable, the platform has none); `null` then records
|
||||
* "no identity available", which readers degrade to a liveness check
|
||||
* rather than treating as a mismatch.
|
||||
*/
|
||||
function workerPidIdentity(host: { pid: number; workerPid: number }): {
|
||||
hostPid: number;
|
||||
workerPid: number;
|
||||
hostProcStart: string | null;
|
||||
workerProcStart: string | null;
|
||||
pidNs: number | null;
|
||||
} {
|
||||
return {
|
||||
hostPid: host.pid,
|
||||
workerPid: host.workerPid,
|
||||
hostProcStart: readProcStartToken(host.pid),
|
||||
workerProcStart: readProcStartToken(host.workerPid),
|
||||
pidNs: readPidNamespaceId(),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSessionCwd(cwd: string): string {
|
||||
try {
|
||||
return fs.realpathSync(cwd);
|
||||
@@ -470,8 +508,7 @@ class AgentViewSupervisorProcessHandler
|
||||
result.sessionId,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
hostPid: host.pid,
|
||||
workerPid: host.workerPid,
|
||||
...workerPidIdentity(host),
|
||||
...(host.hostId ? { hostId: host.hostId } : {}),
|
||||
...(host.endpoint ? { hostEndpoint: host.endpoint } : {}),
|
||||
...(host.authToken ? { hostAuthToken: host.authToken } : {}),
|
||||
@@ -703,8 +740,7 @@ class AgentViewSupervisorProcessHandler
|
||||
adoption.sessionId,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
hostPid: host.pid,
|
||||
workerPid: host.workerPid,
|
||||
...workerPidIdentity(host),
|
||||
...(host.hostId ? { hostId: host.hostId } : {}),
|
||||
...(host.endpoint ? { hostEndpoint: host.endpoint } : {}),
|
||||
...(host.authToken ? { hostAuthToken: host.authToken } : {}),
|
||||
@@ -2322,8 +2358,7 @@ class WorkerRegistry {
|
||||
sessionId,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
hostPid: host.pid,
|
||||
workerPid: host.workerPid,
|
||||
...workerPidIdentity(host),
|
||||
...(host.hostId ? { hostId: host.hostId } : {}),
|
||||
hostEndpoint: worker.hostEndpoint,
|
||||
...(worker.hostAuthToken
|
||||
@@ -2521,8 +2556,7 @@ class WorkerRegistry {
|
||||
sessionId,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
hostPid: host.pid,
|
||||
workerPid: host.workerPid,
|
||||
...workerPidIdentity(host),
|
||||
...(host.hostId ? { hostId: host.hostId } : {}),
|
||||
...(host.endpoint ? { hostEndpoint: host.endpoint } : {}),
|
||||
...(host.authToken ? { hostAuthToken: host.authToken } : {}),
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { atomicWriteFile, Storage } from '@qwen-code/qwen-code-core';
|
||||
import { sanitizeSessionId } from './protocol.js';
|
||||
import type {
|
||||
AgentViewActivityFile,
|
||||
AgentViewLaunchFile,
|
||||
@@ -19,6 +20,11 @@ import type {
|
||||
AgentViewWorkerFile,
|
||||
} from './protocol.js';
|
||||
|
||||
// Re-exported from its old home: the sanitizer moved to `protocol.js` so
|
||||
// the pure row-merging module can canonicalize ids without importing this
|
||||
// filesystem store, and every existing importer keeps working.
|
||||
export { sanitizeSessionId };
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export interface AgentViewStorePaths {
|
||||
@@ -547,16 +553,6 @@ export async function writeAgentViewSupervisor(
|
||||
});
|
||||
}
|
||||
|
||||
export function sanitizeSessionId(sessionId: string): string {
|
||||
const safe = path
|
||||
.basename(sessionId.replace(/\\/g, '/'))
|
||||
.toLowerCase()
|
||||
.replace(/^\.+/g, '_')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[<>:"|?*\x00-\x1F]/g, '_');
|
||||
return safe || '_';
|
||||
}
|
||||
|
||||
function compareRosterEntries(
|
||||
left: AgentViewRosterEntry,
|
||||
right: AgentViewRosterEntry,
|
||||
@@ -888,6 +884,13 @@ function normalizeWorker(
|
||||
schemaVersion: 1,
|
||||
hostPid: numberValue(raw['hostPid']),
|
||||
workerPid: numberValue(raw['workerPid']),
|
||||
// `null` rather than `undefined` for an absent token: it is the value
|
||||
// `isSameProcess` reads as "no identity recorded, fall back to a bare
|
||||
// liveness check", so a pre-identity worker file keeps its old
|
||||
// behaviour instead of being treated as a mismatch.
|
||||
hostProcStart: stringValue(raw['hostProcStart']) ?? null,
|
||||
workerProcStart: stringValue(raw['workerProcStart']) ?? null,
|
||||
pidNs: numberValue(raw['pidNs']) ?? null,
|
||||
endpoint: stringValue(raw['endpoint']),
|
||||
hostEndpoint: stringValue(raw['hostEndpoint']),
|
||||
hostAuthToken: stringValue(raw['hostAuthToken']),
|
||||
|
||||
@@ -15,9 +15,27 @@ import type { SessionRegistryRecord } from '@qwen-code/qwen-code-core';
|
||||
import type { AgentViewTaskState } from '../../agent-view/presentation.js';
|
||||
|
||||
const isPidAlive = vi.fn((pid: number) => pid > 0);
|
||||
/** The start token the OS would report for a pid right now. */
|
||||
const currentProcStart = vi.fn((pid: number): string | null => `start-${pid}`);
|
||||
const pidNamespaceId = vi.fn((): number | null => null);
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', () => ({
|
||||
isPidAlive: (...args: unknown[]) => isPidAlive(...(args as [number])),
|
||||
readProcStartToken: (...args: unknown[]) =>
|
||||
currentProcStart(...(args as [number])),
|
||||
readPidNamespaceId: () => pidNamespaceId(),
|
||||
// Mirrors the real degradation contract rather than stubbing a verdict,
|
||||
// so a test that records no token exercises the same fall-through to a
|
||||
// bare liveness check that a pre-identity worker file gets in
|
||||
// production: `procStart == null` and an unreadable current token both
|
||||
// mean "no identity available", never "mismatch".
|
||||
isSameProcess: (pid: number, procStart?: string | null) => {
|
||||
if (!isPidAlive(pid)) return false;
|
||||
if (procStart == null) return true;
|
||||
const current = currentProcStart(pid);
|
||||
if (current === null) return true;
|
||||
return current === procStart;
|
||||
},
|
||||
}));
|
||||
|
||||
const { managedSessionRows, mergeSessionRows } = await import(
|
||||
@@ -29,6 +47,10 @@ const NOW = Date.parse('2026-09-04T12:00:00Z');
|
||||
beforeEach(() => {
|
||||
isPidAlive.mockReset();
|
||||
isPidAlive.mockImplementation((pid: number) => pid > 0);
|
||||
currentProcStart.mockReset();
|
||||
currentProcStart.mockImplementation((pid: number) => `start-${pid}`);
|
||||
pidNamespaceId.mockReset();
|
||||
pidNamespaceId.mockImplementation(() => null);
|
||||
});
|
||||
|
||||
function state(
|
||||
@@ -164,6 +186,79 @@ describe('managedSessionRows', () => {
|
||||
expect(row.pid).toBe(100);
|
||||
});
|
||||
|
||||
it('drops a live pid the recorded identity does not vouch for', () => {
|
||||
// The half of the blocker liveness cannot see. After a SIGKILL or a
|
||||
// reboot nothing clears the durable worker file, and the OS is free to
|
||||
// hand 200 to something unrelated: `kill(200, 0)` then says "alive"
|
||||
// about a process that has nothing to do with this session, and a
|
||||
// script reading `--json` would kill a stranger.
|
||||
const [row] = managedSessionRows(
|
||||
[
|
||||
snapshot({
|
||||
worker: workerFile({
|
||||
workerPid: 200,
|
||||
workerProcStart: 'start-200-before-the-reboot',
|
||||
}),
|
||||
}),
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
expect(isPidAlive(200)).toBe(true);
|
||||
expect(row.pid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses pids from a worker file written in another PID namespace', () => {
|
||||
// One `~/.qwen` shared across machines or namespaces — an NFS home, a
|
||||
// devcontainer with the home mounted — needs no recycling to break
|
||||
// this: the foreign file's pids get probed here, where low numbers
|
||||
// routinely belong to live local processes.
|
||||
pidNamespaceId.mockImplementation(() => 4026531999);
|
||||
const [row] = managedSessionRows(
|
||||
[
|
||||
snapshot({
|
||||
worker: workerFile({
|
||||
workerPid: 200,
|
||||
workerProcStart: 'start-200',
|
||||
pidNs: 4026531837,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
expect(row.pid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still reports a pid when either side’s namespace is unreadable', () => {
|
||||
// The guard fires only on a known disagreement. `/proc` being
|
||||
// unreadable must not blank the pid of a worker that is really there.
|
||||
pidNamespaceId.mockImplementation(() => null);
|
||||
const [row] = managedSessionRows(
|
||||
[
|
||||
snapshot({
|
||||
worker: workerFile({
|
||||
workerPid: 200,
|
||||
workerProcStart: 'start-200',
|
||||
pidNs: 4026531837,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
expect(row.pid).toBe(200);
|
||||
});
|
||||
|
||||
it('keeps reporting pids from a worker file written before identity existed', () => {
|
||||
// `AgentViewWorkerFile` is a durable schemaVersion 1 record. A file
|
||||
// written by an older supervisor carries no token, and that must
|
||||
// degrade to the liveness check this command already did — not blank
|
||||
// the pid of every session that predates the field.
|
||||
const [row] = managedSessionRows(
|
||||
[snapshot({ worker: workerFile({ hostPid: 100, workerPid: 200 }) })],
|
||||
NOW,
|
||||
);
|
||||
expect(row.pid).toBe(200);
|
||||
});
|
||||
|
||||
it('leaves the pid unset when no process is recorded, rather than reporting 0', () => {
|
||||
// A managed session that has not spawned a worker yet, or whose
|
||||
// worker has exited, has no pid to point at. Zero is a real pid.
|
||||
@@ -261,6 +356,22 @@ describe('mergeSessionRows', () => {
|
||||
expect(rows[0].taskState).toBe('running');
|
||||
});
|
||||
|
||||
it('lists a mixed-case session once, though the two sources spell it differently', () => {
|
||||
// The store files a session under a sanitized, lowercased directory
|
||||
// name and reports that as the id; the registry keeps the raw spelling
|
||||
// the worker registered with, which adoption preserves on purpose
|
||||
// because the native session store is case-sensitive. A raw string
|
||||
// comparison lets this record through and the table lists one session
|
||||
// twice — once with its real state, once as `interactive`.
|
||||
const rows = mergeSessionRows(
|
||||
[record({ sessionId: 'Managed-1', name: 'app-ab' })],
|
||||
managedSessionRows([snapshot({ state: state() })], NOW),
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].managed).toBe(true);
|
||||
expect(rows[0].sessionId).toBe('managed-1');
|
||||
});
|
||||
|
||||
it('lets a live registry record survive the adopting window', () => {
|
||||
// Adoption reuses the live session's id before ownership flips to
|
||||
// managed. Until it flips, the registry record is the only row that
|
||||
|
||||
@@ -15,15 +15,29 @@
|
||||
*
|
||||
* This module turns both sources into one row shape. It is deliberately
|
||||
* pure: the readers stay in the command, so the merge and the labelling
|
||||
* are testable without a filesystem or a supervisor. The one probe it
|
||||
* makes — `isPidAlive`, which decides whether a recorded pid may still
|
||||
* be printed — is an input the tests control like any other.
|
||||
* are testable without a filesystem or a supervisor. The probes it makes
|
||||
* — the process-identity checks that decide whether a recorded pid may
|
||||
* still be printed — are inputs the tests control like any other.
|
||||
*
|
||||
* Both sources describe the same sessions, and both had their own idea of
|
||||
* what a session's identity is. That is the bug class this module has to
|
||||
* keep closed:
|
||||
*
|
||||
* - the store files a session under a sanitized (lowercased) directory
|
||||
* name and reports that as the id, while the registry keeps the raw
|
||||
* spelling the worker registered with, so the merge canonicalizes
|
||||
* through `sanitizeSessionId` before comparing;
|
||||
* - the registry verifies a pid with a recorded start token plus
|
||||
* namespace and boot-id guards, so a managed pid is held to the same
|
||||
* standard rather than to a bare liveness probe.
|
||||
*/
|
||||
|
||||
import {
|
||||
isPidAlive,
|
||||
isSameProcess,
|
||||
readPidNamespaceId,
|
||||
type SessionRegistryRecord,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { sanitizeSessionId } from '../../agent-view/protocol.js';
|
||||
import type {
|
||||
AgentViewSessionSnapshot,
|
||||
AgentViewWorkerFile,
|
||||
@@ -130,17 +144,49 @@ export function managedSessionRows(
|
||||
*
|
||||
* The worker is the process doing the work; the host only owns the PTY —
|
||||
* report whichever lives, worker first, matching the supervisor's own
|
||||
* two-pid liveness idiom. The store is durable and nothing reaps it when
|
||||
* no supervisor runs, so a crash or a reboot leaves recorded pids behind
|
||||
* that are dead, or recycled to an unrelated process; printing one would
|
||||
* point a `kill` at the wrong target. When neither lives, the row prints
|
||||
* `-`, exactly like a session that never had a worker.
|
||||
* two-pid liveness idiom.
|
||||
*
|
||||
* Liveness alone is not enough to print a number a user or a script may
|
||||
* `kill`. The store is durable and nothing reaps it while no supervisor
|
||||
* runs, so a crash or a reboot leaves recorded pids behind; once the OS
|
||||
* recycles one, a bare `kill(pid, 0)` says "alive" about a process that
|
||||
* has nothing to do with this session. A `~/.qwen` shared between
|
||||
* machines or PID namespaces — an NFS home, a devcontainer with the home
|
||||
* mounted — reaches the same end without any recycling, because a foreign
|
||||
* file's pids get probed here, where low numbers routinely belong to live
|
||||
* local processes. So each candidate is checked with `isSameProcess`
|
||||
* against the start token the supervisor recorded beside it, which is the
|
||||
* contract the registry rows in this very table already answer to.
|
||||
*
|
||||
* Degradation is deliberate in two places. A worker file written before
|
||||
* those tokens existed carries none, and `isSameProcess` reads that as
|
||||
* "no identity recorded" and falls through to a liveness check — the
|
||||
* behaviour this command had before, rather than a blanked pid on every
|
||||
* pre-existing session. And the namespace guard only fires when both
|
||||
* sides are known and disagree; an unreadable namespace on either side
|
||||
* must not blank a real worker's pid.
|
||||
*
|
||||
* When nothing qualifies the row prints `-`, exactly like a session that
|
||||
* never had a worker.
|
||||
*/
|
||||
function liveWorkerPid(
|
||||
worker: AgentViewWorkerFile | undefined,
|
||||
): number | undefined {
|
||||
for (const pid of [worker?.workerPid, worker?.hostPid]) {
|
||||
if (pid !== undefined && isPidAlive(pid)) return pid;
|
||||
if (!worker) return undefined;
|
||||
const ownNamespace = readPidNamespaceId();
|
||||
if (
|
||||
worker.pidNs != null &&
|
||||
ownNamespace != null &&
|
||||
worker.pidNs !== ownNamespace
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const candidates = [
|
||||
[worker.workerPid, worker.workerProcStart],
|
||||
[worker.hostPid, worker.hostProcStart],
|
||||
] as const;
|
||||
for (const [pid, procStart] of candidates) {
|
||||
if (pid !== undefined && isSameProcess(pid, procStart)) return pid;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -176,11 +222,21 @@ export function mergeSessionRows(
|
||||
records: readonly SessionRegistryRecord[],
|
||||
managed: readonly SessionRow[],
|
||||
): SessionRow[] {
|
||||
const managedIds = new Set(managed.map((row) => row.sessionId));
|
||||
// Canonicalized on both sides rather than compared raw. The two
|
||||
// sources spell one id differently: the store reports the sanitized
|
||||
// directory name it files the session under, while the registry keeps
|
||||
// the raw spelling the worker registered with — adoption keeps both on
|
||||
// purpose, because the native session store is case-sensitive. Comparing
|
||||
// the spellings would let any managed session whose id contains an
|
||||
// uppercase letter through this filter, and the table would list it
|
||||
// twice: once with its real state, once as `interactive`.
|
||||
const managedIds = new Set(
|
||||
managed.map((row) => sanitizeSessionId(row.sessionId)),
|
||||
);
|
||||
return [
|
||||
...managed,
|
||||
...records
|
||||
.filter((record) => !managedIds.has(record.sessionId))
|
||||
.filter((record) => !managedIds.has(sanitizeSessionId(record.sessionId)))
|
||||
.map(registryRow),
|
||||
];
|
||||
}
|
||||
Reference in new issue
Block a user