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
45 changes: 45 additions & 0 deletions src/core/ask-broker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,51 @@ export function getAskSnapshot(askId: string): PendingAsk | undefined {
return a ? snapshot(a) : undefined;
}

/** List unsettled asks for Desktop / dashboard aggregation (read-only snapshots). */
export function listPendingAsks(): PendingAsk[] {
gcSettled();
const out: PendingAsk[] = [];
for (const ask of pending.values()) {
if (!ask.settled) out.push(snapshot(ask));
}
return out;
}

/**
* Desktop / trusted-host answer path. Bypasses canTalk (no Feishu openId) —
* caller must be authenticated as the local dashboard/desktop operator.
*/
export function submitAskFromDesktop(args: {
askId: string;
/** Selected option keys per question (same shape as submitAsk selections). */
selections: ReadonlyArray<ReadonlyArray<string>>;
by?: string;
}): AskClickOutcome {
gcSettled();
const ask = pending.get(args.askId);
if (!ask) return 'stale';
if (ask.settled) return 'already_settled';

const answers = args.selections;
for (let i = 0; i < ask.questions.length; i++) {
const q = ask.questions[i]!;
const sel = answers[i] ?? [];
if (!q.multiSelect && sel.length !== 1) return 'stale';
for (const key of sel) {
if (!q.options.some((o) => o.key === key)) return 'stale';
}
}

settle(args.askId, {
kind: 'answered',
answers,
by: args.by ?? 'desktop',
comment: null,
timedOut: false,
});
return 'accepted';
}

