@sapiom/harness 0.1.6 → 0.2.0
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/CHANGELOG.md +90 -0
- package/dist/cli/args.d.ts +16 -0
- package/dist/cli/args.d.ts.map +1 -0
- package/dist/cli/args.js +80 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/bin.js +12 -65
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/settings.d.ts.map +1 -1
- package/dist/cli/settings.js +33 -1
- package/dist/cli/settings.js.map +1 -1
- package/dist/core/adapters/claude-code.d.ts +60 -2
- package/dist/core/adapters/claude-code.d.ts.map +1 -1
- package/dist/core/adapters/claude-code.js +119 -32
- package/dist/core/adapters/claude-code.js.map +1 -1
- package/dist/core/adapters/codex.d.ts +21 -2
- package/dist/core/adapters/codex.d.ts.map +1 -1
- package/dist/core/adapters/codex.js +53 -2
- package/dist/core/adapters/codex.js.map +1 -1
- package/dist/core/adapters/detect.d.ts +9 -0
- package/dist/core/adapters/detect.d.ts.map +1 -1
- package/dist/core/adapters/detect.js +9 -0
- package/dist/core/adapters/detect.js.map +1 -1
- package/dist/core/canvas-body.d.ts +28 -28
- package/dist/core/canvas-body.d.ts.map +1 -1
- package/dist/core/canvas-body.js +117 -55
- package/dist/core/canvas-body.js.map +1 -1
- package/dist/core/canvas-derive.d.ts +43 -0
- package/dist/core/canvas-derive.d.ts.map +1 -0
- package/dist/core/canvas-derive.js +95 -0
- package/dist/core/canvas-derive.js.map +1 -0
- package/dist/core/canvas-enrichment.d.ts +52 -127
- package/dist/core/canvas-enrichment.d.ts.map +1 -1
- package/dist/core/canvas-enrichment.js +17 -178
- package/dist/core/canvas-enrichment.js.map +1 -1
- package/dist/core/canvas-graph.d.ts +40 -1
- package/dist/core/canvas-graph.d.ts.map +1 -1
- package/dist/core/canvas-graph.js +71 -9
- package/dist/core/canvas-graph.js.map +1 -1
- package/dist/core/canvas-interconnections.d.ts +26 -8
- package/dist/core/canvas-interconnections.d.ts.map +1 -1
- package/dist/core/canvas-interconnections.js +132 -24
- package/dist/core/canvas-interconnections.js.map +1 -1
- package/dist/core/canvas-manifest-check.d.ts.map +1 -1
- package/dist/core/canvas-manifest-check.js +15 -2
- package/dist/core/canvas-manifest-check.js.map +1 -1
- package/dist/core/canvas-render.d.ts +8 -15
- package/dist/core/canvas-render.d.ts.map +1 -1
- package/dist/core/canvas-render.js +29 -41
- package/dist/core/canvas-render.js.map +1 -1
- package/dist/core/canvas-run-state.d.ts +41 -0
- package/dist/core/canvas-run-state.d.ts.map +1 -1
- package/dist/core/canvas-run-state.js +136 -0
- package/dist/core/canvas-run-state.js.map +1 -1
- package/dist/core/canvas-svg.d.ts +9 -4
- package/dist/core/canvas-svg.d.ts.map +1 -1
- package/dist/core/canvas-svg.js +84 -13
- package/dist/core/canvas-svg.js.map +1 -1
- package/dist/core/canvas-template.d.ts +9 -10
- package/dist/core/canvas-template.d.ts.map +1 -1
- package/dist/core/canvas-template.js +57 -20
- package/dist/core/canvas-template.js.map +1 -1
- package/dist/core/canvas-watcher.d.ts +22 -1
- package/dist/core/canvas-watcher.d.ts.map +1 -1
- package/dist/core/canvas-watcher.js +111 -30
- package/dist/core/canvas-watcher.js.map +1 -1
- package/dist/core/collector/normalizer.d.ts +8 -0
- package/dist/core/collector/normalizer.d.ts.map +1 -1
- package/dist/core/collector/normalizer.js +8 -0
- package/dist/core/collector/normalizer.js.map +1 -1
- package/dist/core/collector/store.d.ts +75 -1
- package/dist/core/collector/store.d.ts.map +1 -1
- package/dist/core/collector/store.js +334 -2
- package/dist/core/collector/store.js.map +1 -1
- package/dist/core/errors.d.ts +10 -4
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +11 -5
- package/dist/core/errors.js.map +1 -1
- package/dist/core/example-seed.d.ts +8 -0
- package/dist/core/example-seed.d.ts.map +1 -1
- package/dist/core/example-seed.js +47 -10
- package/dist/core/example-seed.js.map +1 -1
- package/dist/core/inject/system-prompt.d.ts +8 -0
- package/dist/core/inject/system-prompt.d.ts.map +1 -1
- package/dist/core/inject/system-prompt.js +3 -1
- package/dist/core/inject/system-prompt.js.map +1 -1
- package/dist/core/macros.d.ts.map +1 -1
- package/dist/core/macros.js +5 -6
- package/dist/core/macros.js.map +1 -1
- package/dist/core/paths.d.ts +1 -0
- package/dist/core/paths.d.ts.map +1 -1
- package/dist/core/paths.js +1 -0
- package/dist/core/paths.js.map +1 -1
- package/dist/core/record-archive.d.ts +187 -0
- package/dist/core/record-archive.d.ts.map +1 -0
- package/dist/core/record-archive.js +465 -0
- package/dist/core/record-archive.js.map +1 -0
- package/dist/core/rehydration.d.ts +72 -0
- package/dist/core/rehydration.d.ts.map +1 -0
- package/dist/core/rehydration.js +72 -0
- package/dist/core/rehydration.js.map +1 -0
- package/dist/core/resume-brief.d.ts +130 -0
- package/dist/core/resume-brief.d.ts.map +1 -0
- package/dist/core/resume-brief.js +404 -0
- package/dist/core/resume-brief.js.map +1 -0
- package/dist/core/rolling-summary.d.ts +130 -0
- package/dist/core/rolling-summary.d.ts.map +1 -0
- package/dist/core/rolling-summary.js +227 -0
- package/dist/core/rolling-summary.js.map +1 -0
- package/dist/core/session-manager.d.ts +11 -3
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +56 -7
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/session-record.d.ts +165 -0
- package/dist/core/session-record.d.ts.map +1 -0
- package/dist/core/session-record.js +470 -0
- package/dist/core/session-record.js.map +1 -0
- package/dist/core/spawn-target.d.ts +12 -0
- package/dist/core/spawn-target.d.ts.map +1 -0
- package/dist/core/spawn-target.js +187 -0
- package/dist/core/spawn-target.js.map +1 -0
- package/dist/core/task-manager.d.ts.map +1 -1
- package/dist/core/task-manager.js +9 -1
- package/dist/core/task-manager.js.map +1 -1
- package/dist/core/template-catalog.d.ts +48 -0
- package/dist/core/template-catalog.d.ts.map +1 -0
- package/dist/core/template-catalog.js +338 -0
- package/dist/core/template-catalog.js.map +1 -0
- package/dist/core/workflow-registry.d.ts.map +1 -1
- package/dist/core/workflow-registry.js +2 -2
- package/dist/core/workflow-registry.js.map +1 -1
- package/dist/core/workspace-watcher.d.ts.map +1 -1
- package/dist/core/workspace-watcher.js +4 -3
- package/dist/core/workspace-watcher.js.map +1 -1
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -1
- package/dist/profiles/default.d.ts.map +1 -1
- package/dist/profiles/default.js +9 -14
- package/dist/profiles/default.js.map +1 -1
- package/dist/server/canvas.d.ts.map +1 -1
- package/dist/server/canvas.js +7 -7
- package/dist/server/canvas.js.map +1 -1
- package/dist/server/fs.d.ts.map +1 -1
- package/dist/server/fs.js +17 -1
- package/dist/server/fs.js.map +1 -1
- package/dist/server/index.d.ts +12 -3
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +321 -50
- package/dist/server/index.js.map +1 -1
- package/dist/server/ingest.d.ts +11 -0
- package/dist/server/ingest.d.ts.map +1 -1
- package/dist/server/ingest.js +1 -0
- package/dist/server/ingest.js.map +1 -1
- package/dist/server/macros.d.ts +4 -6
- package/dist/server/macros.d.ts.map +1 -1
- package/dist/server/macros.js.map +1 -1
- package/dist/server/rest.d.ts +18 -6
- package/dist/server/rest.d.ts.map +1 -1
- package/dist/server/rest.js +185 -48
- package/dist/server/rest.js.map +1 -1
- package/dist/server/templates.d.ts +24 -0
- package/dist/server/templates.d.ts.map +1 -0
- package/dist/server/templates.js +54 -0
- package/dist/server/templates.js.map +1 -0
- package/dist/shared/types.d.ts +477 -29
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/types.js +19 -11
- package/dist/shared/types.js.map +1 -1
- package/dist/web/assets/index-DC65cskq.js +446 -0
- package/dist/web/assets/index-xtD-3FFa.css +32 -0
- package/dist/web/index.html +2 -2
- package/package.json +5 -4
- package/dist/core/canvas-enrich.d.ts +0 -115
- package/dist/core/canvas-enrich.d.ts.map +0 -1
- package/dist/core/canvas-enrich.js +0 -186
- package/dist/core/canvas-enrich.js.map +0 -1
- package/dist/profiles/canvas-guidelines.d.ts +0 -20
- package/dist/profiles/canvas-guidelines.d.ts.map +0 -1
- package/dist/profiles/canvas-guidelines.js +0 -48
- package/dist/profiles/canvas-guidelines.js.map +0 -1
- package/dist/web/assets/index-DCtyI4VW.css +0 -32
- package/dist/web/assets/index-WSw5RvYR.js +0 -445
- package/dist/web/welcome-hero-dark.png +0 -0
- package/dist/web/welcome-hero-light.png +0 -0
package/dist/server/index.js
CHANGED
|
@@ -15,7 +15,6 @@ import { WebSocketServer } from "ws";
|
|
|
15
15
|
import open from "open";
|
|
16
16
|
import { JSON_BODY_LIMIT_BYTES } from "../shared/types.js";
|
|
17
17
|
import { resolveStatePaths } from "../core/paths.js";
|
|
18
|
-
import { seedExampleProject } from "../core/example-seed.js";
|
|
19
18
|
import { SessionManager, } from "../core/session-manager.js";
|
|
20
19
|
import { TaskManager } from "../core/task-manager.js";
|
|
21
20
|
import { createClaudeCodeAdapter } from "../core/adapters/claude-code.js";
|
|
@@ -23,6 +22,10 @@ import { createCodexAdapter } from "../core/adapters/codex.js";
|
|
|
23
22
|
import { WorkflowRegistry, createWorkflowsRouter, } from "../core/workflow-registry.js";
|
|
24
23
|
import { DEFAULT_MACROS } from "../core/macros.js";
|
|
25
24
|
import { createEventStore } from "../core/collector/store.js";
|
|
25
|
+
import { createClaudeTranscriptEnricher, createSessionRecordReader, } from "../core/session-record.js";
|
|
26
|
+
import { buildRehydrationBrief, systemPromptDeliveryFor, } from "../core/rehydration.js";
|
|
27
|
+
import { createRollingSummarizer, readRollingSummary, } from "../core/rolling-summary.js";
|
|
28
|
+
import { backfillSessionRecords, createRecordArchive, } from "../core/record-archive.js";
|
|
26
29
|
import { createHarnessEmitter } from "../core/collector/analytics-emitter.js";
|
|
27
30
|
import { migrateHarnessIdentity } from "../core/collector/identity-migration.js";
|
|
28
31
|
import { normalizeHookEvent } from "../core/collector/normalizer.js";
|
|
@@ -30,7 +33,7 @@ import { enrichTurnCompleted } from "../core/collector/transcript.js";
|
|
|
30
33
|
import { createSeqCounter } from "../core/collector/seq.js";
|
|
31
34
|
import { findRolloutFile, tailCodexRollout, } from "../core/collector/codex-tailer.js";
|
|
32
35
|
import { getOrCreateMachineId } from "../cli/machine-id.js";
|
|
33
|
-
import { pruneDeadRecentDirs } from "../cli/settings.js";
|
|
36
|
+
import { loadSettings, pruneDeadRecentDirs } from "../cli/settings.js";
|
|
34
37
|
import { generateClaudeSettings } from "../core/inject/claude-settings.js";
|
|
35
38
|
import { generateMcpConfig } from "../core/inject/mcp-config.js";
|
|
36
39
|
import { generateSystemPromptFile } from "../core/inject/system-prompt.js";
|
|
@@ -43,8 +46,8 @@ import { PortDetector, portFromUrl } from "../core/port-detector.js";
|
|
|
43
46
|
import { EventBus } from "../core/event-bus.js";
|
|
44
47
|
import { writeHarnessContext, harnessContextFileExists, } from "../core/workspace-context.js";
|
|
45
48
|
import { ensureCanvasTemplate } from "../core/canvas-template.js";
|
|
46
|
-
import { renderCanvasForSession
|
|
47
|
-
import {
|
|
49
|
+
import { renderCanvasForSession } from "../core/canvas-render.js";
|
|
50
|
+
import { invalidateExtractionCache } from "../core/canvas-cache.js";
|
|
48
51
|
import { sweepNdjson } from "../core/collector/store-retention.js";
|
|
49
52
|
import { createDefinitionSlugResolver, resolveAgentsBaseUrl, } from "../core/definition-slug-resolver.js";
|
|
50
53
|
import { createBootTokenMiddleware } from "./auth.js";
|
|
@@ -60,6 +63,7 @@ import { createCanvasRenderRouter } from "./canvas-render.js";
|
|
|
60
63
|
import { createMacrosRouter } from "./macros.js";
|
|
61
64
|
import { createFsRouter } from "./fs.js";
|
|
62
65
|
import { createRunsRouter } from "./runs.js";
|
|
66
|
+
import { createTemplatesRouter } from "./templates.js";
|
|
63
67
|
import { createActionsRouter } from "./actions.js";
|
|
64
68
|
import { createAuthRouter, createMutableAuthState, } from "./auth-routes.js";
|
|
65
69
|
// resolveAgentsBaseUrl is imported above from definition-slug-resolver.js
|
|
@@ -100,6 +104,9 @@ function packageRoot() {
|
|
|
100
104
|
function workflowListsEqual(a, b) {
|
|
101
105
|
if (a.length !== b.length)
|
|
102
106
|
return false;
|
|
107
|
+
// NUL separates the fields because it can't occur in any of them. Keep it
|
|
108
|
+
// written as the escape `\u0000` — a literal NUL byte in the source makes
|
|
109
|
+
// grep and ripgrep classify this whole file as binary and silently skip it.
|
|
103
110
|
const key = (w) => `${w.path}\u0000${w.name}\u0000${w.definitionId ?? ""}\u0000${w.source}`;
|
|
104
111
|
const setA = new Set(a.map(key));
|
|
105
112
|
return b.every((w) => setA.has(key(w)));
|
|
@@ -123,8 +130,29 @@ function readVersion() {
|
|
|
123
130
|
* so the remote `sapiom` MCP is actually authenticated — a factory rather
|
|
124
131
|
* than a plain function since it's per-server-instance state.
|
|
125
132
|
*/
|
|
126
|
-
function createDefaultBuildLaunchOpts(apiKey, generatedRoot) {
|
|
127
|
-
return async (harnessSessionId) => {
|
|
133
|
+
function createDefaultBuildLaunchOpts(apiKey, generatedRoot, rehydration) {
|
|
134
|
+
return async (harnessSessionId, req) => {
|
|
135
|
+
// Portable continue (SAP-2059). Resolved before the prompt file is
|
|
136
|
+
// written, because for a `launch-flag` harness the brief IS part of that
|
|
137
|
+
// file. Best-effort throughout: a brief that can't be assembled leaves
|
|
138
|
+
// `rehydratedFrom` unset and the session launches as an ordinary fresh
|
|
139
|
+
// one rather than failing — see CreateSessionRequest.rehydrateFrom.
|
|
140
|
+
//
|
|
141
|
+
// Only ever set on create(): resume() calls this builder with the persisted
|
|
142
|
+
// HarnessSession, which carries no `rehydrateFrom`, so a rehydrated session
|
|
143
|
+
// that is later resumed does not get the brief a second time (the agent's
|
|
144
|
+
// own conversation already holds it).
|
|
145
|
+
const rehydrateFrom = req.rehydrateFrom;
|
|
146
|
+
const brief = rehydrateFrom && rehydration
|
|
147
|
+
? await rehydration.buildBrief(rehydrateFrom).catch((err) => {
|
|
148
|
+
console.error("[harness] rehydration brief failed:", err);
|
|
149
|
+
return null;
|
|
150
|
+
})
|
|
151
|
+
: null;
|
|
152
|
+
// The other channel (post-ready injection, for a harness with no prompt
|
|
153
|
+
// flag) is driven from the session status handler in startServer — the
|
|
154
|
+
// brief must not go into a file that adapter never reads.
|
|
155
|
+
const viaSystemPrompt = brief !== null && rehydration?.deliveryFor(req.harness) === "launch-flag";
|
|
128
156
|
const [settings, mcpConfigFile, systemPromptFile, pluginDir] = await Promise.all([
|
|
129
157
|
generateClaudeSettings({ harnessSessionId, generatedRoot }),
|
|
130
158
|
generateMcpConfig(harnessSessionId, {
|
|
@@ -132,7 +160,10 @@ function createDefaultBuildLaunchOpts(apiKey, generatedRoot) {
|
|
|
132
160
|
apiKey,
|
|
133
161
|
generatedRoot,
|
|
134
162
|
}),
|
|
135
|
-
generateSystemPromptFile(harnessSessionId, {
|
|
163
|
+
generateSystemPromptFile(harnessSessionId, {
|
|
164
|
+
generatedRoot,
|
|
165
|
+
...(viaSystemPrompt ? { appendix: brief } : {}),
|
|
166
|
+
}),
|
|
136
167
|
generateSkillsPlugin(harnessSessionId, { generatedRoot }),
|
|
137
168
|
]);
|
|
138
169
|
return {
|
|
@@ -140,6 +171,9 @@ function createDefaultBuildLaunchOpts(apiKey, generatedRoot) {
|
|
|
140
171
|
mcpConfigFile,
|
|
141
172
|
systemPromptFile,
|
|
142
173
|
...(pluginDir ? { pluginDir } : {}),
|
|
174
|
+
// Set on BOTH channels: the post-ready path hasn't delivered yet, but a
|
|
175
|
+
// brief exists and will, and this is the flag that tells it to.
|
|
176
|
+
...(brief !== null && rehydrateFrom ? { rehydratedFrom: rehydrateFrom } : {}),
|
|
143
177
|
};
|
|
144
178
|
};
|
|
145
179
|
}
|
|
@@ -202,6 +236,19 @@ export const startServer = async (options) => {
|
|
|
202
236
|
const bus = new EventBus();
|
|
203
237
|
const canvasWatcher = new CanvasWatcherManager({
|
|
204
238
|
onChange: (harnessSessionId) => bus.publish({ type: "canvas.reload", harnessSessionId }),
|
|
239
|
+
// A workflow SOURCE edit auto re-renders the bound workflow — deterministic
|
|
240
|
+
// and free, so there's nothing to wait for and no button to press. Uses the
|
|
241
|
+
// preserve-on-failure path: while an agent is mid-edit the sources are
|
|
242
|
+
// transiently un-buildable, and flashing an extraction-error panel over a
|
|
243
|
+
// perfectly good diagram reads as broken. So keep the last good render until
|
|
244
|
+
// the edit builds cleanly, then swap it in (the write flows back through
|
|
245
|
+
// onChange above as the iframe reload, with its loading skeleton). Only a
|
|
246
|
+
// workflow that has never rendered shows the honest error.
|
|
247
|
+
onSourceChange: (harnessSessionId) => {
|
|
248
|
+
const session = sessionManager.get(harnessSessionId);
|
|
249
|
+
if (session)
|
|
250
|
+
void autoRenderCanvas(session).catch(() => { });
|
|
251
|
+
},
|
|
205
252
|
});
|
|
206
253
|
// The harness's own infrastructure shouldn't ever show up as a "discovered"
|
|
207
254
|
// dev server in the Preview pane — a mention of our own listening port (in
|
|
@@ -293,6 +340,54 @@ export const startServer = async (options) => {
|
|
|
293
340
|
: null;
|
|
294
341
|
await writeHarnessContext(session, boundWorkflow, workflowsCache);
|
|
295
342
|
};
|
|
343
|
+
// Declared before the launch-opts builder (rather than beside the ingest
|
|
344
|
+
// pipeline, where the rest of the event wiring lives) because portable
|
|
345
|
+
// continue resolves a rehydration brief from this reader at session-create
|
|
346
|
+
// time — see createDefaultBuildLaunchOpts below.
|
|
347
|
+
const eventStorePath = options.eventStorePath ?? statePaths.events;
|
|
348
|
+
const eventStore = createEventStore(eventStorePath);
|
|
349
|
+
// Archived session records — the copies that survive events.ndjson's 50 MB /
|
|
350
|
+
// 30-day retention (core/record-archive.ts). Declared here, above the session
|
|
351
|
+
// manager, because the "exited" handler archives through it.
|
|
352
|
+
const recordsRoot = options.recordsRoot ?? statePaths.records;
|
|
353
|
+
const recordArchive = createRecordArchive({ root: recordsRoot });
|
|
354
|
+
// Past-session transcripts, rebuilt from the events above rather than from
|
|
355
|
+
// any vendor's history file — the same code path for claude-code and codex.
|
|
356
|
+
// The claude enricher is a pure bonus on top: when that transcript happens
|
|
357
|
+
// to still exist, the final turn gains its model/usage; when it doesn't, the
|
|
358
|
+
// record renders unchanged.
|
|
359
|
+
const sessionRecordReader = createSessionRecordReader(eventStore, {
|
|
360
|
+
enrichFinalTurn: createClaudeTranscriptEnricher(),
|
|
361
|
+
archive: recordArchive,
|
|
362
|
+
});
|
|
363
|
+
/**
|
|
364
|
+
* Fold a session's record from the events and archive it — the write that
|
|
365
|
+
* makes its history outlive the log. Idempotent (it replaces any earlier
|
|
366
|
+
* archive of the same conversation), detached, and silent about nothing: a
|
|
367
|
+
* failure is logged and never propagated, because a session's exit must not
|
|
368
|
+
* fail over its bookkeeping.
|
|
369
|
+
*
|
|
370
|
+
* Folded via `readFromEvents`, deliberately not `read`: re-archiving whatever
|
|
371
|
+
* `read` returned would compact an already-compacted excerpt and re-stamp it
|
|
372
|
+
* with a fresh `archivedAt`, making a stale copy look current.
|
|
373
|
+
*
|
|
374
|
+
* The sweep runs right after a write because a write is the only thing that
|
|
375
|
+
* grows the store — enforcing the caps at the moment they can be exceeded
|
|
376
|
+
* beats waiting for the next boot.
|
|
377
|
+
*/
|
|
378
|
+
const archiveSessionRecord = async (harnessSessionId) => {
|
|
379
|
+
const record = await sessionRecordReader.readFromEvents(harnessSessionId);
|
|
380
|
+
if (!record)
|
|
381
|
+
return;
|
|
382
|
+
if (!(await recordArchive.write(record)))
|
|
383
|
+
return;
|
|
384
|
+
await recordArchive.sweep();
|
|
385
|
+
};
|
|
386
|
+
const archiveSessionRecordDetached = (harnessSessionId) => {
|
|
387
|
+
void archiveSessionRecord(harnessSessionId).catch((err) => {
|
|
388
|
+
console.error("[harness] session record archive failed:", err);
|
|
389
|
+
});
|
|
390
|
+
};
|
|
296
391
|
// Exit-time deletion of generated/<id> (see the onStatusChange handler
|
|
297
392
|
// below) can race a fast resume(): resume regenerates the dir via
|
|
298
393
|
// buildLaunchOpts, and the rm scheduled at the previous exit could still
|
|
@@ -300,8 +395,65 @@ export const startServer = async (options) => {
|
|
|
300
395
|
// before (re)generating its files.
|
|
301
396
|
const generatedRoot = options.generatedRoot ?? statePaths.generated;
|
|
302
397
|
const pendingGeneratedRemovals = new Map();
|
|
398
|
+
/**
|
|
399
|
+
* The git branch the PRIOR session was last on, from whichever adapter
|
|
400
|
+
* recorded it — our own events never carry one. One adapter history scan per
|
|
401
|
+
* rehydrate, which is a user-initiated "continue this session" click rather
|
|
402
|
+
* than a hot path (the reason `GET /sessions/history` avoids per-row probes
|
|
403
|
+
* doesn't apply to a single deliberate action). Null for a harness whose
|
|
404
|
+
* transcript doesn't record a branch, and never throws.
|
|
405
|
+
*/
|
|
406
|
+
const priorGitBranch = async (record) => {
|
|
407
|
+
if (!record.cwd || !record.agentSessionId)
|
|
408
|
+
return null;
|
|
409
|
+
const adapter = adapters[record.harness];
|
|
410
|
+
if (!adapter)
|
|
411
|
+
return null;
|
|
412
|
+
try {
|
|
413
|
+
const rows = await adapter.listPastSessions(record.cwd);
|
|
414
|
+
return rows.find((row) => row.agentSessionId === record.agentSessionId)?.gitBranch ?? null;
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
/**
|
|
421
|
+
* Portable continue: the brief text for a `rehydrateFrom` id, or null when
|
|
422
|
+
* our event log holds nothing for it.
|
|
423
|
+
*
|
|
424
|
+
* Closes over `sessionManager` (declared just below, since it consumes the
|
|
425
|
+
* builder this feeds) for the prior session's title and workflow binding —
|
|
426
|
+
* safe because nothing can create a session before the manager that creates
|
|
427
|
+
* them exists.
|
|
428
|
+
*/
|
|
429
|
+
const resolveRehydrationBrief = (rehydrateFrom) => buildRehydrationBrief(rehydrateFrom, {
|
|
430
|
+
readRecord: (id) => sessionRecordReader.read(id),
|
|
431
|
+
readSummary: (harnessSessionId) => readRollingSummary(generatedRoot, harnessSessionId),
|
|
432
|
+
resolveContext: async (record) => {
|
|
433
|
+
// The earliest merged session the registry still knows — the record's
|
|
434
|
+
// own primary id first, so a conversation that spans a resume reports
|
|
435
|
+
// where it began rather than whichever segment happens to be indexed.
|
|
436
|
+
const prior = record.mergedSessionIds
|
|
437
|
+
.map((id) => sessionManager.get(id))
|
|
438
|
+
.find((session) => session !== undefined);
|
|
439
|
+
const workflowPath = prior?.boundWorkflowPath ?? null;
|
|
440
|
+
const workflow = workflowPath
|
|
441
|
+
? (workflowsCache.find((w) => w.path === workflowPath) ?? null)
|
|
442
|
+
: null;
|
|
443
|
+
return {
|
|
444
|
+
title: prior?.title ?? null,
|
|
445
|
+
gitBranch: await priorGitBranch(record),
|
|
446
|
+
workflow: workflow
|
|
447
|
+
? { name: workflow.name, path: workflow.path, definitionId: workflow.definitionId }
|
|
448
|
+
: null,
|
|
449
|
+
};
|
|
450
|
+
},
|
|
451
|
+
});
|
|
303
452
|
const innerBuildLaunchOpts = options.buildLaunchOpts ??
|
|
304
|
-
createDefaultBuildLaunchOpts(identity?.apiKey ?? null, generatedRoot
|
|
453
|
+
createDefaultBuildLaunchOpts(identity?.apiKey ?? null, generatedRoot, {
|
|
454
|
+
buildBrief: resolveRehydrationBrief,
|
|
455
|
+
deliveryFor: (harness) => systemPromptDeliveryFor(adapters[harness]),
|
|
456
|
+
});
|
|
305
457
|
const buildLaunchOpts = async (harnessSessionId, req) => {
|
|
306
458
|
await pendingGeneratedRemovals.get(harnessSessionId);
|
|
307
459
|
return innerBuildLaunchOpts(harnessSessionId, req);
|
|
@@ -322,6 +474,41 @@ export const startServer = async (options) => {
|
|
|
322
474
|
await sessionManager.init();
|
|
323
475
|
const sessionSweepTimer = setInterval(() => sessionManager.sweepDeadSessions(), SESSION_LIVENESS_SWEEP_MS);
|
|
324
476
|
sessionSweepTimer.unref?.();
|
|
477
|
+
// Portable continue, second channel: for a harness whose adapter never puts
|
|
478
|
+
// `systemPromptFile` in front of the agent, the brief is injected once the
|
|
479
|
+
// session reports `ready` — not merely "running", which for a TUI sitting on
|
|
480
|
+
// a "trust this directory?" prompt would feed the brief to the prompt. No
|
|
481
|
+
// adapter shipped today needs this (both declare `launch-flag`), so it is
|
|
482
|
+
// dormant rather than dead: a new harness with no prompt flag gets working
|
|
483
|
+
// rehydration from one line in its adapter.
|
|
484
|
+
const briefsDelivered = new Set();
|
|
485
|
+
sessionManager.onStatusChange((session) => {
|
|
486
|
+
if (session.status === "exited") {
|
|
487
|
+
briefsDelivered.delete(session.id);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const rehydratedFrom = session.rehydratedFrom;
|
|
491
|
+
if (!session.ready || !rehydratedFrom)
|
|
492
|
+
return;
|
|
493
|
+
if (systemPromptDeliveryFor(adapters[session.harness]) !== "post-ready-injection")
|
|
494
|
+
return;
|
|
495
|
+
if (briefsDelivered.has(session.id))
|
|
496
|
+
return;
|
|
497
|
+
// Claimed before the await so a burst of status frames can't double-inject.
|
|
498
|
+
briefsDelivered.add(session.id);
|
|
499
|
+
void (async () => {
|
|
500
|
+
const brief = await resolveRehydrationBrief(rehydratedFrom);
|
|
501
|
+
if (!brief)
|
|
502
|
+
return;
|
|
503
|
+
// submit:true — the brief has to enter the conversation to be context at
|
|
504
|
+
// all; left unsubmitted it would just sit in the composer as a wall of
|
|
505
|
+
// text the user has to deal with before they can type.
|
|
506
|
+
await sessionManager.submitInput(session.id, brief, true);
|
|
507
|
+
})().catch((err) => {
|
|
508
|
+
briefsDelivered.delete(session.id);
|
|
509
|
+
console.error("[harness] post-ready rehydration injection failed:", err);
|
|
510
|
+
});
|
|
511
|
+
});
|
|
325
512
|
// Background tasks (canvas enrichment today): headless one-shot agent
|
|
326
513
|
// runs that never touch a user's interactive session. They
|
|
327
514
|
// reuse the exact same per-id config generation as sessions — a task's
|
|
@@ -343,6 +530,24 @@ export const startServer = async (options) => {
|
|
|
343
530
|
taskManager.onStatusChange((task) => {
|
|
344
531
|
bus.publish({ type: "task.status", task });
|
|
345
532
|
});
|
|
533
|
+
// Rolling summary (opt-in, `HarnessSettings.rollingSummary`): folds a live
|
|
534
|
+
// session's record into a ≤500-word summary.md that a later portable
|
|
535
|
+
// continue reads. Everything about it is detached from the session's own
|
|
536
|
+
// path — `noteEvent` is synchronous and the fold is a separate headless
|
|
537
|
+
// process — so a turn is never slower or riskier for it being on. A codex
|
|
538
|
+
// session produces none (no `launchTask`); its briefs degrade to
|
|
539
|
+
// last-N-turns, which is also the default for everyone with the setting off.
|
|
540
|
+
const rollingSummarizer = createRollingSummarizer({
|
|
541
|
+
generatedRoot,
|
|
542
|
+
enabled: async () => (await loadSettings(statePaths.settings)).rollingSummary === true,
|
|
543
|
+
readRecord: (harnessSessionId) => sessionRecordReader.read(harnessSessionId),
|
|
544
|
+
getSession: (harnessSessionId) => {
|
|
545
|
+
const session = sessionManager.get(harnessSessionId);
|
|
546
|
+
return session ? { harness: session.harness, cwd: session.cwd } : undefined;
|
|
547
|
+
},
|
|
548
|
+
runTask: (req) => taskManager.run(req),
|
|
549
|
+
});
|
|
550
|
+
taskManager.onStatusChange((task) => rollingSummarizer.noteTaskStatus(task));
|
|
346
551
|
// One boot-time sweep for generated dirs the exit-time cleanup below never
|
|
347
552
|
// reached (crashes, force-kills, accumulation from before retention
|
|
348
553
|
// existed). Age-gated (GENERATED_SWEEP_MAX_AGE_MS) and skips live
|
|
@@ -436,6 +641,15 @@ export const startServer = async (options) => {
|
|
|
436
641
|
void rescanWorkspaceForSession(session.id).catch((err) => {
|
|
437
642
|
console.error("[harness] initial workspace rescan failed:", err);
|
|
438
643
|
});
|
|
644
|
+
// A resumed/already-bound session skips the rescan's auto-bind render
|
|
645
|
+
// (guarded by !boundWorkflowPath), so render it here — a reopened
|
|
646
|
+
// workflow shows its diagram on start without any manual trigger, now
|
|
647
|
+
// that the empty-state render button is gone.
|
|
648
|
+
if (session.boundWorkflowPath) {
|
|
649
|
+
void autoRenderCanvas(session).catch((err) => {
|
|
650
|
+
console.error("[harness] on-start canvas render failed:", err);
|
|
651
|
+
});
|
|
652
|
+
}
|
|
439
653
|
}
|
|
440
654
|
}
|
|
441
655
|
else if (session.status === "exited") {
|
|
@@ -445,6 +659,13 @@ export const startServer = async (options) => {
|
|
|
445
659
|
executionDetector.reset(session.id);
|
|
446
660
|
// Let a resumed session get a fresh on-start rescan.
|
|
447
661
|
rescannedOnStart.delete(session.id);
|
|
662
|
+
// Archive the record now the session is over, so its history survives
|
|
663
|
+
// events.ndjson's retention. This path is what covers a session that
|
|
664
|
+
// exited WITHOUT a SessionEnd hook (killed pty, crashed agent) and so
|
|
665
|
+
// never produced the `session.end` event the ingest path archives on;
|
|
666
|
+
// when both fire, the second write simply replaces the first with the
|
|
667
|
+
// fuller record.
|
|
668
|
+
archiveSessionRecordDetached(session.id);
|
|
448
669
|
// The generated config dir is dead once the pty is: every file in it
|
|
449
670
|
// is regenerated by buildLaunchOpts on resume, and the agent's last
|
|
450
671
|
// emit.cjs execution (SessionEnd) happens before its process exits.
|
|
@@ -495,43 +716,26 @@ export const startServer = async (options) => {
|
|
|
495
716
|
bus.publish({ type: "workflows.changed" });
|
|
496
717
|
return found;
|
|
497
718
|
};
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
await renderWorkflowRenderFile(cwd, workflow);
|
|
507
|
-
},
|
|
508
|
-
});
|
|
509
|
-
// Renders a session's bound workflow via the deterministic pipeline —
|
|
510
|
-
// always against the live workflowsCache, never an LLM in the render
|
|
511
|
-
// itself; a cheap no-op for an unbound session (the canvas router serves
|
|
512
|
-
// the empty state on its own). Never throws (see core/canvas-render.ts);
|
|
513
|
-
// best-effort, like every other canvas write here. The bind path
|
|
514
|
-
// (PATCH /sessions/:id/workflow → rest.ts) and the UNPROMPTED call sites
|
|
515
|
-
// (session-create/boot auto-render, via autoRenderCanvas — which won't
|
|
516
|
-
// replace a workflow's existing render with an error panel when its
|
|
517
|
-
// extraction fails) also kick the enrichment freshness check afterwards:
|
|
518
|
-
// instant base diagram now, annotations merged in when the task lands.
|
|
719
|
+
// Renders a session's bound workflow via the fully deterministic pipeline —
|
|
720
|
+
// always against the live workflowsCache; structure + derived annotations,
|
|
721
|
+
// no LLM, no user token. A cheap no-op for an unbound session (the canvas
|
|
722
|
+
// router serves the empty state on its own). Never throws (see
|
|
723
|
+
// core/canvas-render.ts); best-effort, like every other canvas write here.
|
|
724
|
+
// autoRenderCanvas is the UNPROMPTED variant (session-create/boot) that
|
|
725
|
+
// won't replace a workflow's existing render with an error panel when its
|
|
726
|
+
// extraction fails.
|
|
519
727
|
const renderCanvas = async (session) => {
|
|
520
728
|
await renderCanvasForSession(session, workflowsCache);
|
|
521
|
-
await enrichment.ensureFresh(session, workflowsCache);
|
|
522
729
|
};
|
|
523
730
|
const autoRenderCanvas = async (session) => {
|
|
524
731
|
await renderCanvasForSession(session, workflowsCache, {
|
|
525
732
|
preserveExistingOnFailure: true,
|
|
526
733
|
});
|
|
527
|
-
await enrichment.ensureFresh(session, workflowsCache);
|
|
528
734
|
};
|
|
529
735
|
const initialWorkflowScan = scanWorkflowsAndBroadcast(launchDir).catch((err) => {
|
|
530
736
|
console.error("[harness] initial workflow scan failed:", err);
|
|
531
737
|
return [];
|
|
532
738
|
});
|
|
533
|
-
const eventStorePath = options.eventStorePath ?? statePaths.events;
|
|
534
|
-
const eventStore = createEventStore(eventStorePath);
|
|
535
739
|
// Boot-time retention sweep: keeps events.ndjson within the 50 MB / 30-day
|
|
536
740
|
// caps even on long-lived installs. Runs through the store's exclusive queue
|
|
537
741
|
// so the sweep's read→filter→rename window never races a concurrent append.
|
|
@@ -546,6 +750,37 @@ export const startServer = async (options) => {
|
|
|
546
750
|
runNdjsonSweep();
|
|
547
751
|
const ndjsonRetentionTimer = setInterval(runNdjsonSweep, NDJSON_RETENTION_SWEEP_MS);
|
|
548
752
|
ndjsonRetentionTimer.unref?.();
|
|
753
|
+
// One boot-time pass that archives conversations the log still holds but the
|
|
754
|
+
// archive doesn't, then sweeps the archive's own caps. This is what covers the
|
|
755
|
+
// two cases archiving-at-exit can't: a harness that was force-killed (no exit
|
|
756
|
+
// transition, no session.end), and every session that ended before this
|
|
757
|
+
// existed — whose history would otherwise vanish at its 30-day mark.
|
|
758
|
+
//
|
|
759
|
+
// It races the ndjson sweep queued above, and deliberately doesn't wait for
|
|
760
|
+
// it: reads run outside the store's exclusive queue by design (see store.ts),
|
|
761
|
+
// and either order is correct here — win the race and the record is archived
|
|
762
|
+
// from bytes retention was about to delete, lose it and the record is archived
|
|
763
|
+
// from what survived. Both beat not archiving it.
|
|
764
|
+
//
|
|
765
|
+
// Fire-and-forget: boot must not wait on it. The cost is one full index build
|
|
766
|
+
// (~130 ms against a 50 MB log), which the first history open would have paid
|
|
767
|
+
// anyway.
|
|
768
|
+
void backfillSessionRecords({
|
|
769
|
+
conversationIds: () => sessionRecordReader.conversationIds(),
|
|
770
|
+
readFromEvents: (id) => sessionRecordReader.readFromEvents(id),
|
|
771
|
+
archive: recordArchive,
|
|
772
|
+
isLiveSession: (harnessSessionId) => {
|
|
773
|
+
const session = sessionManager.get(harnessSessionId);
|
|
774
|
+
return session !== undefined && session.status !== "exited";
|
|
775
|
+
},
|
|
776
|
+
onCapped: (remaining) => {
|
|
777
|
+
console.error(`[harness] session record backfill hit its per-boot cap; ${remaining} conversation(s) left for the next boot`);
|
|
778
|
+
},
|
|
779
|
+
})
|
|
780
|
+
.then(() => recordArchive.sweep())
|
|
781
|
+
.catch((err) => {
|
|
782
|
+
console.error("[harness] session record backfill failed:", err);
|
|
783
|
+
});
|
|
549
784
|
const harnessVersion = readVersion();
|
|
550
785
|
const batcher = createHarnessEmitter({
|
|
551
786
|
telemetryOptIn: options.telemetryOptIn,
|
|
@@ -622,12 +857,14 @@ export const startServer = async (options) => {
|
|
|
622
857
|
});
|
|
623
858
|
},
|
|
624
859
|
launchDir,
|
|
860
|
+
defaultProjectRoot: options.projectRoot ?? launchDir,
|
|
625
861
|
agentsBaseUrl: resolveAgentsBaseUrl(),
|
|
626
862
|
availableHarnesses: options.availableHarnesses,
|
|
627
863
|
listTasks: () => taskManager.list(),
|
|
628
864
|
firstRun: options.firstRun,
|
|
629
865
|
consentSource: options.consentSource,
|
|
630
866
|
consentEnvReason: options.consentEnvReason,
|
|
867
|
+
sessionRecords: sessionRecordReader,
|
|
631
868
|
uiTrack: {
|
|
632
869
|
store: eventStore,
|
|
633
870
|
batcher,
|
|
@@ -636,12 +873,6 @@ export const startServer = async (options) => {
|
|
|
636
873
|
userId: identity?.userId ?? null,
|
|
637
874
|
tenantId: identity?.tenantId ?? null,
|
|
638
875
|
},
|
|
639
|
-
seedSampleProject: async () => {
|
|
640
|
-
const { root, projectDir, created } = await seedExampleProject({
|
|
641
|
-
targetRoot: options.sampleProjectRoot ?? statePaths.sampleProject,
|
|
642
|
-
});
|
|
643
|
-
return { root, projectDir, created };
|
|
644
|
-
},
|
|
645
876
|
settingsPath: statePaths.settings,
|
|
646
877
|
}));
|
|
647
878
|
app.use("/api", createCanvasRenderRouter({
|
|
@@ -663,6 +894,12 @@ export const startServer = async (options) => {
|
|
|
663
894
|
apiKey: apiKeyProvider,
|
|
664
895
|
baseUrl: resolveAgentsBaseUrl(),
|
|
665
896
|
}));
|
|
897
|
+
// The template gallery, relayed from CORE (not the agents surface) so the
|
|
898
|
+
// Studio's picker and the dashboard's Template library read one catalog.
|
|
899
|
+
// baseUrl omitted: the router self-defaults via resolveCoreBaseUrl().
|
|
900
|
+
app.use(createTemplatesRouter({
|
|
901
|
+
apiKey: apiKeyProvider,
|
|
902
|
+
}));
|
|
666
903
|
// Direct action macros (Deploy / Prod-run) — server-side, key never reaches
|
|
667
904
|
// the browser, no Claude Code. Resolves a workflow :id (its path) against the
|
|
668
905
|
// same live cache the rest router's findWorkflow uses.
|
|
@@ -680,15 +917,17 @@ export const startServer = async (options) => {
|
|
|
680
917
|
findWorkflow: (workflowPath) => workflowsCache.find((w) => w.path === workflowPath) ?? null,
|
|
681
918
|
getSessionCwd: (harnessSessionId) => sessionManager.get(harnessSessionId)?.cwd ?? null,
|
|
682
919
|
getBoundWorkflowPath: (harnessSessionId) => sessionManager.get(harnessSessionId)?.boundWorkflowPath ?? null,
|
|
683
|
-
// The visualize macro
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
// session → 400).
|
|
920
|
+
// The visualize/refresh macro forces a fresh deterministic re-render:
|
|
921
|
+
// drop the bound workflow's extraction cache so a source change (new or
|
|
922
|
+
// removed steps) is picked up, then re-render. Fully deterministic and
|
|
923
|
+
// instant — no task, no user token, nothing to already-be-running.
|
|
688
924
|
renderCanvas: async (harnessSessionId) => {
|
|
689
925
|
const session = sessionManager.get(harnessSessionId);
|
|
690
|
-
if (session)
|
|
691
|
-
|
|
926
|
+
if (!session)
|
|
927
|
+
return;
|
|
928
|
+
if (session.boundWorkflowPath)
|
|
929
|
+
invalidateExtractionCache(session.boundWorkflowPath);
|
|
930
|
+
await renderCanvas(session);
|
|
692
931
|
},
|
|
693
932
|
injectInput: async (harnessSessionId, text, submit) => {
|
|
694
933
|
// Two-phase write: a combined text+\r lands in Claude Code as a
|
|
@@ -745,6 +984,9 @@ export const startServer = async (options) => {
|
|
|
745
984
|
batcher,
|
|
746
985
|
enrichFromTranscript: enrichTurnCompleted,
|
|
747
986
|
onNormalizedEvent: (event) => {
|
|
987
|
+
// Synchronous and total — it counts turns and detaches any fold it
|
|
988
|
+
// decides to start, so the ingest path never waits on a summary.
|
|
989
|
+
rollingSummarizer.noteEvent(event);
|
|
748
990
|
if (event.type !== "tool.call")
|
|
749
991
|
return;
|
|
750
992
|
const { toolInput, toolResponseSummary } = event.payload;
|
|
@@ -765,6 +1007,14 @@ export const startServer = async (options) => {
|
|
|
765
1007
|
executionDetector.flush(event.harnessSessionId);
|
|
766
1008
|
}
|
|
767
1009
|
},
|
|
1010
|
+
onEventPersisted: (event) => {
|
|
1011
|
+
// The normal end of a session: the SessionEnd hook's event is in the
|
|
1012
|
+
// store, so the archived record carries the whole conversation including
|
|
1013
|
+
// its `endedAt`. (The "exited" status handler archives too, for sessions
|
|
1014
|
+
// that never get here.)
|
|
1015
|
+
if (event.type === "session.end")
|
|
1016
|
+
archiveSessionRecordDetached(event.harnessSessionId);
|
|
1017
|
+
},
|
|
768
1018
|
onError: (err) => console.error("[harness] ingest processing error:", err),
|
|
769
1019
|
seqCounter,
|
|
770
1020
|
};
|
|
@@ -969,14 +1219,35 @@ export const startServer = async (options) => {
|
|
|
969
1219
|
if (shutdownTimerHandle !== undefined)
|
|
970
1220
|
clearTimeout(shutdownTimerHandle);
|
|
971
1221
|
void batcher.close();
|
|
1222
|
+
// wss.close() stops NEW upgrades but never terminates the connections
|
|
1223
|
+
// already open — and httpServer.close() then waits indefinitely for those
|
|
1224
|
+
// sockets to drain. The main window's live /ws/events and /ws/terminal
|
|
1225
|
+
// connections (plus any keep-alive HTTP socket) would therefore hang
|
|
1226
|
+
// close() forever, so Electron's before-quit never reaches app.quit() and
|
|
1227
|
+
// the process lingers as a zombie still holding the single-instance lock —
|
|
1228
|
+
// which blocks the next launch (it hangs on "Starting Sapiom…"). Force
|
|
1229
|
+
// each client shut, drop keep-alive HTTP conns, and bound the final wait
|
|
1230
|
+
// so shutdown always completes.
|
|
1231
|
+
for (const client of terminalWss.clients)
|
|
1232
|
+
client.terminate();
|
|
1233
|
+
for (const client of eventsWss.clients)
|
|
1234
|
+
client.terminate();
|
|
972
1235
|
terminalWss.close();
|
|
973
1236
|
eventsWss.close();
|
|
974
|
-
|
|
1237
|
+
httpServer.closeAllConnections?.();
|
|
1238
|
+
const HTTP_CLOSE_TIMEOUT_MS = 3000;
|
|
1239
|
+
await new Promise((resolve) => {
|
|
1240
|
+
let httpCloseTimer = setTimeout(() => {
|
|
1241
|
+
httpCloseTimer = undefined;
|
|
1242
|
+
resolve();
|
|
1243
|
+
}, HTTP_CLOSE_TIMEOUT_MS);
|
|
1244
|
+
httpCloseTimer.unref?.();
|
|
975
1245
|
httpServer.close((err) => {
|
|
976
1246
|
if (err)
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1247
|
+
console.error("[harness] httpServer.close error:", err);
|
|
1248
|
+
if (httpCloseTimer !== undefined)
|
|
1249
|
+
clearTimeout(httpCloseTimer);
|
|
1250
|
+
resolve();
|
|
980
1251
|
});
|
|
981
1252
|
});
|
|
982
1253
|
},
|