@jini-ai/daemon 0.3.8 → 0.5.1
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 +124 -0
- package/README.md +50 -8
- package/dist/agent-executor/contracts.d.ts +736 -0
- package/dist/agent-executor/contracts.js +28 -0
- package/dist/agent-executor/event-translation.d.ts +87 -0
- package/dist/agent-executor/event-translation.js +230 -0
- package/dist/agent-executor/events.d.ts +190 -0
- package/dist/agent-executor/events.js +848 -0
- package/dist/agent-executor/handoff.d.ts +18 -0
- package/dist/agent-executor/handoff.js +16 -0
- package/dist/agent-executor/index.d.ts +98 -0
- package/dist/agent-executor/index.js +98 -0
- package/dist/agent-executor/launch.d.ts +375 -0
- package/dist/agent-executor/launch.js +1001 -0
- package/dist/agent-executor/mcp-bridge.d.ts +338 -0
- package/dist/agent-executor/mcp-bridge.js +835 -0
- package/dist/agent-executor/values.d.ts +5 -0
- package/dist/agent-executor/values.js +13 -0
- package/dist/agent-executor.d.ts +375 -176
- package/dist/agent-executor.js +388 -335
- package/dist/close-status.d.ts +11 -8
- package/dist/close-status.js +31 -19
- package/dist/continuation/continuation-transport.d.ts +3 -1
- package/dist/continuation/continuation-transport.js +1 -1
- package/dist/continuation/journal.d.ts +12 -5
- package/dist/continuation/journal.js +4 -4
- package/dist/continuation/run-scoped-context-store.d.ts +13 -4
- package/dist/continuation/run-scoped-context-store.js +6 -6
- package/dist/continuation/run-start-handler.d.ts +1 -1
- package/dist/continuation/run-start-handler.js +3 -16
- package/dist/delegated-tool-bridge.d.ts +7 -3
- package/dist/delegated-tool-bridge.js +32 -31
- package/dist/event-log.d.ts +7 -5
- package/dist/event-log.js +12 -11
- package/dist/frontend-capability-tools.d.ts +1 -1
- package/dist/frontend-capability-tools.js +3 -2
- package/dist/frontend-session-registry.d.ts +32 -9
- package/dist/frontend-session-registry.js +12 -11
- package/dist/http/active-context.d.ts +105 -0
- package/dist/http/active-context.js +67 -0
- package/dist/http/agents.d.ts +68 -0
- package/dist/http/agents.js +12 -0
- package/dist/http/attachments.d.ts +618 -0
- package/dist/http/attachments.js +1034 -0
- package/dist/http/cancel-owned-runs.d.ts +49 -0
- package/dist/http/cancel-owned-runs.js +35 -0
- package/dist/http/component-catalog.d.ts +84 -0
- package/dist/http/component-catalog.js +64 -0
- package/dist/http/connectors.d.ts +209 -0
- package/dist/http/connectors.js +409 -0
- package/dist/http/daemon-status.d.ts +76 -0
- package/dist/http/daemon-status.js +55 -0
- package/dist/http/db-ops.d.ts +123 -0
- package/dist/http/db-ops.js +189 -0
- package/dist/http/delegated-tools.d.ts +113 -0
- package/dist/http/delegated-tools.js +230 -0
- package/dist/http/frontend-control.d.ts +136 -0
- package/dist/http/frontend-control.js +79 -0
- package/dist/http/frontend-sessions.d.ts +110 -0
- package/dist/http/frontend-sessions.js +180 -0
- package/dist/http/health.d.ts +95 -0
- package/dist/http/health.js +40 -0
- package/dist/http/host-tools.d.ts +214 -0
- package/dist/http/host-tools.js +329 -0
- package/dist/http/index.d.ts +52 -0
- package/dist/http/index.js +27 -0
- package/dist/http/media.d.ts +146 -0
- package/dist/http/media.js +346 -0
- package/dist/http/memory.d.ts +265 -0
- package/dist/http/memory.js +328 -0
- package/dist/http/model-proxy.d.ts +49 -0
- package/dist/http/model-proxy.js +642 -0
- package/dist/http/remote-run-events.d.ts +70 -0
- package/dist/http/remote-run-events.js +224 -0
- package/dist/http/research.d.ts +48 -0
- package/dist/http/research.js +315 -0
- package/dist/http/route-manifest.d.ts +35 -0
- package/dist/http/route-manifest.js +103 -0
- package/dist/http/routines.d.ts +120 -0
- package/dist/http/routines.js +298 -0
- package/dist/http/run-credentials.d.ts +73 -0
- package/dist/http/run-credentials.js +71 -0
- package/dist/http/runs.d.ts +91 -0
- package/dist/http/runs.js +221 -0
- package/dist/http/terminals.d.ts +138 -0
- package/dist/http/terminals.js +325 -0
- package/dist/http/tool-catalog.d.ts +78 -0
- package/dist/http/tool-catalog.js +66 -0
- package/dist/http/workspace-root.d.ts +77 -0
- package/dist/http/workspace-root.js +63 -0
- package/dist/http/xai.d.ts +90 -0
- package/dist/http/xai.js +655 -0
- package/dist/image-prompt-delivery.d.ts +13 -3
- package/dist/image-prompt-delivery.js +5 -5
- package/dist/index.d.ts +556 -4
- package/dist/index.js +556 -4
- package/dist/legacy-data-migration.d.ts +59 -8
- package/dist/legacy-data-migration.js +38 -33
- package/dist/read-only-tools.d.ts +130 -0
- package/dist/read-only-tools.js +115 -0
- package/dist/remote-tool-bridge.d.ts +11 -5
- package/dist/remote-tool-bridge.js +15 -15
- package/dist/routines/index.d.ts +1 -1
- package/dist/routines/index.js +1 -1
- package/dist/routines/routine-store.d.ts +34 -12
- package/dist/routines/routine-store.js +21 -20
- package/dist/routines/schedule.d.ts +48 -10
- package/dist/routines/schedule.js +21 -21
- package/dist/routines/scheduler.d.ts +32 -11
- package/dist/routines/scheduler.js +61 -57
- package/dist/routines/types.d.ts +10 -3
- package/dist/run/core/result.d.ts +6 -2
- package/dist/run/core/result.js +3 -3
- package/dist/run/core/retry.d.ts +17 -4
- package/dist/run/core/retry.js +7 -11
- package/dist/run/diagnostics/diagnostics.d.ts +14 -4
- package/dist/run/diagnostics/diagnostics.js +7 -7
- package/dist/run/index.d.ts +1 -1
- package/dist/run/index.js +1 -1
- package/dist/run-credentials.d.ts +138 -0
- package/dist/run-credentials.js +152 -0
- package/dist/run-lifecycle.d.ts +92 -33
- package/dist/run-lifecycle.js +81 -74
- package/dist/scheduler.d.ts +8 -0
- package/dist/scheduler.js +2 -0
- package/dist/session-coordination.d.ts +115 -0
- package/dist/session-coordination.js +111 -0
- package/dist/store/agent-sessions/errors.d.ts +8 -0
- package/dist/store/agent-sessions/errors.js +10 -0
- package/dist/store/agent-sessions/index.d.ts +12 -0
- package/dist/store/agent-sessions/index.js +19 -0
- package/dist/store/agent-sessions/pglite.d.ts +12 -0
- package/dist/store/agent-sessions/pglite.js +7 -0
- package/dist/store/agent-sessions/ports.d.ts +24 -0
- package/dist/store/agent-sessions/ports.js +2 -0
- package/dist/store/agent-sessions/postgres.d.ts +12 -0
- package/dist/store/agent-sessions/postgres.js +7 -0
- package/dist/store/agent-sessions/schema.d.ts +3 -0
- package/dist/store/agent-sessions/schema.js +23 -0
- package/dist/store/agent-sessions/sql.d.ts +35 -0
- package/dist/store/agent-sessions/sql.js +59 -0
- package/dist/store/agent-sessions/sqlite.d.ts +117 -0
- package/dist/store/agent-sessions/sqlite.js +128 -0
- package/dist/store/event-log/sqlite.d.ts +75 -0
- package/dist/store/event-log/sqlite.js +230 -0
- package/dist/surface-exchanges.d.ts +97 -0
- package/dist/surface-exchanges.js +153 -0
- package/dist/terminal-session.d.ts +43 -11
- package/dist/terminal-session.js +22 -20
- package/dist/tokens.js +4 -4
- package/dist/tool-audit.d.ts +114 -0
- package/dist/tool-audit.js +95 -0
- package/dist/tool-executor.d.ts +22 -7
- package/dist/tool-executor.js +16 -16
- package/dist/tool-result-media.d.ts +3 -1
- package/dist/tool-result-media.js +1 -1
- package/dist/tool-result-surfaces.d.ts +3 -1
- package/dist/tool-result-surfaces.js +1 -1
- package/package.json +127 -9
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module terminals
|
|
3
|
+
*
|
|
4
|
+
* Interactive-terminal HTTP surface, mirroring OD's
|
|
5
|
+
* `apps/daemon/src/routes/terminal.ts` (`/api/projects/:id/terminals` +
|
|
6
|
+
* `.../:tid/{stream,stdin,resize,kill}`) generalized under `/api/terminals`.
|
|
7
|
+
* See `ADS-memory/reports/proposals/PROP-http-route-packs-terminal-pty-2026-07-21.md`
|
|
8
|
+
* for the design discussion and `@jini-ai/daemon`'s `terminal-session.ts` module
|
|
9
|
+
* doc for the session-ownership/gating decisions this route pack calls into.
|
|
10
|
+
*
|
|
11
|
+
* This file is deliberately thin — no PTY spawning, no session registry, no
|
|
12
|
+
* ownership logic lives here (that is `@jini-ai/daemon`'s `TerminalSessionManager`,
|
|
13
|
+
* injected as `deps.manager`). This package only:
|
|
14
|
+
*
|
|
15
|
+
* - Resolves a `POST /api/terminals` request's `resourceRef` to a spawn `cwd`
|
|
16
|
+
* via `workspace-root.ts` (the same port `host-tools.ts`'s open-in route
|
|
17
|
+
* uses), then routes creation through `deps.toolExecutor.execute(...,
|
|
18
|
+
* 'terminal.create', ...)` — the one call `@jini-ai/daemon`'s module gates by
|
|
19
|
+
* policy (matching `db-ops.ts`'s tool-execution-boundary precedent).
|
|
20
|
+
* - Routes `stdin`/`resize`/`kill`/`stream` directly to `deps.manager`'s
|
|
21
|
+
* lighter, session-ownership-checked methods — deliberately **not** through
|
|
22
|
+
* `ToolExecutor` again (see the daemon module's doc for why a full
|
|
23
|
+
* authorize/confirm round-trip per keystroke would make a terminal
|
|
24
|
+
* unusable).
|
|
25
|
+
* - Adapts `deps.manager.attach`'s push/end sink to `sse.ts`'s generic SSE
|
|
26
|
+
* channel, the same way `runs.ts` adapts `RunLifecycle.stream`.
|
|
27
|
+
*/
|
|
28
|
+
import { randomUUID } from 'node:crypto';
|
|
29
|
+
import { TERMINAL_CREATE_TOOL_ID } from '../index.js';
|
|
30
|
+
import { createApiError } from '@jini-ai/protocol';
|
|
31
|
+
import { defineJsonRoute, mountJsonRoute } from '@jini-ai/http-kit';
|
|
32
|
+
import { guardSameOrigin } from '@jini-ai/http-kit';
|
|
33
|
+
import { validationError } from '@jini-ai/http-kit';
|
|
34
|
+
import { sendApiError, statusForError } from '@jini-ai/http-kit';
|
|
35
|
+
import { createSseChannel, requestedAfterCursor } from '@jini-ai/http-kit';
|
|
36
|
+
import { denyAllWorkspaceRoots, resolveWorkspaceRoot, WorkspaceRootDeniedError } from './workspace-root.js';
|
|
37
|
+
import { err, ok } from '@jini-ai/http-kit';
|
|
38
|
+
function defaultTerminalsInternalErrorSink(context) {
|
|
39
|
+
// eslint-disable-next-line no-console
|
|
40
|
+
console.error(`[@jini-ai/http-kit] internal error (${context.source}, correlationId=${context.correlationId})`, context.error);
|
|
41
|
+
}
|
|
42
|
+
function isRecord(value) {
|
|
43
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
/** Every one-off `terminal.create` tool call gets its own opaque `RunRef` — matching `db-ops.ts`'s `freshRun()`: these are single requests, not steps of a longer-lived agent run. */
|
|
46
|
+
function freshRun() {
|
|
47
|
+
return { id: randomUUID() };
|
|
48
|
+
}
|
|
49
|
+
function toolResultToApiResult(deps, result) {
|
|
50
|
+
switch (result.status) {
|
|
51
|
+
case 'completed':
|
|
52
|
+
return ok({ value: result.output });
|
|
53
|
+
case 'denied':
|
|
54
|
+
return err({ error: createApiError({ code: 'TOOL_OPERATION_DENIED', message: 'this operation was denied by policy' }) });
|
|
55
|
+
case 'confirmation-denied':
|
|
56
|
+
return err({ error: createApiError({ code: 'TOOL_OPERATION_DENIED', message: 'this operation was denied during confirmation' }) });
|
|
57
|
+
case 'timed-out':
|
|
58
|
+
case 'cancelled':
|
|
59
|
+
case 'failed': {
|
|
60
|
+
const correlationId = randomUUID();
|
|
61
|
+
const sink = deps.onInternalError ?? defaultTerminalsInternalErrorSink;
|
|
62
|
+
sink({ source: 'terminal-create', correlationId, error: result.status === 'failed' ? result.error : result.status });
|
|
63
|
+
return err({ error: createApiError({ code: 'INTERNAL_ERROR', message: 'an internal error occurred' }, { requestId: correlationId }) });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function parseTerminalCreate(input) {
|
|
68
|
+
if (!isRecord(input.body))
|
|
69
|
+
return err({ error: validationError({ message: 'body must be a JSON object' }) });
|
|
70
|
+
const resourceRef = input.body.resourceRef;
|
|
71
|
+
if (typeof resourceRef !== 'string' || resourceRef.length === 0) {
|
|
72
|
+
return err({ error: validationError({ message: 'resourceRef is required' }, { issues: [{ path: 'resourceRef', message: 'required non-empty string' }] }) });
|
|
73
|
+
}
|
|
74
|
+
const detail = input.body.detail;
|
|
75
|
+
if (detail !== undefined && (typeof detail !== 'string' || detail.length === 0)) {
|
|
76
|
+
return err({ error: validationError({ message: 'detail must be a non-empty string when provided' }) });
|
|
77
|
+
}
|
|
78
|
+
const cols = input.body.cols;
|
|
79
|
+
if (cols !== undefined && typeof cols !== 'number') {
|
|
80
|
+
return err({ error: validationError({ message: 'cols must be a number when provided' }) });
|
|
81
|
+
}
|
|
82
|
+
const rows = input.body.rows;
|
|
83
|
+
if (rows !== undefined && typeof rows !== 'number') {
|
|
84
|
+
return err({ error: validationError({ message: 'rows must be a number when provided' }) });
|
|
85
|
+
}
|
|
86
|
+
const shell = input.body.shell;
|
|
87
|
+
if (shell !== undefined && (typeof shell !== 'string' || shell.length === 0)) {
|
|
88
|
+
return err({ error: validationError({ message: 'shell must be a non-empty string when provided' }) });
|
|
89
|
+
}
|
|
90
|
+
return ok({
|
|
91
|
+
value: {
|
|
92
|
+
resourceRef,
|
|
93
|
+
...(detail === undefined ? {} : { detail }),
|
|
94
|
+
...(cols === undefined ? {} : { cols }),
|
|
95
|
+
...(rows === undefined ? {} : { rows }),
|
|
96
|
+
...(shell === undefined ? {} : { shell }),
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* `POST /api/terminals` — the one gated call (see module doc). Resolves
|
|
102
|
+
* `resourceRef` to a working directory the same way `host-tools.ts`'s
|
|
103
|
+
* open-in route does, then authorizes+spawns the session through
|
|
104
|
+
* `ToolExecutor.execute(..., 'terminal.create', ...)`.
|
|
105
|
+
*/
|
|
106
|
+
export const terminalCreateRoute = defineJsonRoute({
|
|
107
|
+
method: 'post', path: '/api/terminals', parse: parseTerminalCreate, handle: async ({ input, deps }) => {
|
|
108
|
+
let cwd;
|
|
109
|
+
try {
|
|
110
|
+
cwd = await resolveWorkspaceRoot({ request: { resourceRef: input.resourceRef, ...(input.detail === undefined ? {} : { detail: input.detail }) } }, { resolver: deps.resolveRoot ?? denyAllWorkspaceRoots });
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error instanceof WorkspaceRootDeniedError) {
|
|
114
|
+
return err({ error: createApiError({ code: 'NOT_FOUND', message: `resource "${input.resourceRef}" was not found` }) });
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
const toolInput = {
|
|
119
|
+
resourceRef: input.resourceRef,
|
|
120
|
+
cwd,
|
|
121
|
+
...(input.cols === undefined ? {} : { cols: input.cols }),
|
|
122
|
+
...(input.rows === undefined ? {} : { rows: input.rows }),
|
|
123
|
+
...(input.shell === undefined ? {} : { shell: input.shell }),
|
|
124
|
+
};
|
|
125
|
+
const result = await deps.toolExecutor.execute({ principal: deps.principal, run: freshRun(), toolId: TERMINAL_CREATE_TOOL_ID, input: toolInput });
|
|
126
|
+
return toolResultToApiResult(deps, result);
|
|
127
|
+
}
|
|
128
|
+
}, { requireSameOrigin: true, successStatus: 201 });
|
|
129
|
+
function parseTerminalList(input) {
|
|
130
|
+
const value = input.query.resourceRef;
|
|
131
|
+
if (value === undefined)
|
|
132
|
+
return ok({ value: {} });
|
|
133
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
134
|
+
return err({ error: validationError({ message: 'resourceRef must be a non-empty string when provided' }) });
|
|
135
|
+
}
|
|
136
|
+
return ok({ value: { resourceRef: value } });
|
|
137
|
+
}
|
|
138
|
+
/** `GET /api/terminals` — sessions the calling principal owns, optionally narrowed by `resourceRef`. Never gated through `ToolExecutor` — a read scoped to the caller's own sessions, matching `runs.ts`'s `runListRoute` precedent. */
|
|
139
|
+
export const terminalListRoute = defineJsonRoute({
|
|
140
|
+
method: 'get', path: '/api/terminals', parse: parseTerminalList, handle: ({ input, deps }) => ok({ value: { terminals: deps.manager.list({ principal: deps.principal }, { filter: input.resourceRef === undefined ? {} : { resourceRef: input.resourceRef } }) } })
|
|
141
|
+
});
|
|
142
|
+
function parseTerminalId(input) {
|
|
143
|
+
const id = input.params.id;
|
|
144
|
+
return typeof id === 'string' && id.length > 0 ? ok({ value: id }) : err({ error: validationError({ message: 'id must be a non-empty path parameter' }) });
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Exported (not just internal) so the `result.session === null` branch — a real race
|
|
148
|
+
* (`write`/`resize`/`kill` finding metadata for an id whose underlying session was concurrently
|
|
149
|
+
* killed between the ownership check and the critical section, per `@jini-ai/daemon`'s
|
|
150
|
+
* `terminal-session.ts` `currentSnapshot`) — is directly unit-testable against a synthetic
|
|
151
|
+
* `TerminalSessionActionResult` rather than requiring a real, hard-to-deterministically-force race
|
|
152
|
+
* through the full `TerminalSessionManager`. Matches this file's own `createDeferredEndGate`
|
|
153
|
+
* precedent for the same "extract into a directly-testable pure function" shape.
|
|
154
|
+
*/
|
|
155
|
+
export function actionResultToApiResult({ result, notFoundMessage }, _optional = {}) {
|
|
156
|
+
if (result.status === 'not-found')
|
|
157
|
+
return err({ error: createApiError({ code: 'NOT_FOUND', message: notFoundMessage }) });
|
|
158
|
+
return ok({ value: { ok: result.ok, ...(result.session ? { terminal: result.session } : {}) } });
|
|
159
|
+
}
|
|
160
|
+
function parseStdinInput(input) {
|
|
161
|
+
const parsedId = parseTerminalId(input);
|
|
162
|
+
if (!parsedId.ok)
|
|
163
|
+
return parsedId;
|
|
164
|
+
if (!isRecord(input.body))
|
|
165
|
+
return err({ error: validationError({ message: 'body must be a JSON object' }) });
|
|
166
|
+
const data = input.body.data;
|
|
167
|
+
if (typeof data !== 'string') {
|
|
168
|
+
return err({ error: validationError({ message: 'data (string) is required' }, { issues: [{ path: 'data', message: 'required string' }] }) });
|
|
169
|
+
}
|
|
170
|
+
return ok({ value: { id: parsedId.value, data } });
|
|
171
|
+
}
|
|
172
|
+
/** `POST /api/terminals/:id/stdin` — writes `data` to the session's pty. Ownership-checked by `deps.manager.write` directly, not re-gated through `ToolExecutor` (see module doc). */
|
|
173
|
+
export const terminalStdinRoute = defineJsonRoute({
|
|
174
|
+
method: 'post', path: '/api/terminals/:id/stdin', parse: parseStdinInput, handle: async ({ input, deps }) => {
|
|
175
|
+
const result = await deps.manager.write({ principal: deps.principal, id: input.id, input: input.data });
|
|
176
|
+
return actionResultToApiResult({ result, notFoundMessage: `terminal "${input.id}" was not found` });
|
|
177
|
+
}
|
|
178
|
+
}, { requireSameOrigin: true });
|
|
179
|
+
function parseResizeInput(input) {
|
|
180
|
+
const parsedId = parseTerminalId(input);
|
|
181
|
+
if (!parsedId.ok)
|
|
182
|
+
return parsedId;
|
|
183
|
+
if (!isRecord(input.body))
|
|
184
|
+
return err({ error: validationError({ message: 'body must be a JSON object' }) });
|
|
185
|
+
const cols = Number(input.body.cols);
|
|
186
|
+
const rows = Number(input.body.rows);
|
|
187
|
+
if (!Number.isFinite(cols) || !Number.isFinite(rows)) {
|
|
188
|
+
return err({ error: validationError({ message: 'cols and rows (numbers) are required' }) });
|
|
189
|
+
}
|
|
190
|
+
return ok({ value: { id: parsedId.value, cols, rows } });
|
|
191
|
+
}
|
|
192
|
+
/** `POST /api/terminals/:id/resize` — resizes the session's pty. */
|
|
193
|
+
export const terminalResizeRoute = defineJsonRoute({
|
|
194
|
+
method: 'post', path: '/api/terminals/:id/resize', parse: parseResizeInput, handle: async ({ input, deps }) => {
|
|
195
|
+
const result = await deps.manager.resize({ principal: deps.principal, id: input.id, cols: input.cols, rows: input.rows });
|
|
196
|
+
return actionResultToApiResult({ result, notFoundMessage: `terminal "${input.id}" was not found` });
|
|
197
|
+
}
|
|
198
|
+
}, { requireSameOrigin: true });
|
|
199
|
+
function handleKill(id, deps) {
|
|
200
|
+
return deps.manager.kill({ principal: deps.principal, id }, { signal: 'SIGTERM' }).then((result) => actionResultToApiResult({ result, notFoundMessage: `terminal "${id}" was not found` }));
|
|
201
|
+
}
|
|
202
|
+
/** `POST /api/terminals/:id/kill` — sends `SIGTERM` to the session's pty. */
|
|
203
|
+
export const terminalKillRoute = defineJsonRoute({ method: 'post', path: '/api/terminals/:id/kill', parse: parseTerminalId, handle: ({ input: id, deps }) => handleKill(id, deps) }, { requireSameOrigin: true });
|
|
204
|
+
/** `DELETE /api/terminals/:id` — alias for `kill`, matching OD's dual `POST .../kill` / `DELETE` routes. */
|
|
205
|
+
export const terminalDeleteRoute = defineJsonRoute({ method: 'delete', path: '/api/terminals/:id', parse: parseTerminalId, handle: ({ input: id, deps }) => handleKill(id, deps) }, { requireSameOrigin: true });
|
|
206
|
+
/**
|
|
207
|
+
* The `channelOpened`/`endRequestedBeforeOpen` state machine `registerTerminalEventStream`'s
|
|
208
|
+
* `TerminalSseSink.end()` needs, extracted into its own directly-testable unit — see that
|
|
209
|
+
* function's inline comment for *why* the deferral exists (an already-exited session's replay
|
|
210
|
+
* path can call `end()` before `open()` has ever run).
|
|
211
|
+
*
|
|
212
|
+
* **On the `markOpened()`-then-`end()` ordering (opened=true when `end()` runs):** this is the
|
|
213
|
+
* ordering every *currently reachable* production call graph in this repo takes for a live
|
|
214
|
+
* session that exits while already streaming — but it is unreachable through that graph, not
|
|
215
|
+
* unreachable in principle. `@jini-ai/platform`'s `TerminalService.finish()` (`packages/platform/src/
|
|
216
|
+
* terminal.ts`) calls `sink.send('exit', ...)` immediately before `sink.end()` for every live
|
|
217
|
+
* client; `send()` here forwards straight to `channel.enqueue()`, whose `isEndEvent: (e) => e.kind
|
|
218
|
+
* === 'exit'` match auto-closes the channel *synchronously inside that same `enqueue()` call* —
|
|
219
|
+
* which synchronously runs `registerTerminalEventStream`'s own `channel.onClose` callback, which
|
|
220
|
+
* detaches this sink from `TerminalService`'s `session.clients` before `finish()`'s own, separate
|
|
221
|
+
* `sink.end()` loop ever runs. That loop iterates `session.clients` fresh at call time (not a
|
|
222
|
+
* snapshot taken before `emit()`), so by the time it runs, this sink has already removed itself —
|
|
223
|
+
* `sink.end()` is consequently never invoked while `channelOpened` is `true` via any call graph
|
|
224
|
+
* this repo's own `@jini-ai/platform` + `@jini-ai/daemon/http` composition can currently produce (confirmed by
|
|
225
|
+
* instrumenting the real call path directly, not inferred from reading the source — a synchronous
|
|
226
|
+
* console probe in `end()` never fired across this file's full "live-exit-while-streaming" test).
|
|
227
|
+
* That is a fact about today's specific `TerminalService`/`SseChannel` wiring, not a proof that no
|
|
228
|
+
* future caller of `TerminalSseSink` could ever call `end()` post-open (a hypothetical non-SSE
|
|
229
|
+
* sink, or a future `TerminalService` exit reason not preceded by a matching `send()`, would hit
|
|
230
|
+
* exactly this branch) — so the branch stays as real, intentional defensive code, tested directly
|
|
231
|
+
* here rather than deleted for being unreachable *today* or left silently uncovered.
|
|
232
|
+
*/
|
|
233
|
+
export function createDeferredEndGate({ channel }, _optional = {}) {
|
|
234
|
+
let opened = false;
|
|
235
|
+
let endRequestedBeforeOpen = false;
|
|
236
|
+
return {
|
|
237
|
+
markOpened() {
|
|
238
|
+
opened = true;
|
|
239
|
+
// Idempotent-safe even if the queued 'exit' event already auto-closed the channel via
|
|
240
|
+
// `isEndEvent` during `open()`'s own drain (`channel.end()` is documented safe to call twice).
|
|
241
|
+
if (endRequestedBeforeOpen)
|
|
242
|
+
channel.end();
|
|
243
|
+
},
|
|
244
|
+
end() {
|
|
245
|
+
if (opened) {
|
|
246
|
+
channel.end();
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
endRequestedBeforeOpen = true;
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* `GET /api/terminals/:id/stream` — SSE, with `Last-Event-ID`/`afterCursor`
|
|
256
|
+
* reconnect replay (via `sse.ts`'s `requestedAfterCursor`, the same helper
|
|
257
|
+
* `runs.ts` uses). Adapts `deps.manager.attach`'s push/end
|
|
258
|
+
* `TerminalSseSink` to `sse.ts`'s generic channel, mirroring
|
|
259
|
+
* `registerRunEventStream`'s adaptation of `RunLifecycle.stream`.
|
|
260
|
+
*
|
|
261
|
+
* Bypasses `mountJsonRoute` (raw `app.get`, for the SSE response shape), so it does not get
|
|
262
|
+
* `requireSameOrigin` for free the way the JSON routes above do — the guard is applied here
|
|
263
|
+
* directly, before the SSE channel is opened, so a cross-origin request never gets a stream.
|
|
264
|
+
*/
|
|
265
|
+
export function registerTerminalEventStream({ app, deps, adapter }, _optional = {}) {
|
|
266
|
+
app.get('/api/terminals/:id/stream', (req, res) => {
|
|
267
|
+
const origin = guardSameOrigin({ req, origin: adapter });
|
|
268
|
+
if (!origin.ok) {
|
|
269
|
+
sendApiError({ res, status: statusForError({ error: origin.error }), error: origin.error });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const id = req.params.id;
|
|
273
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
274
|
+
sendApiError({ res, status: 400, error: createApiError({ code: 'BAD_REQUEST', message: 'id must be a non-empty path parameter' }) });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const cursor = requestedAfterCursor(req);
|
|
278
|
+
const lastEventId = cursor === null ? 0 : Number(cursor);
|
|
279
|
+
const channel = createSseChannel({ res }, { isEndEvent: ({ event }) => event.kind === 'exit' });
|
|
280
|
+
// `deps.manager.attach` can call `sink.end()` synchronously, from inside the very call below
|
|
281
|
+
// (an already-exited session's replay path) — before the channel has ever been `open()`ed. If
|
|
282
|
+
// `end()` mapped straight to `channel.end()`, that would end the response with the queued
|
|
283
|
+
// backlog never flushed (headers never even sent). `createDeferredEndGate` defers the actual
|
|
284
|
+
// `channel.end()` call until after `open()` has drained whatever `send()` already queued — see
|
|
285
|
+
// that function's own doc for the full reachability analysis of its other branch.
|
|
286
|
+
const deferredEnd = createDeferredEndGate({ channel });
|
|
287
|
+
const sink = {
|
|
288
|
+
send(event, data, eventId) {
|
|
289
|
+
channel.enqueue({ event: { opaqueCursor: String(eventId), kind: event, data } });
|
|
290
|
+
},
|
|
291
|
+
end() {
|
|
292
|
+
deferredEnd.end();
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
let attachedSink = null;
|
|
296
|
+
channel.onClose({
|
|
297
|
+
callback: () => {
|
|
298
|
+
if (attachedSink)
|
|
299
|
+
deps.manager.detach({ id, sink: attachedSink });
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
const result = deps.manager.attach({ principal: deps.principal, id, lastEventId, sink });
|
|
303
|
+
if (result === 'not-found') {
|
|
304
|
+
channel.abandon();
|
|
305
|
+
sendApiError({ res, status: 404, error: createApiError({ code: 'NOT_FOUND', message: `terminal "${id}" was not found` }) });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (result === 'attached') {
|
|
309
|
+
attachedSink = sink;
|
|
310
|
+
}
|
|
311
|
+
channel.open();
|
|
312
|
+
deferredEnd.markOpened();
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
/** Mounts every terminal route (`list`/`create`/`stdin`/`resize`/`kill`/`delete`/`stream`) on `app`. A pack's `http(app, services)` calls this directly. */
|
|
316
|
+
export function registerTerminalRoutes({ app, deps, adapter }, _optional = {}) {
|
|
317
|
+
mountJsonRoute({ app, spec: terminalListRoute, deps, adapter });
|
|
318
|
+
mountJsonRoute({ app, spec: terminalCreateRoute, deps, adapter });
|
|
319
|
+
mountJsonRoute({ app, spec: terminalStdinRoute, deps, adapter });
|
|
320
|
+
mountJsonRoute({ app, spec: terminalResizeRoute, deps, adapter });
|
|
321
|
+
mountJsonRoute({ app, spec: terminalKillRoute, deps, adapter });
|
|
322
|
+
mountJsonRoute({ app, spec: terminalDeleteRoute, deps, adapter });
|
|
323
|
+
registerTerminalEventStream({ app, deps, adapter });
|
|
324
|
+
}
|
|
325
|
+
//# sourceMappingURL=terminals.js.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module tool-catalog
|
|
3
|
+
*
|
|
4
|
+
* `GET /api/tools/search` / `GET /api/tools/:id` — the discovery half of
|
|
5
|
+
* `ai-control-plane.md` §29 / `PROP-tool-catalog-discovery-2026-07-26.md` §6.1's staged verbs
|
|
6
|
+
* (`search_tools`/`describe_tool`), v0 scope: no principal scoping, no versioning, no schema
|
|
7
|
+
* narrowing. `@jini-ai/mcp`'s `search_tools`/`describe_tool` tool defs proxy these two routes,
|
|
8
|
+
* exactly mirroring how `run-tools.ts`'s tools proxy `runs.ts`.
|
|
9
|
+
*
|
|
10
|
+
* This module does not depend on `@jini-ai/sqlite-chat` or `better-sqlite3` — `ToolCatalogQuery` is a
|
|
11
|
+
* plain injected interface, structurally compatible with `@jini-ai/sqlite-chat`'s
|
|
12
|
+
* `searchToolCatalog`/`getToolCatalogEntry` so a caller wires those in with zero adapter code,
|
|
13
|
+
* matching `db-ops.ts`'s established convention.
|
|
14
|
+
*
|
|
15
|
+
* **Deliberately not routed through `ToolExecutor`.** Unlike `db-ops.ts`'s three tools, nothing
|
|
16
|
+
* here executes a handler or has a side effect — it is a read over a snapshot table. `PROP` §6.3:
|
|
17
|
+
* "discovery is not permission... the catalog is never consulted for authorization." The actual
|
|
18
|
+
* gate is still `ToolExecutor`, reached only via `execute_delegated_tool`
|
|
19
|
+
* (`packages/daemon/src/delegated-tool-bridge.ts`) — finding a tool id here grants nothing.
|
|
20
|
+
*/
|
|
21
|
+
import type { Express } from 'express';
|
|
22
|
+
import { type AdapterContext } from '@jini-ai/http-kit';
|
|
23
|
+
export interface ToolCatalogSearchHit {
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly description: string;
|
|
26
|
+
readonly source: string;
|
|
27
|
+
readonly score: number;
|
|
28
|
+
}
|
|
29
|
+
export interface ToolCatalogEntry {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly description: string;
|
|
32
|
+
readonly inputSchema?: unknown;
|
|
33
|
+
readonly source: string;
|
|
34
|
+
}
|
|
35
|
+
/** Structurally identical to `@jini-ai/sqlite-chat`'s `searchToolCatalog`/`getToolCatalogEntry` — defined locally so this package incurs no `better-sqlite3` dependency. */
|
|
36
|
+
export interface ToolCatalogQuery {
|
|
37
|
+
readonly search: ({ query }: {
|
|
38
|
+
readonly query: string;
|
|
39
|
+
}, optionalArgs?: {
|
|
40
|
+
readonly limit?: number | undefined;
|
|
41
|
+
}) => readonly ToolCatalogSearchHit[];
|
|
42
|
+
readonly describe: ({ id }: {
|
|
43
|
+
readonly id: string;
|
|
44
|
+
}, _optional?: Record<string, never>) => ToolCatalogEntry | null;
|
|
45
|
+
}
|
|
46
|
+
export interface ToolCatalogHttpDeps {
|
|
47
|
+
readonly catalog: ToolCatalogQuery;
|
|
48
|
+
}
|
|
49
|
+
/** `GET /api/tools/search?q=...&limit=...` — ranked candidates only, never schemas (`PROP` §6.1). */
|
|
50
|
+
export declare const toolCatalogSearchRoute: import("@jini-ai/http-kit").JsonRouteSpec<{
|
|
51
|
+
query: string;
|
|
52
|
+
limit: number;
|
|
53
|
+
}, {
|
|
54
|
+
hits: readonly ToolCatalogSearchHit[];
|
|
55
|
+
}, ToolCatalogHttpDeps>;
|
|
56
|
+
/**
|
|
57
|
+
* `GET /api/tools/:id` — full descriptor (schema included), paid for only once a candidate survives
|
|
58
|
+
* search.
|
|
59
|
+
*
|
|
60
|
+
* An unknown tool id is **404 `NOT_FOUND`**, not 400. A well-formed request naming a catalog entry
|
|
61
|
+
* that does not exist is a missing resource, not a malformed request — and the three sibling route
|
|
62
|
+
* families in this package that have the same "the referenced thing isn't there" case
|
|
63
|
+
* (`memory.ts`'s `memory not found`, `routines.ts`'s `routine not found`, `media.ts`'s
|
|
64
|
+
* `media task not found`) all already answer 404. This route answered 400 until 2026-07-29 purely
|
|
65
|
+
* because it reached for `validationError` — the same helper its `parse` step uses, where 400 *is*
|
|
66
|
+
* correct — so a caller could not distinguish "you sent a bad request" from "that tool is gone".
|
|
67
|
+
* `parse` failures here (a missing `:id` segment) are still 400.
|
|
68
|
+
*/
|
|
69
|
+
export declare const toolCatalogDescribeRoute: import("@jini-ai/http-kit").JsonRouteSpec<{
|
|
70
|
+
id: string;
|
|
71
|
+
}, ToolCatalogEntry, ToolCatalogHttpDeps>;
|
|
72
|
+
/** Mounts both tool-catalog routes on `app`. A pack's `http({ app, services })` calls this directly. */
|
|
73
|
+
export declare function registerToolCatalogRoutes({ app, deps, adapter }: {
|
|
74
|
+
readonly app: Express;
|
|
75
|
+
readonly deps: ToolCatalogHttpDeps;
|
|
76
|
+
readonly adapter: AdapterContext;
|
|
77
|
+
}, _optional?: Record<string, never>): void;
|
|
78
|
+
//# sourceMappingURL=tool-catalog.d.ts.map
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createApiError } from '@jini-ai/protocol';
|
|
2
|
+
import { defineJsonRoute, mountJsonRoute } from '@jini-ai/http-kit';
|
|
3
|
+
import { validationError } from '@jini-ai/http-kit';
|
|
4
|
+
import { err, ok } from '@jini-ai/http-kit';
|
|
5
|
+
const MAX_SEARCH_LIMIT = 25;
|
|
6
|
+
const DEFAULT_SEARCH_LIMIT = 10;
|
|
7
|
+
function parseSearchInput(input) {
|
|
8
|
+
const rawQuery = input.query.q;
|
|
9
|
+
if (typeof rawQuery !== 'string' || rawQuery.trim().length === 0) {
|
|
10
|
+
return err({ error: validationError({ message: 'q (search query) is required and must be a non-empty string' }) });
|
|
11
|
+
}
|
|
12
|
+
const rawLimit = input.query.limit;
|
|
13
|
+
if (rawLimit === undefined)
|
|
14
|
+
return ok({ value: { query: rawQuery, limit: DEFAULT_SEARCH_LIMIT } });
|
|
15
|
+
if (typeof rawLimit !== 'string' || !/^[0-9]+$/.test(rawLimit)) {
|
|
16
|
+
return err({ error: validationError({ message: 'limit must be a single positive-integer query-string value when provided' }) });
|
|
17
|
+
}
|
|
18
|
+
const parsed = Number(rawLimit);
|
|
19
|
+
if (parsed < 1)
|
|
20
|
+
return err({ error: validationError({ message: 'limit must be at least 1' }) });
|
|
21
|
+
return ok({ value: { query: rawQuery, limit: Math.min(parsed, MAX_SEARCH_LIMIT) } });
|
|
22
|
+
}
|
|
23
|
+
/** `GET /api/tools/search?q=...&limit=...` — ranked candidates only, never schemas (`PROP` §6.1). */
|
|
24
|
+
export const toolCatalogSearchRoute = defineJsonRoute({
|
|
25
|
+
method: 'get',
|
|
26
|
+
path: '/api/tools/search',
|
|
27
|
+
parse: parseSearchInput,
|
|
28
|
+
handle: ({ input, deps }) => ok({ value: { hits: deps.catalog.search({ query: input.query }, { limit: input.limit }) } }),
|
|
29
|
+
}, { requireSameOrigin: true });
|
|
30
|
+
function parseDescribeInput(input) {
|
|
31
|
+
const id = input.params.id;
|
|
32
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
33
|
+
return err({ error: validationError({ message: 'id path segment is required' }) });
|
|
34
|
+
}
|
|
35
|
+
return ok({ value: { id } });
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* `GET /api/tools/:id` — full descriptor (schema included), paid for only once a candidate survives
|
|
39
|
+
* search.
|
|
40
|
+
*
|
|
41
|
+
* An unknown tool id is **404 `NOT_FOUND`**, not 400. A well-formed request naming a catalog entry
|
|
42
|
+
* that does not exist is a missing resource, not a malformed request — and the three sibling route
|
|
43
|
+
* families in this package that have the same "the referenced thing isn't there" case
|
|
44
|
+
* (`memory.ts`'s `memory not found`, `routines.ts`'s `routine not found`, `media.ts`'s
|
|
45
|
+
* `media task not found`) all already answer 404. This route answered 400 until 2026-07-29 purely
|
|
46
|
+
* because it reached for `validationError` — the same helper its `parse` step uses, where 400 *is*
|
|
47
|
+
* correct — so a caller could not distinguish "you sent a bad request" from "that tool is gone".
|
|
48
|
+
* `parse` failures here (a missing `:id` segment) are still 400.
|
|
49
|
+
*/
|
|
50
|
+
export const toolCatalogDescribeRoute = defineJsonRoute({
|
|
51
|
+
method: 'get',
|
|
52
|
+
path: '/api/tools/:id',
|
|
53
|
+
parse: parseDescribeInput,
|
|
54
|
+
handle: ({ input, deps }) => {
|
|
55
|
+
const entry = deps.catalog.describe({ id: input.id });
|
|
56
|
+
if (!entry)
|
|
57
|
+
return err({ error: createApiError({ code: 'NOT_FOUND', message: `no catalog entry for tool id "${input.id}"` }) });
|
|
58
|
+
return ok({ value: entry });
|
|
59
|
+
},
|
|
60
|
+
}, { requireSameOrigin: true });
|
|
61
|
+
/** Mounts both tool-catalog routes on `app`. A pack's `http({ app, services })` calls this directly. */
|
|
62
|
+
export function registerToolCatalogRoutes({ app, deps, adapter }, _optional = {}) {
|
|
63
|
+
mountJsonRoute({ app, spec: toolCatalogSearchRoute, deps, adapter });
|
|
64
|
+
mountJsonRoute({ app, spec: toolCatalogDescribeRoute, deps, adapter });
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=tool-catalog.js.map
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic, host-injectable "workspace root" resolution port.
|
|
3
|
+
*
|
|
4
|
+
* Several OD route packs need the same thing at the boundary: "given some
|
|
5
|
+
* opaque resource reference, what filesystem directory does a spawned
|
|
6
|
+
* process or launched editor run in?" OD answers that with
|
|
7
|
+
* `ctx.projectStore.getProject` + `ctx.projectFiles.resolveProjectDir` — a
|
|
8
|
+
* project-store lookup plus a metadata-driven directory resolver, both
|
|
9
|
+
* product-specific (see `packages/http/archived provenance ledger`'s routes-classification
|
|
10
|
+
* table, rows `#11 terminal.ts` and `#23 host-tools.ts`, both of which name
|
|
11
|
+
* exactly this dependency as the reason `POST /api/projects/:id/open-in` and
|
|
12
|
+
* the interactive-terminal spawn route were not ported earlier). The kernel
|
|
13
|
+
* has no concept of "project," so it cannot hardcode that lookup — but it
|
|
14
|
+
* also must not silently invent a directory (guessing `process.cwd()` or an
|
|
15
|
+
* OS temp dir would let one request's resource reference leak into an
|
|
16
|
+
* unrelated working directory).
|
|
17
|
+
*
|
|
18
|
+
* Follows the same explicit-injection, conservative-default shape this repo
|
|
19
|
+
* already established for `@jini-ai/cli`'s `resolveDaemonUrl`
|
|
20
|
+
* (`packages/cli/src/daemon-url.ts`): a host supplies a resolver callback; the
|
|
21
|
+
* built-in default resolves nothing and the resolution function throws
|
|
22
|
+
* rather than guessing. It also mirrors `active-context.ts`'s
|
|
23
|
+
* `resolveResource` DI shape (a caller-supplied lookup replacing OD's
|
|
24
|
+
* `getProject`) and its `resourceRef`/`detail` field naming.
|
|
25
|
+
*/
|
|
26
|
+
/** What a route needs resolved: an opaque resource reference, plus an optional sub-locator within it (e.g. a specific file), matching `active-context.ts`'s `resourceRef`/`detail` naming. */
|
|
27
|
+
export interface WorkspaceRootRequest {
|
|
28
|
+
readonly resourceRef: string;
|
|
29
|
+
readonly detail?: string | null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Host-supplied lookup: given a {@link WorkspaceRootRequest}, return the
|
|
33
|
+
* absolute filesystem path a process/editor should be rooted at, or a
|
|
34
|
+
* nullish value when this host has no directory for that resource (an
|
|
35
|
+
* unknown id, a resource with no on-disk representation, or a caller not
|
|
36
|
+
* permitted to resolve it — the resolver decides, the kernel does not).
|
|
37
|
+
*/
|
|
38
|
+
export type WorkspaceRootResolver = (requiredArgs: {
|
|
39
|
+
readonly request: WorkspaceRootRequest;
|
|
40
|
+
}) => string | null | undefined | Promise<string | null | undefined>;
|
|
41
|
+
/** Thrown by {@link resolveWorkspaceRoot} when no root could be resolved — the caller has nothing safe to spawn a process or open an editor against. */
|
|
42
|
+
export declare class WorkspaceRootDeniedError extends Error {
|
|
43
|
+
readonly resourceRef: string;
|
|
44
|
+
constructor({ resourceRef }: {
|
|
45
|
+
readonly resourceRef: string;
|
|
46
|
+
}, { reason }?: {
|
|
47
|
+
readonly reason?: string | undefined;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The built-in, zero-config default resolver: resolves nothing, for every
|
|
52
|
+
* request. A host that never wires its own resolver gets a hard denial from
|
|
53
|
+
* every call site that needs a workspace root — never a guessed path. This
|
|
54
|
+
* is the "conservative default that denies rather than guessing" half of
|
|
55
|
+
* the port, matching the task's explicit precedent
|
|
56
|
+
* (`ToolPolicy`/`resolveDaemonUrl`).
|
|
57
|
+
*/
|
|
58
|
+
export declare const denyAllWorkspaceRoots: WorkspaceRootResolver;
|
|
59
|
+
export interface ResolveWorkspaceRootOptions {
|
|
60
|
+
/** Defaults to {@link denyAllWorkspaceRoots} — a host must explicitly opt in to resolving real paths. */
|
|
61
|
+
readonly resolver?: WorkspaceRootResolver;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Resolves a workspace root for `request` via `options.resolver`.
|
|
65
|
+
*
|
|
66
|
+
* @throws {WorkspaceRootDeniedError} If the resolver is omitted, or returns
|
|
67
|
+
* `null`/`undefined`/an empty string. This function never falls back to a
|
|
68
|
+
* guessed path (`process.cwd()`, an OS temp dir, `resourceRef` itself
|
|
69
|
+
* treated as a path) — a route that cannot resolve a real root has nothing
|
|
70
|
+
* safe to act on.
|
|
71
|
+
* @complexity O(1) plus the injected resolver's own cost.
|
|
72
|
+
* @overallScore 100/100
|
|
73
|
+
*/
|
|
74
|
+
export declare function resolveWorkspaceRoot({ request }: {
|
|
75
|
+
readonly request: WorkspaceRootRequest;
|
|
76
|
+
}, options?: ResolveWorkspaceRootOptions): Promise<string>;
|
|
77
|
+
//# sourceMappingURL=workspace-root.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic, host-injectable "workspace root" resolution port.
|
|
3
|
+
*
|
|
4
|
+
* Several OD route packs need the same thing at the boundary: "given some
|
|
5
|
+
* opaque resource reference, what filesystem directory does a spawned
|
|
6
|
+
* process or launched editor run in?" OD answers that with
|
|
7
|
+
* `ctx.projectStore.getProject` + `ctx.projectFiles.resolveProjectDir` — a
|
|
8
|
+
* project-store lookup plus a metadata-driven directory resolver, both
|
|
9
|
+
* product-specific (see `packages/http/archived provenance ledger`'s routes-classification
|
|
10
|
+
* table, rows `#11 terminal.ts` and `#23 host-tools.ts`, both of which name
|
|
11
|
+
* exactly this dependency as the reason `POST /api/projects/:id/open-in` and
|
|
12
|
+
* the interactive-terminal spawn route were not ported earlier). The kernel
|
|
13
|
+
* has no concept of "project," so it cannot hardcode that lookup — but it
|
|
14
|
+
* also must not silently invent a directory (guessing `process.cwd()` or an
|
|
15
|
+
* OS temp dir would let one request's resource reference leak into an
|
|
16
|
+
* unrelated working directory).
|
|
17
|
+
*
|
|
18
|
+
* Follows the same explicit-injection, conservative-default shape this repo
|
|
19
|
+
* already established for `@jini-ai/cli`'s `resolveDaemonUrl`
|
|
20
|
+
* (`packages/cli/src/daemon-url.ts`): a host supplies a resolver callback; the
|
|
21
|
+
* built-in default resolves nothing and the resolution function throws
|
|
22
|
+
* rather than guessing. It also mirrors `active-context.ts`'s
|
|
23
|
+
* `resolveResource` DI shape (a caller-supplied lookup replacing OD's
|
|
24
|
+
* `getProject`) and its `resourceRef`/`detail` field naming.
|
|
25
|
+
*/
|
|
26
|
+
/** Thrown by {@link resolveWorkspaceRoot} when no root could be resolved — the caller has nothing safe to spawn a process or open an editor against. */
|
|
27
|
+
export class WorkspaceRootDeniedError extends Error {
|
|
28
|
+
resourceRef;
|
|
29
|
+
constructor({ resourceRef }, { reason } = {}) {
|
|
30
|
+
super(reason ?? `no workspace root available for resource "${resourceRef}"`);
|
|
31
|
+
this.name = 'WorkspaceRootDeniedError';
|
|
32
|
+
this.resourceRef = resourceRef;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The built-in, zero-config default resolver: resolves nothing, for every
|
|
37
|
+
* request. A host that never wires its own resolver gets a hard denial from
|
|
38
|
+
* every call site that needs a workspace root — never a guessed path. This
|
|
39
|
+
* is the "conservative default that denies rather than guessing" half of
|
|
40
|
+
* the port, matching the task's explicit precedent
|
|
41
|
+
* (`ToolPolicy`/`resolveDaemonUrl`).
|
|
42
|
+
*/
|
|
43
|
+
export const denyAllWorkspaceRoots = () => null;
|
|
44
|
+
/**
|
|
45
|
+
* Resolves a workspace root for `request` via `options.resolver`.
|
|
46
|
+
*
|
|
47
|
+
* @throws {WorkspaceRootDeniedError} If the resolver is omitted, or returns
|
|
48
|
+
* `null`/`undefined`/an empty string. This function never falls back to a
|
|
49
|
+
* guessed path (`process.cwd()`, an OS temp dir, `resourceRef` itself
|
|
50
|
+
* treated as a path) — a route that cannot resolve a real root has nothing
|
|
51
|
+
* safe to act on.
|
|
52
|
+
* @complexity O(1) plus the injected resolver's own cost.
|
|
53
|
+
* @overallScore 100/100
|
|
54
|
+
*/
|
|
55
|
+
export async function resolveWorkspaceRoot({ request }, options = {}) {
|
|
56
|
+
const resolver = options.resolver ?? denyAllWorkspaceRoots;
|
|
57
|
+
const root = await resolver({ request });
|
|
58
|
+
if (typeof root !== 'string' || root.length === 0) {
|
|
59
|
+
throw new WorkspaceRootDeniedError({ resourceRef: request.resourceRef });
|
|
60
|
+
}
|
|
61
|
+
return root;
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=workspace-root.js.map
|