/** Read a pending ask by id — for tests only. Returns a snapshot; mutating it
* has no effect on broker state. */
export function _getPending(askId: string): PendingAsk | undefined {
Expand Down
44 changes: 44 additions & 0 deletions src/core/dashboard-ipc-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ import { validateTriggerRequest, type TriggerResponse } from '../services/trigge
import { resolveCliSelection, selectionKeyForBot } from '../setup/cli-selection.js';
import { checkCliAvailability } from '../setup/cli-availability.js';
import { enrichHistorySenders, type HistoryBotInfo } from '../dashboard/history-senders.js';
import { listPendingAsks, submitAskFromDesktop } from './ask-broker.js';

let exactChatGrantHandler: typeof applyExactChatGrantRequest = applyExactChatGrantRequest;
/** Test seam: replace the exact-grant service without touching live Feishu/config state. */
Expand Down Expand Up @@ -491,6 +492,49 @@ ipcRoute('POST', DEVICE_ISOLATION_COMMIT_PATH, (req, res) =>
ipcRoute('POST', DEVICE_ISOLATION_RELEASE_PATH, (req, res) =>
handleDeviceIsolationActivationRoute(req, res, releaseDeviceIsolationActivation));

// ─── Pending asks (trusted Desktop/dashboard operator only) ─────────────────

ipcRoute('GET', '/api/asks/pending', (req, res) => {
if (!isTrustedHostIpcRequest(req)) {
return jsonRes(res, 403, { ok: false, error: 'trusted_host_required' });
}
const asks = listPendingAsks().map((ask) => ({
askId: ask.askId,
sessionId: ask.sessionId,
larkAppId: ask.larkAppId,
chatId: ask.chatId,
rootMessageId: ask.rootMessageId,
questions: ask.questions,
deadlineAt: ask.deadlineAt,
createdAt: ask.createdAt,
}));
return jsonRes(res, 200, { asks });
});

ipcRoute('POST', '/api/asks/answer', async (req, res) => {
if (!isTrustedHostIpcRequest(req)) {
return jsonRes(res, 403, { ok: false, error: 'trusted_host_required' });
}
let body: { askId?: string; selections?: string[][]; by?: string };
try {
body = await readJsonBody(req);
} catch {
return jsonRes(res, 400, { ok: false, error: 'bad_json' });
}
if (!body.askId || !Array.isArray(body.selections)) {
return jsonRes(res, 400, { ok: false, error: 'askId_and_selections_required' });
}
const outcome = submitAskFromDesktop({
askId: body.askId,
selections: body.selections,
by: typeof body.by === 'string' ? body.by : 'desktop',
});
if (outcome !== 'accepted') {
return jsonRes(res, 409, { ok: false, error: outcome });
}
return jsonRes(res, 200, { ok: true, outcome });
});

ipcRoute('GET', '/api/sessions', (_req, res) => {
// Active first (live state), closed appended (historical)
const active = listActiveSessions().map(composeRowFromActive);
Expand Down
8 changes: 8 additions & 0 deletions src/core/dashboard-rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ export interface SessionRow {
/** Riff AIO Sandbox web terminal link. When set, the dashboard "Web终端"
* button opens this URL directly instead of building a local port link. */
riffAccessUrl?: string;
/** Presentation enrichment stamped by the central dashboard read-model:
* bot avatar URL from the live daemon descriptor.
* Absent on older daemons — consumers must fall back. */
botAvatarUrl?: string;
/** Repo top-level dir name of workingDir, when it is a git repo. */
repoName?: string;
/** Current branch of workingDir; absent for detached HEAD / non-repo. */
gitBranch?: string;
}

export function feishuChatLink(chatId: string, brand: Brand = 'feishu'): string {
Expand Down
8 changes: 8 additions & 0 deletions src/core/session-cwd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/
import type { DaemonSession } from './types.js';
import * as sessionStore from '../services/session-store.js';
import { dashboardEventBus } from './dashboard-events.js';

/**
* 重钉一个话题会话的工作目录(daemon 记录 = 唯一事实源):
Expand All @@ -20,4 +21,11 @@ export function repinSessionWorkingDir(ds: DaemonSession, resolvedPath: string):
// 两条改 cwd 的路径都必须清。
ds.session.riffRepoDirs = undefined;
sessionStore.updateSession(ds.session);
dashboardEventBus.publish({
type: 'session.update',
body: {
sessionId: ds.session.sessionId,
patch: { workingDir: resolvedPath },
},
});
}
122 changes: 122 additions & 0 deletions src/core/session-row-enrichment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// src/core/session-row-enrichment.ts
//
// Git presentation metadata for dashboard session rows. Resolution happens in
// the central dashboard read-model (not in an HTTP request handler), so REST
// snapshots and SSE updates share one row shape.

import { execFile } from 'node:child_process';
import { basename } from 'node:path';

// ── Git repo info (per-cwd TTL cache) ─────────────────────────────────────

export type GitRepoInfo = {
/** basename of the repo top-level dir. */
repoName: string;
/** Current branch; null for detached HEAD. */
branch: string | null;
};

export interface GitRepoResolveOptions {
/** Bypass a possibly stale positive cache at a known turn boundary. */
force?: boolean;
}

const GIT_INFO_OK_TTL_MS = 60_000;
const GIT_INFO_MISS_TTL_MS = 300_000;
const GIT_TIMEOUT_MS = 1_500;
/** Concurrent git probes across all callers (a 99-session first poll must not
* fork-bomb the host). */
const GIT_MAX_CONCURRENT_PROBES = 8;

const gitInfoCache = new Map<string, {
at: number;
info: GitRepoInfo | null;
sequence: number;
}>();
/** Dedup so a poll burst spawns at most one git probe per cwd. */
const gitInfoInflight = new Map<string, Promise<GitRepoInfo | null>>();
let gitProbeSequence = 0;

let gitProbesRunning = 0;
const gitProbeQueue: Array<() => void> = [];

async function withGitProbeSlot<T>(fn: () => Promise<T>): Promise<T> {
if (gitProbesRunning >= GIT_MAX_CONCURRENT_PROBES) {
await new Promise<void>((resolve) => gitProbeQueue.push(resolve));
}
gitProbesRunning++;
try {
return await fn();
} finally {
gitProbesRunning--;
gitProbeQueue.shift()?.();
}
}

function runGit(args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
'git',
args,
{ timeout: GIT_TIMEOUT_MS, killSignal: 'SIGKILL', maxBuffer: 64 * 1024 },
(err, stdout) => (err ? reject(err) : resolve(stdout)),
);
});
}

async function probeGitRepoInfo(cwd: string): Promise<GitRepoInfo | null> {
// One probe: line 1 = top-level path, line 2 = branch ('HEAD' when detached).
const out = await runGit(['-C', cwd, 'rev-parse', '--show-toplevel', '--abbrev-ref', 'HEAD']);
const [top = '', branchRaw = ''] = out.split('\n').map((l) => l.trim());
if (!top) return null;
return {
repoName: basename(top) || top,
branch: branchRaw && branchRaw !== 'HEAD' ? branchRaw : null,
};
}

/** Resolve repoName/branch for a session cwd; null when not a git repo. Never throws. */
export async function getGitRepoInfo(
cwd: string,
options: GitRepoResolveOptions = {},
): Promise<GitRepoInfo | null> {
const dir = cwd.trim();
if (!dir) return null;
const now = Date.now();
const hit = gitInfoCache.get(dir);
if (
!options.force
&& hit
&& now - hit.at < (hit.info ? GIT_INFO_OK_TTL_MS : GIT_INFO_MISS_TTL_MS)
) {
return hit.info;
}
const inflight = gitInfoInflight.get(dir);
if (!options.force && inflight) return inflight;
const sequence = ++gitProbeSequence;
const p = (async (): Promise<GitRepoInfo | null> => {
try {
return await withGitProbeSlot(() => probeGitRepoInfo(dir));
} catch {
return null;
}
})();
gitInfoInflight.set(dir, p);
try {
const info = await p;
const current = gitInfoCache.get(dir);
// A force refresh started later must remain authoritative even if an older
// probe happens to finish after it.
if (!current || sequence >= current.sequence) {
gitInfoCache.set(dir, { at: Date.now(), info, sequence });
}
return info;
} finally {
if (gitInfoInflight.get(dir) === p) gitInfoInflight.delete(dir);
}
}

/** Test hook: clear the resolver cache. */
export function clearSessionRowEnrichmentCaches(): void {
gitInfoCache.clear();
}
Loading