@jmoyers/harness 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -155
- package/package.json +5 -1
- package/packages/harness-ai/src/anthropic-client.ts +99 -0
- package/packages/harness-ai/src/anthropic-protocol.ts +581 -0
- package/packages/harness-ai/src/anthropic-provider.ts +82 -0
- package/packages/harness-ai/src/async-iterable-stream.ts +65 -0
- package/packages/harness-ai/src/index.ts +36 -0
- package/packages/harness-ai/src/json-parse.ts +66 -0
- package/packages/harness-ai/src/sse.ts +80 -0
- package/packages/harness-ai/src/stream-object.ts +96 -0
- package/packages/harness-ai/src/stream-text.ts +1340 -0
- package/packages/harness-ai/src/types.ts +330 -0
- package/packages/harness-ai/src/ui-stream.ts +217 -0
- package/scripts/codex-live-mux-runtime.ts +123 -7
- package/scripts/control-plane-daemon.ts +20 -3
- package/scripts/harness.ts +566 -133
- package/src/cli/gateway-record.ts +16 -1
- package/src/control-plane/agent-realtime-api.ts +4 -0
- package/src/control-plane/prompt/agent-prompt-extractor.ts +191 -0
- package/src/control-plane/prompt/extractors/claude-prompt-extractor.ts +53 -0
- package/src/control-plane/prompt/extractors/codex-prompt-extractor.ts +50 -0
- package/src/control-plane/prompt/extractors/cursor-prompt-extractor.ts +56 -0
- package/src/control-plane/prompt/session-prompt-engine.ts +69 -0
- package/src/control-plane/prompt/thread-title-namer.ts +290 -0
- package/src/control-plane/stream-command-parser.ts +12 -0
- package/src/control-plane/stream-protocol.ts +109 -0
- package/src/control-plane/stream-server-command.ts +14 -0
- package/src/control-plane/stream-server-session-runtime.ts +12 -0
- package/src/control-plane/stream-server.ts +485 -19
- package/src/mux/input-shortcuts.ts +9 -0
- package/src/mux/live-mux/critique-review.ts +5 -1
- package/src/mux/live-mux/git-parsing.ts +24 -0
- package/src/mux/live-mux/global-shortcut-handlers.ts +8 -0
- package/src/mux/render-frame.ts +1 -1
- package/src/pty/pty_host.ts +46 -1
- package/src/services/control-plane.ts +22 -0
- package/src/services/runtime-control-actions.ts +69 -0
- package/src/services/runtime-navigation-input.ts +4 -0
- package/src/services/runtime-rail-input.ts +4 -0
- package/src/services/runtime-workspace-actions.ts +5 -0
- package/src/ui/global-shortcut-input.ts +2 -0
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
encodeStreamEnvelope,
|
|
19
19
|
type StreamObservedEvent,
|
|
20
20
|
type StreamSessionKeyEventRecord,
|
|
21
|
+
type StreamSessionPromptRecord,
|
|
21
22
|
type StreamSessionController,
|
|
22
23
|
type StreamSessionListSort,
|
|
23
24
|
type StreamSessionRuntimeStatus,
|
|
@@ -83,6 +84,15 @@ import {
|
|
|
83
84
|
} from './stream-server-session-runtime.ts';
|
|
84
85
|
import { closeOwnedStateStore as closeOwnedStreamServerStateStore } from './stream-server-state-store.ts';
|
|
85
86
|
import { SessionStatusEngine } from './status/session-status-engine.ts';
|
|
87
|
+
import { SessionPromptEngine } from './prompt/session-prompt-engine.ts';
|
|
88
|
+
import {
|
|
89
|
+
appendThreadTitlePromptHistory,
|
|
90
|
+
createAnthropicThreadTitleNamer,
|
|
91
|
+
fallbackThreadTitleFromPromptHistory,
|
|
92
|
+
normalizeThreadTitleCandidate,
|
|
93
|
+
readThreadTitlePromptHistory,
|
|
94
|
+
type ThreadTitleNamer,
|
|
95
|
+
} from './prompt/thread-title-namer.ts';
|
|
86
96
|
import {
|
|
87
97
|
eventIncludesRepositoryId as filterEventIncludesRepositoryId,
|
|
88
98
|
eventIncludesTaskId as filterEventIncludesTaskId,
|
|
@@ -220,6 +230,14 @@ interface GitHubIntegrationConfig {
|
|
|
220
230
|
readonly viewerLogin: string | null;
|
|
221
231
|
}
|
|
222
232
|
|
|
233
|
+
interface ThreadTitleConfig {
|
|
234
|
+
readonly enabled: boolean;
|
|
235
|
+
readonly apiKey: string | null;
|
|
236
|
+
readonly modelId: string | null;
|
|
237
|
+
readonly baseUrl: string | null;
|
|
238
|
+
readonly fetch: typeof fetch | null;
|
|
239
|
+
}
|
|
240
|
+
|
|
223
241
|
interface GitHubRemotePullRequest {
|
|
224
242
|
readonly number: number;
|
|
225
243
|
readonly title: string;
|
|
@@ -283,6 +301,8 @@ interface StartControlPlaneStreamServerOptions {
|
|
|
283
301
|
githubFetch?: typeof fetch;
|
|
284
302
|
readGitDirectorySnapshot?: GitDirectorySnapshotReader;
|
|
285
303
|
lifecycleHooks?: HarnessLifecycleHooksConfig;
|
|
304
|
+
threadTitle?: Partial<ThreadTitleConfig>;
|
|
305
|
+
threadTitleNamer?: ThreadTitleNamer;
|
|
286
306
|
}
|
|
287
307
|
|
|
288
308
|
interface ConnectionState {
|
|
@@ -383,6 +403,17 @@ interface StreamJournalEntry {
|
|
|
383
403
|
event: StreamObservedEvent;
|
|
384
404
|
}
|
|
385
405
|
|
|
406
|
+
interface ThreadTitleRefreshState {
|
|
407
|
+
inFlight: boolean;
|
|
408
|
+
pending: boolean;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
interface ThreadTitleRefreshResult {
|
|
412
|
+
readonly status: 'updated' | 'unchanged' | 'skipped';
|
|
413
|
+
readonly conversation: ControlPlaneConversationRecord | null;
|
|
414
|
+
readonly reason: string | null;
|
|
415
|
+
}
|
|
416
|
+
|
|
386
417
|
interface DirectoryGitStatusCacheEntry {
|
|
387
418
|
readonly summary: GitDirectorySnapshot['summary'];
|
|
388
419
|
readonly repositorySnapshot: GitDirectorySnapshot['repository'];
|
|
@@ -413,6 +444,8 @@ const DEFAULT_GITHUB_POLL_MS = 15_000;
|
|
|
413
444
|
const HISTORY_POLL_JITTER_RATIO = 0.35;
|
|
414
445
|
const SESSION_DIAGNOSTICS_BUCKET_MS = 10_000;
|
|
415
446
|
const SESSION_DIAGNOSTICS_BUCKET_COUNT = 6;
|
|
447
|
+
const PROMPT_EVENT_DEDUPE_TTL_MS = 5 * 60 * 1000;
|
|
448
|
+
const MAX_PROMPT_EVENT_DEDUPE_ENTRIES = 4096;
|
|
416
449
|
const DEFAULT_BOOTSTRAP_SESSION_COLS = 80;
|
|
417
450
|
const DEFAULT_BOOTSTRAP_SESSION_ROWS = 24;
|
|
418
451
|
const DEFAULT_TENANT_ID = 'tenant-local';
|
|
@@ -433,12 +466,17 @@ const DEFAULT_AGENT_INSTALL_COMMANDS: Readonly<Record<AgentToolType, string | nu
|
|
|
433
466
|
const DEFAULT_CURSOR_HOOK_RELAY_SCRIPT_PATH = fileURLToPath(
|
|
434
467
|
new URL('../../scripts/cursor-hook-relay.ts', import.meta.url),
|
|
435
468
|
);
|
|
469
|
+
const THREAD_TITLE_AGENT_TYPES = new Set(['codex', 'claude', 'cursor']);
|
|
436
470
|
const LIFECYCLE_TELEMETRY_EVENT_NAMES = new Set([
|
|
437
471
|
'codex.user_prompt',
|
|
438
472
|
'codex.turn.e2e_duration_ms',
|
|
439
473
|
'codex.conversation_starts',
|
|
440
474
|
]);
|
|
441
475
|
|
|
476
|
+
function isThreadTitleAgentType(agentType: string): boolean {
|
|
477
|
+
return THREAD_TITLE_AGENT_TYPES.has(agentType.trim().toLowerCase());
|
|
478
|
+
}
|
|
479
|
+
|
|
442
480
|
function shellEscape(value: string): string {
|
|
443
481
|
if (value.length === 0) {
|
|
444
482
|
return "''";
|
|
@@ -734,6 +772,33 @@ function normalizeGitHubIntegrationConfig(
|
|
|
734
772
|
};
|
|
735
773
|
}
|
|
736
774
|
|
|
775
|
+
function normalizeThreadTitleConfig(
|
|
776
|
+
input: Partial<ThreadTitleConfig> | undefined,
|
|
777
|
+
): ThreadTitleConfig {
|
|
778
|
+
const envApiKeyRaw = process.env.ANTHROPIC_API_KEY;
|
|
779
|
+
const envApiKey =
|
|
780
|
+
typeof envApiKeyRaw === 'string' && envApiKeyRaw.trim().length > 0 ? envApiKeyRaw.trim() : null;
|
|
781
|
+
const apiKeyRaw = input?.apiKey ?? envApiKey;
|
|
782
|
+
const apiKey =
|
|
783
|
+
typeof apiKeyRaw === 'string' && apiKeyRaw.trim().length > 0 ? apiKeyRaw.trim() : null;
|
|
784
|
+
const envModelRaw = process.env.HARNESS_THREAD_TITLE_MODEL;
|
|
785
|
+
const modelFromEnv =
|
|
786
|
+
typeof envModelRaw === 'string' && envModelRaw.trim().length > 0 ? envModelRaw.trim() : null;
|
|
787
|
+
const modelIdRaw = input?.modelId ?? modelFromEnv;
|
|
788
|
+
const modelId =
|
|
789
|
+
typeof modelIdRaw === 'string' && modelIdRaw.trim().length > 0 ? modelIdRaw.trim() : null;
|
|
790
|
+
const baseUrlRaw = input?.baseUrl;
|
|
791
|
+
const baseUrl =
|
|
792
|
+
typeof baseUrlRaw === 'string' && baseUrlRaw.trim().length > 0 ? baseUrlRaw.trim() : null;
|
|
793
|
+
return {
|
|
794
|
+
enabled: input?.enabled ?? apiKey !== null,
|
|
795
|
+
apiKey,
|
|
796
|
+
modelId,
|
|
797
|
+
baseUrl,
|
|
798
|
+
fetch: input?.fetch ?? null,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
737
802
|
function parseGitHubOwnerRepoFromRemote(remoteUrl: string): { owner: string; repo: string } | null {
|
|
738
803
|
const trimmed = remoteUrl.trim();
|
|
739
804
|
if (trimmed.length === 0) {
|
|
@@ -1054,6 +1119,11 @@ export class ControlPlaneStreamServer {
|
|
|
1054
1119
|
};
|
|
1055
1120
|
private readonly readGitDirectorySnapshot: GitDirectorySnapshotReader;
|
|
1056
1121
|
private readonly statusEngine = new SessionStatusEngine();
|
|
1122
|
+
private readonly promptEngine = new SessionPromptEngine();
|
|
1123
|
+
private readonly threadTitleNamer: ThreadTitleNamer | null;
|
|
1124
|
+
private readonly promptEventDedupeByKey = new Map<string, number>();
|
|
1125
|
+
private readonly threadTitleRevisionBySessionId = new Map<string, number>();
|
|
1126
|
+
private readonly threadTitleRefreshBySessionId = new Map<string, ThreadTitleRefreshState>();
|
|
1057
1127
|
private readonly server: Server;
|
|
1058
1128
|
private readonly telemetryServer: HttpServer | null;
|
|
1059
1129
|
private telemetryAddress: AddressInfo | null = null;
|
|
@@ -1071,6 +1141,7 @@ export class ControlPlaneStreamServer {
|
|
|
1071
1141
|
private githubTokenResolutionError: string | null = null;
|
|
1072
1142
|
private gitStatusPollInFlight = false;
|
|
1073
1143
|
private githubPollInFlight = false;
|
|
1144
|
+
private githubPollPromise: Promise<void> | null = null;
|
|
1074
1145
|
private readonly gitStatusRefreshInFlightDirectoryIds = new Set<string>();
|
|
1075
1146
|
private readonly gitStatusByDirectoryId = new Map<string, DirectoryGitStatusCacheEntry>();
|
|
1076
1147
|
private readonly gitStatusDirectoriesById = new Map<string, ControlPlaneDirectoryRecord>();
|
|
@@ -1082,6 +1153,7 @@ export class ControlPlaneStreamServer {
|
|
|
1082
1153
|
private streamCursor = 0;
|
|
1083
1154
|
private listening = false;
|
|
1084
1155
|
private stateStoreClosed = false;
|
|
1156
|
+
private closing = false;
|
|
1085
1157
|
|
|
1086
1158
|
constructor(options: StartControlPlaneStreamServerOptions = {}) {
|
|
1087
1159
|
this.host = options.host ?? '127.0.0.1';
|
|
@@ -1127,6 +1199,19 @@ export class ControlPlaneStreamServer {
|
|
|
1127
1199
|
await readGitDirectorySnapshot(cwd, undefined, {
|
|
1128
1200
|
includeCommitCount: false,
|
|
1129
1201
|
}));
|
|
1202
|
+
const threadTitleConfig = normalizeThreadTitleConfig(options.threadTitle);
|
|
1203
|
+
if (options.threadTitleNamer !== undefined) {
|
|
1204
|
+
this.threadTitleNamer = options.threadTitleNamer;
|
|
1205
|
+
} else if (threadTitleConfig.enabled && threadTitleConfig.apiKey !== null) {
|
|
1206
|
+
this.threadTitleNamer = createAnthropicThreadTitleNamer({
|
|
1207
|
+
apiKey: threadTitleConfig.apiKey,
|
|
1208
|
+
...(threadTitleConfig.modelId === null ? {} : { modelId: threadTitleConfig.modelId }),
|
|
1209
|
+
...(threadTitleConfig.baseUrl === null ? {} : { baseUrl: threadTitleConfig.baseUrl }),
|
|
1210
|
+
...(threadTitleConfig.fetch === null ? {} : { fetch: threadTitleConfig.fetch }),
|
|
1211
|
+
});
|
|
1212
|
+
} else {
|
|
1213
|
+
this.threadTitleNamer = null;
|
|
1214
|
+
}
|
|
1130
1215
|
this.lifecycleHooks = new LifecycleHooksRuntime(
|
|
1131
1216
|
options.lifecycleHooks ?? {
|
|
1132
1217
|
enabled: false,
|
|
@@ -1205,9 +1290,11 @@ export class ControlPlaneStreamServer {
|
|
|
1205
1290
|
}
|
|
1206
1291
|
|
|
1207
1292
|
async close(): Promise<void> {
|
|
1293
|
+
this.closing = true;
|
|
1208
1294
|
this.stopHistoryPolling();
|
|
1209
1295
|
this.stopGitStatusPolling();
|
|
1210
1296
|
this.stopGitHubPolling();
|
|
1297
|
+
await this.waitForGitHubPollingToSettle();
|
|
1211
1298
|
|
|
1212
1299
|
for (const sessionId of [...this.sessions.keys()]) {
|
|
1213
1300
|
this.destroySession(sessionId, true);
|
|
@@ -1390,9 +1477,9 @@ export class ControlPlaneStreamServer {
|
|
|
1390
1477
|
if (!this.github.enabled || this.githubPollTimer !== null) {
|
|
1391
1478
|
return;
|
|
1392
1479
|
}
|
|
1393
|
-
|
|
1480
|
+
this.triggerGitHubPoll();
|
|
1394
1481
|
this.githubPollTimer = setInterval(() => {
|
|
1395
|
-
|
|
1482
|
+
this.triggerGitHubPoll();
|
|
1396
1483
|
}, this.github.pollMs);
|
|
1397
1484
|
this.githubPollTimer.unref();
|
|
1398
1485
|
}
|
|
@@ -1405,6 +1492,49 @@ export class ControlPlaneStreamServer {
|
|
|
1405
1492
|
this.githubPollTimer = null;
|
|
1406
1493
|
}
|
|
1407
1494
|
|
|
1495
|
+
private triggerGitHubPoll(): void {
|
|
1496
|
+
void this.pollGitHub().catch((error: unknown) => {
|
|
1497
|
+
if (this.shouldIgnoreGitHubPollError(error)) {
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1501
|
+
recordPerfEvent('control-plane.github.poll.failed', {
|
|
1502
|
+
error: message,
|
|
1503
|
+
});
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
private isStateStoreClosedError(error: unknown): boolean {
|
|
1508
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1509
|
+
const normalized = message.trim().toLowerCase();
|
|
1510
|
+
return normalized.includes('database has closed') || normalized.includes('database is closed');
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
private shouldSkipStateStoreWork(): boolean {
|
|
1514
|
+
return this.closing || this.stateStoreClosed;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
private shouldIgnoreGitHubPollError(error: unknown): boolean {
|
|
1518
|
+
return this.shouldSkipStateStoreWork() || this.isStateStoreClosedError(error);
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
private async waitForGitHubPollingToSettle(): Promise<void> {
|
|
1522
|
+
const pollPromise = this.githubPollPromise;
|
|
1523
|
+
if (pollPromise === null) {
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
try {
|
|
1527
|
+
await pollPromise;
|
|
1528
|
+
} catch (error: unknown) {
|
|
1529
|
+
if (!this.shouldIgnoreGitHubPollError(error)) {
|
|
1530
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1531
|
+
recordPerfEvent('control-plane.github.poll.failed-on-close', {
|
|
1532
|
+
error: message,
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1408
1538
|
private reloadGitStatusDirectoriesFromStore(): void {
|
|
1409
1539
|
const directories = this.stateStore.listDirectories({
|
|
1410
1540
|
includeArchived: false,
|
|
@@ -1943,6 +2073,31 @@ export class ControlPlaneStreamServer {
|
|
|
1943
2073
|
event.observedAt,
|
|
1944
2074
|
);
|
|
1945
2075
|
}
|
|
2076
|
+
if (inserted && resolvedSessionId !== null) {
|
|
2077
|
+
const promptEvent = this.promptEngine.extractFromTelemetry({
|
|
2078
|
+
agentType: 'codex',
|
|
2079
|
+
source: event.source,
|
|
2080
|
+
eventName: event.eventName,
|
|
2081
|
+
summary: event.summary,
|
|
2082
|
+
payload: event.payload,
|
|
2083
|
+
observedAt: event.observedAt,
|
|
2084
|
+
});
|
|
2085
|
+
if (promptEvent !== null) {
|
|
2086
|
+
const liveState = this.sessions.get(resolvedSessionId);
|
|
2087
|
+
if (liveState !== undefined) {
|
|
2088
|
+
this.publishSessionPromptObservedEvent(liveState, promptEvent);
|
|
2089
|
+
} else {
|
|
2090
|
+
const observedScope = this.observedScopeForSessionId(resolvedSessionId);
|
|
2091
|
+
if (observedScope !== null) {
|
|
2092
|
+
this.publishSessionPromptObservedEventForScope(
|
|
2093
|
+
resolvedSessionId,
|
|
2094
|
+
observedScope,
|
|
2095
|
+
promptEvent,
|
|
2096
|
+
);
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
1946
2101
|
if (!inserted || resolvedSessionId === null) {
|
|
1947
2102
|
return;
|
|
1948
2103
|
}
|
|
@@ -2081,11 +2236,11 @@ export class ControlPlaneStreamServer {
|
|
|
2081
2236
|
}
|
|
2082
2237
|
|
|
2083
2238
|
private async pollGitHub(): Promise<void> {
|
|
2084
|
-
if (!this.github.enabled || this.githubPollInFlight) {
|
|
2239
|
+
if (!this.github.enabled || this.githubPollInFlight || this.shouldSkipStateStoreWork()) {
|
|
2085
2240
|
return;
|
|
2086
2241
|
}
|
|
2087
2242
|
this.githubPollInFlight = true;
|
|
2088
|
-
|
|
2243
|
+
const pollPromise = (async () => {
|
|
2089
2244
|
const directories = this.stateStore.listDirectories({
|
|
2090
2245
|
includeArchived: false,
|
|
2091
2246
|
limit: 1000,
|
|
@@ -2101,6 +2256,9 @@ export class ControlPlaneStreamServer {
|
|
|
2101
2256
|
}
|
|
2102
2257
|
>();
|
|
2103
2258
|
for (const directory of directories) {
|
|
2259
|
+
if (this.shouldSkipStateStoreWork()) {
|
|
2260
|
+
return;
|
|
2261
|
+
}
|
|
2104
2262
|
const gitStatus = this.gitStatusByDirectoryId.get(directory.directoryId);
|
|
2105
2263
|
const repositoryId = gitStatus?.repositoryId ?? null;
|
|
2106
2264
|
if (repositoryId === null) {
|
|
@@ -2135,21 +2293,31 @@ export class ControlPlaneStreamServer {
|
|
|
2135
2293
|
branchName,
|
|
2136
2294
|
});
|
|
2137
2295
|
}
|
|
2138
|
-
if (targetsByKey.size === 0) {
|
|
2296
|
+
if (targetsByKey.size === 0 || this.shouldSkipStateStoreWork()) {
|
|
2139
2297
|
return;
|
|
2140
2298
|
}
|
|
2141
2299
|
const token = this.github.token ?? (await this.resolveGitHubTokenIfNeeded());
|
|
2142
|
-
if (token === null) {
|
|
2300
|
+
if (token === null || this.shouldSkipStateStoreWork()) {
|
|
2143
2301
|
return;
|
|
2144
2302
|
}
|
|
2145
2303
|
await runWithConcurrencyLimit(
|
|
2146
2304
|
[...targetsByKey.values()],
|
|
2147
2305
|
this.github.maxConcurrency,
|
|
2148
2306
|
async (target) => {
|
|
2307
|
+
if (this.shouldSkipStateStoreWork()) {
|
|
2308
|
+
return;
|
|
2309
|
+
}
|
|
2149
2310
|
await this.syncGitHubBranch(target);
|
|
2150
2311
|
},
|
|
2151
2312
|
);
|
|
2313
|
+
})();
|
|
2314
|
+
this.githubPollPromise = pollPromise;
|
|
2315
|
+
try {
|
|
2316
|
+
await pollPromise;
|
|
2152
2317
|
} finally {
|
|
2318
|
+
if (this.githubPollPromise === pollPromise) {
|
|
2319
|
+
this.githubPollPromise = null;
|
|
2320
|
+
}
|
|
2153
2321
|
this.githubPollInFlight = false;
|
|
2154
2322
|
}
|
|
2155
2323
|
}
|
|
@@ -2161,6 +2329,9 @@ export class ControlPlaneStreamServer {
|
|
|
2161
2329
|
repo: string;
|
|
2162
2330
|
branchName: string;
|
|
2163
2331
|
}): Promise<void> {
|
|
2332
|
+
if (this.shouldSkipStateStoreWork()) {
|
|
2333
|
+
return;
|
|
2334
|
+
}
|
|
2164
2335
|
const now = new Date().toISOString();
|
|
2165
2336
|
const syncStateId = `github-sync:${input.repository.repositoryId}:${input.directory.directoryId}:${input.branchName}`;
|
|
2166
2337
|
try {
|
|
@@ -2169,6 +2340,9 @@ export class ControlPlaneStreamServer {
|
|
|
2169
2340
|
repo: input.repo,
|
|
2170
2341
|
headBranch: input.branchName,
|
|
2171
2342
|
});
|
|
2343
|
+
if (this.shouldSkipStateStoreWork()) {
|
|
2344
|
+
return;
|
|
2345
|
+
}
|
|
2172
2346
|
if (remotePr === null) {
|
|
2173
2347
|
const staleOpen = this.stateStore.listGitHubPullRequests({
|
|
2174
2348
|
repositoryId: input.repository.repositoryId,
|
|
@@ -2255,6 +2429,9 @@ export class ControlPlaneStreamServer {
|
|
|
2255
2429
|
repo: input.repo,
|
|
2256
2430
|
headSha: storedPr.headSha,
|
|
2257
2431
|
});
|
|
2432
|
+
if (this.shouldSkipStateStoreWork()) {
|
|
2433
|
+
return;
|
|
2434
|
+
}
|
|
2258
2435
|
const rollup = summarizeGitHubCiRollup(jobs);
|
|
2259
2436
|
const updatedPr =
|
|
2260
2437
|
this.stateStore.updateGitHubPullRequestCiRollup(
|
|
@@ -2314,20 +2491,29 @@ export class ControlPlaneStreamServer {
|
|
|
2314
2491
|
lastErrorAt: null,
|
|
2315
2492
|
});
|
|
2316
2493
|
} catch (error: unknown) {
|
|
2494
|
+
if (this.shouldIgnoreGitHubPollError(error)) {
|
|
2495
|
+
return;
|
|
2496
|
+
}
|
|
2317
2497
|
const message = error instanceof Error ? error.message : String(error);
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2498
|
+
try {
|
|
2499
|
+
this.stateStore.upsertGitHubSyncState({
|
|
2500
|
+
stateId: syncStateId,
|
|
2501
|
+
tenantId: input.directory.tenantId,
|
|
2502
|
+
userId: input.directory.userId,
|
|
2503
|
+
workspaceId: input.directory.workspaceId,
|
|
2504
|
+
repositoryId: input.repository.repositoryId,
|
|
2505
|
+
directoryId: input.directory.directoryId,
|
|
2506
|
+
branchName: input.branchName,
|
|
2507
|
+
lastSyncAt: now,
|
|
2508
|
+
lastSuccessAt: null,
|
|
2509
|
+
lastError: message,
|
|
2510
|
+
lastErrorAt: now,
|
|
2511
|
+
});
|
|
2512
|
+
} catch (syncStateError: unknown) {
|
|
2513
|
+
if (!this.shouldIgnoreGitHubPollError(syncStateError)) {
|
|
2514
|
+
throw syncStateError;
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2331
2517
|
}
|
|
2332
2518
|
}
|
|
2333
2519
|
|
|
@@ -2617,6 +2803,27 @@ export class ControlPlaneStreamServer {
|
|
|
2617
2803
|
);
|
|
2618
2804
|
}
|
|
2619
2805
|
|
|
2806
|
+
private async refreshConversationTitle(conversationId: string): Promise<{
|
|
2807
|
+
conversation: ControlPlaneConversationRecord;
|
|
2808
|
+
status: 'updated' | 'unchanged' | 'skipped';
|
|
2809
|
+
reason: string | null;
|
|
2810
|
+
}> {
|
|
2811
|
+
const existing = this.stateStore.getConversation(conversationId);
|
|
2812
|
+
if (existing === null) {
|
|
2813
|
+
throw new Error(`conversation not found: ${conversationId}`);
|
|
2814
|
+
}
|
|
2815
|
+
const refreshed = await this.refreshThreadTitle(conversationId);
|
|
2816
|
+
const conversation = this.stateStore.getConversation(conversationId);
|
|
2817
|
+
if (conversation === null) {
|
|
2818
|
+
throw new Error(`conversation not found: ${conversationId}`);
|
|
2819
|
+
}
|
|
2820
|
+
return {
|
|
2821
|
+
conversation,
|
|
2822
|
+
status: refreshed.status,
|
|
2823
|
+
reason: refreshed.reason,
|
|
2824
|
+
};
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2620
2827
|
private handleInput(connectionId: string, sessionId: string, dataBase64: string): void {
|
|
2621
2828
|
handleRuntimeInput(
|
|
2622
2829
|
this as unknown as Parameters<typeof handleRuntimeInput>[0],
|
|
@@ -2674,6 +2881,260 @@ export class ControlPlaneStreamServer {
|
|
|
2674
2881
|
});
|
|
2675
2882
|
}
|
|
2676
2883
|
|
|
2884
|
+
private promptEventSecondBucket(observedAt: string): string {
|
|
2885
|
+
const normalized = observedAt.trim();
|
|
2886
|
+
if (normalized.length >= 19) {
|
|
2887
|
+
return normalized.slice(0, 19);
|
|
2888
|
+
}
|
|
2889
|
+
return normalized;
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
private shouldPublishPromptEvent(sessionId: string, prompt: StreamSessionPromptRecord): boolean {
|
|
2893
|
+
const bucket = this.promptEventSecondBucket(prompt.observedAt);
|
|
2894
|
+
const dedupeKey = `${sessionId}:${prompt.hash}:${prompt.providerEventName ?? ''}:${bucket}`;
|
|
2895
|
+
if (this.promptEventDedupeByKey.has(dedupeKey)) {
|
|
2896
|
+
return false;
|
|
2897
|
+
}
|
|
2898
|
+
const nowMs = Date.now();
|
|
2899
|
+
this.promptEventDedupeByKey.set(dedupeKey, nowMs);
|
|
2900
|
+
if (this.promptEventDedupeByKey.size > MAX_PROMPT_EVENT_DEDUPE_ENTRIES) {
|
|
2901
|
+
for (const [key, observedMs] of this.promptEventDedupeByKey) {
|
|
2902
|
+
if (nowMs - observedMs > PROMPT_EVENT_DEDUPE_TTL_MS) {
|
|
2903
|
+
this.promptEventDedupeByKey.delete(key);
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
if (this.promptEventDedupeByKey.size > MAX_PROMPT_EVENT_DEDUPE_ENTRIES) {
|
|
2907
|
+
let dropCount = this.promptEventDedupeByKey.size - MAX_PROMPT_EVENT_DEDUPE_ENTRIES;
|
|
2908
|
+
for (const key of this.promptEventDedupeByKey.keys()) {
|
|
2909
|
+
this.promptEventDedupeByKey.delete(key);
|
|
2910
|
+
dropCount -= 1;
|
|
2911
|
+
if (dropCount <= 0) {
|
|
2912
|
+
break;
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
return true;
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
private publishSessionPromptObservedEventForScope(
|
|
2921
|
+
sessionId: string,
|
|
2922
|
+
scope: StreamObservedScope,
|
|
2923
|
+
prompt: StreamSessionPromptRecord,
|
|
2924
|
+
): void {
|
|
2925
|
+
if (!this.shouldPublishPromptEvent(sessionId, prompt)) {
|
|
2926
|
+
return;
|
|
2927
|
+
}
|
|
2928
|
+
this.recordThreadPrompt(sessionId, prompt);
|
|
2929
|
+
this.publishObservedEvent(scope, {
|
|
2930
|
+
type: 'session-prompt-event',
|
|
2931
|
+
sessionId,
|
|
2932
|
+
prompt: {
|
|
2933
|
+
text: prompt.text,
|
|
2934
|
+
hash: prompt.hash,
|
|
2935
|
+
confidence: prompt.confidence,
|
|
2936
|
+
captureSource: prompt.captureSource,
|
|
2937
|
+
providerEventName: prompt.providerEventName,
|
|
2938
|
+
providerPayloadKeys: [...prompt.providerPayloadKeys],
|
|
2939
|
+
observedAt: prompt.observedAt,
|
|
2940
|
+
},
|
|
2941
|
+
ts: new Date().toISOString(),
|
|
2942
|
+
directoryId: scope.directoryId,
|
|
2943
|
+
conversationId: scope.conversationId,
|
|
2944
|
+
});
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
private publishSessionPromptObservedEvent(
|
|
2948
|
+
state: SessionState,
|
|
2949
|
+
prompt: StreamSessionPromptRecord,
|
|
2950
|
+
): void {
|
|
2951
|
+
this.publishSessionPromptObservedEventForScope(state.id, this.sessionScope(state), prompt);
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
private recordThreadPrompt(sessionId: string, prompt: StreamSessionPromptRecord): void {
|
|
2955
|
+
const conversation = this.stateStore.getConversation(sessionId);
|
|
2956
|
+
if (conversation === null) {
|
|
2957
|
+
this.threadTitleRevisionBySessionId.delete(sessionId);
|
|
2958
|
+
this.threadTitleRefreshBySessionId.delete(sessionId);
|
|
2959
|
+
return;
|
|
2960
|
+
}
|
|
2961
|
+
if (!isThreadTitleAgentType(conversation.agentType)) {
|
|
2962
|
+
return;
|
|
2963
|
+
}
|
|
2964
|
+
const appended = appendThreadTitlePromptHistory(conversation.adapterState, prompt);
|
|
2965
|
+
if (!appended.added) {
|
|
2966
|
+
return;
|
|
2967
|
+
}
|
|
2968
|
+
this.stateStore.updateConversationAdapterState(sessionId, appended.nextAdapterState);
|
|
2969
|
+
const liveState = this.sessions.get(sessionId);
|
|
2970
|
+
if (liveState !== undefined) {
|
|
2971
|
+
liveState.adapterState = appended.nextAdapterState;
|
|
2972
|
+
}
|
|
2973
|
+
const nextRevision = (this.threadTitleRevisionBySessionId.get(sessionId) ?? 0) + 1;
|
|
2974
|
+
this.threadTitleRevisionBySessionId.set(sessionId, nextRevision);
|
|
2975
|
+
if (this.threadTitleNamer !== null) {
|
|
2976
|
+
this.scheduleThreadTitleRefresh(sessionId);
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
private scheduleThreadTitleRefresh(sessionId: string): void {
|
|
2981
|
+
const state = this.threadTitleRefreshBySessionId.get(sessionId) ?? {
|
|
2982
|
+
inFlight: false,
|
|
2983
|
+
pending: false,
|
|
2984
|
+
};
|
|
2985
|
+
this.threadTitleRefreshBySessionId.set(sessionId, state);
|
|
2986
|
+
if (state.inFlight) {
|
|
2987
|
+
state.pending = true;
|
|
2988
|
+
return;
|
|
2989
|
+
}
|
|
2990
|
+
state.inFlight = true;
|
|
2991
|
+
state.pending = false;
|
|
2992
|
+
void this.runThreadTitleRefreshLoop(sessionId, state);
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
private async runThreadTitleRefreshLoop(
|
|
2996
|
+
sessionId: string,
|
|
2997
|
+
refreshState: ThreadTitleRefreshState,
|
|
2998
|
+
): Promise<void> {
|
|
2999
|
+
let shouldReschedule = false;
|
|
3000
|
+
try {
|
|
3001
|
+
while (true) {
|
|
3002
|
+
refreshState.pending = false;
|
|
3003
|
+
const revision = this.threadTitleRevisionBySessionId.get(sessionId) ?? 0;
|
|
3004
|
+
if (revision <= 0) {
|
|
3005
|
+
return;
|
|
3006
|
+
}
|
|
3007
|
+
await this.refreshThreadTitleForRevision(sessionId, revision);
|
|
3008
|
+
const latestRevision = this.threadTitleRevisionBySessionId.get(sessionId) ?? 0;
|
|
3009
|
+
if (!refreshState.pending && latestRevision === revision) {
|
|
3010
|
+
return;
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
} finally {
|
|
3014
|
+
refreshState.inFlight = false;
|
|
3015
|
+
shouldReschedule = refreshState.pending;
|
|
3016
|
+
if (!shouldReschedule) {
|
|
3017
|
+
this.threadTitleRefreshBySessionId.delete(sessionId);
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
if (shouldReschedule) {
|
|
3021
|
+
this.scheduleThreadTitleRefresh(sessionId);
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
private async refreshThreadTitle(
|
|
3026
|
+
sessionId: string,
|
|
3027
|
+
expectedRevision?: number,
|
|
3028
|
+
): Promise<ThreadTitleRefreshResult> {
|
|
3029
|
+
if (this.threadTitleNamer === null) {
|
|
3030
|
+
return {
|
|
3031
|
+
status: 'skipped',
|
|
3032
|
+
conversation: this.stateStore.getConversation(sessionId),
|
|
3033
|
+
reason: 'thread-title-namer-disabled',
|
|
3034
|
+
};
|
|
3035
|
+
}
|
|
3036
|
+
const conversation = this.stateStore.getConversation(sessionId);
|
|
3037
|
+
if (conversation === null) {
|
|
3038
|
+
if (expectedRevision !== undefined) {
|
|
3039
|
+
this.threadTitleRevisionBySessionId.delete(sessionId);
|
|
3040
|
+
}
|
|
3041
|
+
return {
|
|
3042
|
+
status: 'skipped',
|
|
3043
|
+
conversation: null,
|
|
3044
|
+
reason: 'conversation-not-found',
|
|
3045
|
+
};
|
|
3046
|
+
}
|
|
3047
|
+
if (!isThreadTitleAgentType(conversation.agentType)) {
|
|
3048
|
+
return {
|
|
3049
|
+
status: 'skipped',
|
|
3050
|
+
conversation,
|
|
3051
|
+
reason: 'non-agent-thread',
|
|
3052
|
+
};
|
|
3053
|
+
}
|
|
3054
|
+
const promptHistory = readThreadTitlePromptHistory(conversation.adapterState);
|
|
3055
|
+
if (promptHistory.length === 0) {
|
|
3056
|
+
return {
|
|
3057
|
+
status: 'skipped',
|
|
3058
|
+
conversation,
|
|
3059
|
+
reason: 'prompt-history-empty',
|
|
3060
|
+
};
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
let suggestedTitle: string | null = null;
|
|
3064
|
+
try {
|
|
3065
|
+
suggestedTitle = await this.threadTitleNamer.suggest({
|
|
3066
|
+
conversationId: conversation.conversationId,
|
|
3067
|
+
agentType: conversation.agentType,
|
|
3068
|
+
currentTitle: conversation.title,
|
|
3069
|
+
promptHistory,
|
|
3070
|
+
});
|
|
3071
|
+
} catch (error: unknown) {
|
|
3072
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3073
|
+
recordPerfEvent('control-plane.thread-title.error', {
|
|
3074
|
+
sessionId,
|
|
3075
|
+
error: message,
|
|
3076
|
+
});
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3079
|
+
if (expectedRevision !== undefined) {
|
|
3080
|
+
const latestRevision = this.threadTitleRevisionBySessionId.get(sessionId) ?? 0;
|
|
3081
|
+
if (latestRevision !== expectedRevision) {
|
|
3082
|
+
return {
|
|
3083
|
+
status: 'skipped',
|
|
3084
|
+
conversation,
|
|
3085
|
+
reason: 'stale-revision',
|
|
3086
|
+
};
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
const nextTitle =
|
|
3091
|
+
(suggestedTitle === null ? null : normalizeThreadTitleCandidate(suggestedTitle)) ??
|
|
3092
|
+
fallbackThreadTitleFromPromptHistory(promptHistory);
|
|
3093
|
+
if (conversation.title === nextTitle) {
|
|
3094
|
+
return {
|
|
3095
|
+
status: 'unchanged',
|
|
3096
|
+
conversation,
|
|
3097
|
+
reason: null,
|
|
3098
|
+
};
|
|
3099
|
+
}
|
|
3100
|
+
const updated = this.stateStore.updateConversationTitle(sessionId, nextTitle);
|
|
3101
|
+
if (updated === null) {
|
|
3102
|
+
return {
|
|
3103
|
+
status: 'skipped',
|
|
3104
|
+
conversation: null,
|
|
3105
|
+
reason: 'conversation-not-found',
|
|
3106
|
+
};
|
|
3107
|
+
}
|
|
3108
|
+
this.publishConversationUpdatedObservedEvent(updated);
|
|
3109
|
+
return {
|
|
3110
|
+
status: 'updated',
|
|
3111
|
+
conversation: updated,
|
|
3112
|
+
reason: null,
|
|
3113
|
+
};
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
private async refreshThreadTitleForRevision(sessionId: string, revision: number): Promise<void> {
|
|
3117
|
+
await this.refreshThreadTitle(sessionId, revision);
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
private publishConversationUpdatedObservedEvent(
|
|
3121
|
+
conversation: ControlPlaneConversationRecord,
|
|
3122
|
+
): void {
|
|
3123
|
+
this.publishObservedEvent(
|
|
3124
|
+
{
|
|
3125
|
+
tenantId: conversation.tenantId,
|
|
3126
|
+
userId: conversation.userId,
|
|
3127
|
+
workspaceId: conversation.workspaceId,
|
|
3128
|
+
directoryId: conversation.directoryId,
|
|
3129
|
+
conversationId: conversation.conversationId,
|
|
3130
|
+
},
|
|
3131
|
+
{
|
|
3132
|
+
type: 'conversation-updated',
|
|
3133
|
+
conversation: this.conversationRecord(conversation),
|
|
3134
|
+
},
|
|
3135
|
+
);
|
|
3136
|
+
}
|
|
3137
|
+
|
|
2677
3138
|
private setSessionStatus(
|
|
2678
3139
|
state: SessionState,
|
|
2679
3140
|
status: StreamSessionRuntimeStatus,
|
|
@@ -3076,6 +3537,8 @@ export class ControlPlaneStreamServer {
|
|
|
3076
3537
|
|
|
3077
3538
|
this.sessions.delete(sessionId);
|
|
3078
3539
|
this.launchCommandBySessionId.delete(sessionId);
|
|
3540
|
+
this.threadTitleRevisionBySessionId.delete(sessionId);
|
|
3541
|
+
this.threadTitleRefreshBySessionId.delete(sessionId);
|
|
3079
3542
|
for (const [token, mappedSessionId] of this.telemetryTokenToSessionId.entries()) {
|
|
3080
3543
|
if (mappedSessionId === sessionId) {
|
|
3081
3544
|
this.telemetryTokenToSessionId.delete(token);
|
|
@@ -3212,6 +3675,9 @@ export class ControlPlaneStreamServer {
|
|
|
3212
3675
|
if (event.type === 'session-key-event') {
|
|
3213
3676
|
return event.sessionId;
|
|
3214
3677
|
}
|
|
3678
|
+
if (event.type === 'session-prompt-event') {
|
|
3679
|
+
return event.sessionId;
|
|
3680
|
+
}
|
|
3215
3681
|
if (event.type === 'session-control') {
|
|
3216
3682
|
return event.sessionId;
|
|
3217
3683
|
}
|