@aivi/tracker-linear 0.0.0-stage → 1.0.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/README.md +41 -2
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +34 -0
- package/dist/client.d.ts +277 -0
- package/dist/client.js +362 -0
- package/dist/config.d.ts +94 -0
- package/dist/config.js +171 -0
- package/dist/desk.d.ts +13 -0
- package/dist/desk.js +70 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +8 -0
- package/dist/links.d.ts +48 -0
- package/dist/links.js +83 -0
- package/dist/mcp.d.ts +32 -0
- package/dist/mcp.js +126 -0
- package/dist/module.d.ts +38 -0
- package/dist/module.js +767 -0
- package/dist/projects.d.ts +40 -0
- package/dist/projects.js +119 -0
- package/dist/routes.d.ts +16 -0
- package/dist/routes.js +40 -0
- package/dist/runprogress.d.ts +60 -0
- package/dist/runprogress.js +120 -0
- package/dist/setup-project.d.ts +63 -0
- package/dist/setup-project.js +254 -0
- package/dist/setup.d.ts +3 -0
- package/dist/setup.js +526 -0
- package/dist/tracker.d.ts +178 -0
- package/dist/tracker.js +435 -0
- package/dist/webhook.d.ts +97 -0
- package/dist/webhook.js +40 -0
- package/dist/work.d.ts +66 -0
- package/dist/work.js +98 -0
- package/docs/linear.md +90 -0
- package/package.json +56 -4
package/dist/desk.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { ToolError } from '@aivi/plugin';
|
|
2
|
+
import { primaryLinearApp } from './config.js';
|
|
3
|
+
import { projectLinear } from './projects.js';
|
|
4
|
+
export function createLinearDesk(loaded, config, platform) {
|
|
5
|
+
const app = primaryLinearApp(config);
|
|
6
|
+
if (!app)
|
|
7
|
+
throw new ToolError(409, 'the tracker has no primary Linear app; the ticket desk is not available.');
|
|
8
|
+
const ticket = (run) => platform.issue(app, run.ticketId);
|
|
9
|
+
/** Where a new ticket is born: the project's teams, the create lane as it
|
|
10
|
+
* takes effect (project's `createLane`, defaults', else the project's
|
|
11
|
+
* first configured lane). An explicit create lane must exist in a team —
|
|
12
|
+
* a wrong name is said, never guessed past; the fallback lane lands where
|
|
13
|
+
* Linear's own default sends new issues when no team carries its name. */
|
|
14
|
+
const createTarget = async (projectId) => {
|
|
15
|
+
const linear = projectLinear(loaded, projectId);
|
|
16
|
+
if (!linear?.teams.length)
|
|
17
|
+
throw new ToolError(409, 'this project has no Linear teams; aivi_ticket_create has nowhere to create.');
|
|
18
|
+
const explicit = linear.createLane;
|
|
19
|
+
const lane = explicit ?? loaded.projects.find(p => p.id === projectId)?.lanes?.[0]?.name;
|
|
20
|
+
if (lane)
|
|
21
|
+
for (const teamId of linear.teams) {
|
|
22
|
+
const states = await platform.laneStates(app, teamId);
|
|
23
|
+
if (states.some(s => s.name === lane))
|
|
24
|
+
return { teamId, lane };
|
|
25
|
+
}
|
|
26
|
+
if (explicit)
|
|
27
|
+
throw new ToolError(409, `createLane "${explicit}" names no state in this project's Linear teams — fix createLane in config.`);
|
|
28
|
+
return { teamId: linear.teams[0] };
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
async read(run) {
|
|
32
|
+
const issue = await ticket(run);
|
|
33
|
+
return {
|
|
34
|
+
identifier: issue.identifier,
|
|
35
|
+
title: issue.title,
|
|
36
|
+
description: issue.description,
|
|
37
|
+
lane: issue.state.name,
|
|
38
|
+
labels: issue.labels.map(l => l.name),
|
|
39
|
+
url: issue.url,
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
comments(run) {
|
|
43
|
+
return platform.ticketComments(app, run.ticketId);
|
|
44
|
+
},
|
|
45
|
+
async labels(run) {
|
|
46
|
+
const issue = await ticket(run);
|
|
47
|
+
return { team: await platform.teamLabels(app, issue.teamId), on: issue.labels.map(l => l.name) };
|
|
48
|
+
},
|
|
49
|
+
async edit(run, changes) {
|
|
50
|
+
const issue = await platform.editIssue(app, run.ticketId, changes);
|
|
51
|
+
return { identifier: issue.identifier, title: issue.title, url: issue.url };
|
|
52
|
+
},
|
|
53
|
+
comment(run, text) {
|
|
54
|
+
return platform.addComment(app, run.ticketId, text);
|
|
55
|
+
},
|
|
56
|
+
async create(run, input) {
|
|
57
|
+
const target = await createTarget(run.projectId);
|
|
58
|
+
const issue = await platform.createIssue(app, {
|
|
59
|
+
teamId: target.teamId,
|
|
60
|
+
title: input.title,
|
|
61
|
+
description: input.description,
|
|
62
|
+
...(target.lane ? { lane: target.lane } : {}),
|
|
63
|
+
});
|
|
64
|
+
return { identifier: issue.identifier, url: issue.url, lane: issue.state.name };
|
|
65
|
+
},
|
|
66
|
+
async label(run, label, on) {
|
|
67
|
+
await platform.apply(app, run.ticketId, { kind: 'label', label, on });
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type { AgentActivityContent, AgentActivityInput, LinearAgentSession, LinearClientOptions, LinearCredentials, LinearIssue, LinearTeam, } from './client.ts';
|
|
2
|
+
export { LinearApiError, LinearClient, resolveTeams } from './client.ts';
|
|
3
|
+
export type { LinearConfig, LinearProjectDefaults, LinearProjectEntry, ProjectLinear, } from './config.ts';
|
|
4
|
+
export { assistantAgent, linearPrimarySecretNames, linearProjectDefaultsSchema, linearProjectSchema, linearSchema, linearSecretNames, plugin, primaryLinearApp, } from './config.ts';
|
|
5
|
+
export type { LinearMcpOptions } from './mcp.ts';
|
|
6
|
+
export { LinearMcp } from './mcp.ts';
|
|
7
|
+
export { createLinearModule, describeWorkers, LINEAR, openLinearStore } from './module.ts';
|
|
8
|
+
export type { RoutedProject } from './projects.ts';
|
|
9
|
+
export { linearTeamCollisions, projectForIssue, projectLinear, writeProjectLinear, } from './projects.ts';
|
|
10
|
+
export type { WebhookApp } from './routes.ts';
|
|
11
|
+
export { appWebhookPath, registerWebhookRoutes } from './routes.ts';
|
|
12
|
+
export type { LinearAppRuntime } from './tracker.ts';
|
|
13
|
+
export { clientFor, createLinearPlatform, LinearPlatform, neutralIssue, requireLinearSecrets } from './tracker.ts';
|
|
14
|
+
export type { AgentSessionEventPayload, IssueEventPayload, LinearWebhook, OtherPayload, WebhookVerdict, } from './webhook.ts';
|
|
15
|
+
export { isAgentSessionEvent, isIssueEvent, signWebhook, verifyWebhook, WEBHOOK_MAX_SKEW_MS } from './webhook.ts';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { LinearApiError, LinearClient, resolveTeams } from './client.js';
|
|
2
|
+
export { assistantAgent, linearPrimarySecretNames, linearProjectDefaultsSchema, linearProjectSchema, linearSchema, linearSecretNames, plugin, primaryLinearApp, } from './config.js';
|
|
3
|
+
export { LinearMcp } from './mcp.js';
|
|
4
|
+
export { createLinearModule, describeWorkers, LINEAR, openLinearStore } from './module.js';
|
|
5
|
+
export { linearTeamCollisions, projectForIssue, projectLinear, writeProjectLinear, } from './projects.js';
|
|
6
|
+
export { appWebhookPath, registerWebhookRoutes } from './routes.js';
|
|
7
|
+
export { clientFor, createLinearPlatform, LinearPlatform, neutralIssue, requireLinearSecrets } from './tracker.js';
|
|
8
|
+
export { isAgentSessionEvent, isIssueEvent, signWebhook, verifyWebhook, WEBHOOK_MAX_SKEW_MS } from './webhook.js';
|
package/dist/links.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linear's own durable pair: the agent session ↔ the OpenCode session ↔ the
|
|
3
|
+
* ticket. The orchestrator does not know what an agent session is, so the
|
|
4
|
+
* follower keeps that join itself — it is the whole reason a webhook naming
|
|
5
|
+
* an agent session can find its worker's OpenCode session without asking
|
|
6
|
+
* anybody, and the list a boot pass reconciles against Linear.
|
|
7
|
+
*
|
|
8
|
+
* Its own namespace, its own table (the `Store.migrate` contract every module
|
|
9
|
+
* has: namespaced tables, never host tables).
|
|
10
|
+
*/
|
|
11
|
+
import type { Store } from '@aivi/plugin';
|
|
12
|
+
export interface RunLink {
|
|
13
|
+
agentSession: string;
|
|
14
|
+
ticketId: string;
|
|
15
|
+
opencodeSession?: string;
|
|
16
|
+
/** When the closing landed — the pair is settled and boot asks Linear
|
|
17
|
+
* about it no more. Absent means the closing is still owed. */
|
|
18
|
+
endedAt?: number;
|
|
19
|
+
}
|
|
20
|
+
export declare class RunLinks {
|
|
21
|
+
private readonly store;
|
|
22
|
+
constructor(store: Store);
|
|
23
|
+
/** The delegation happened: record the agent session and its ticket. The
|
|
24
|
+
* OpenCode session joins when the orchestrator's `started` event names
|
|
25
|
+
* this ticket's run. */
|
|
26
|
+
bind(agentSession: string, ticketId: string, now?: number): void;
|
|
27
|
+
/** The run's session exists: attach it to the newest unattached pair for
|
|
28
|
+
* the ticket. Absent means the pair was already recorded (a replay). */
|
|
29
|
+
attach(ticketId: string, opencodeSession: string): boolean;
|
|
30
|
+
byAgentSession(agentSession: string): RunLink | undefined;
|
|
31
|
+
byOpencodeSession(opencodeSession: string): RunLink | undefined;
|
|
32
|
+
/** Every pair whose closing has not landed: the boot pass reads the run
|
|
33
|
+
* state for each and catches Linear up wherever the run has ended. A
|
|
34
|
+
* settled pair is absent — its closing spoke Linear's own state once, and
|
|
35
|
+
* asking again forever was the bug (live, 2026-10-06). Follow-up routing
|
|
36
|
+
* never comes through here: it asks `byAgentSession` directly, settled or
|
|
37
|
+
* not. The lane move is not this list's business: it is the orchestrator's
|
|
38
|
+
* own debt in its own ledger. */
|
|
39
|
+
owed(): RunLink[];
|
|
40
|
+
/** The closing landed: settle the pair. The row stays — it is still the
|
|
41
|
+
* route a person's later message finds its run through. */
|
|
42
|
+
closingLanded(agentSession: string, now?: number): void;
|
|
43
|
+
/** Retire a pair for good. Its use today is the gone-closing: Linear
|
|
44
|
+
* answers the agent session with `Entity not found` — the ticket was
|
|
45
|
+
* deleted — so there is nothing left to pay and no reason for the next
|
|
46
|
+
* boot to ask again. */
|
|
47
|
+
drop(agentSession: string): void;
|
|
48
|
+
}
|
package/dist/links.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const map = (r) => ({
|
|
2
|
+
agentSession: String(r.agent_session),
|
|
3
|
+
ticketId: String(r.ticket_id),
|
|
4
|
+
...(r.opencode_session === null ? {} : { opencodeSession: String(r.opencode_session) }),
|
|
5
|
+
...(r.ended_at === null || r.ended_at === undefined ? {} : { endedAt: Number(r.ended_at) }),
|
|
6
|
+
});
|
|
7
|
+
const migrations = [
|
|
8
|
+
`CREATE TABLE tracker_linear_run_links(
|
|
9
|
+
agent_session TEXT PRIMARY KEY, ticket_id TEXT NOT NULL, opencode_session TEXT UNIQUE,
|
|
10
|
+
created_at INTEGER NOT NULL);
|
|
11
|
+
CREATE INDEX tracker_linear_run_links_ticket ON tracker_linear_run_links(ticket_id);`,
|
|
12
|
+
// The boot pass reads only the pairs whose closing has not landed, so the
|
|
13
|
+
// index is **partial**: it holds the owed rows and nothing else, and the
|
|
14
|
+
// settled history never weighs on a boot.
|
|
15
|
+
`ALTER TABLE tracker_linear_run_links ADD COLUMN ended_at INTEGER;
|
|
16
|
+
CREATE INDEX tracker_linear_run_links_owed ON tracker_linear_run_links(created_at) WHERE ended_at IS NULL;`,
|
|
17
|
+
];
|
|
18
|
+
export class RunLinks {
|
|
19
|
+
store;
|
|
20
|
+
constructor(store) {
|
|
21
|
+
this.store = store;
|
|
22
|
+
store.migrate('tracker-linear', migrations);
|
|
23
|
+
}
|
|
24
|
+
/** The delegation happened: record the agent session and its ticket. The
|
|
25
|
+
* OpenCode session joins when the orchestrator's `started` event names
|
|
26
|
+
* this ticket's run. */
|
|
27
|
+
bind(agentSession, ticketId, now = Date.now()) {
|
|
28
|
+
this.store.db
|
|
29
|
+
.prepare(`INSERT INTO tracker_linear_run_links(agent_session, ticket_id, created_at) VALUES(?,?,?)
|
|
30
|
+
ON CONFLICT(agent_session) DO UPDATE SET ticket_id=excluded.ticket_id`)
|
|
31
|
+
.run(agentSession, ticketId, now);
|
|
32
|
+
}
|
|
33
|
+
/** The run's session exists: attach it to the newest unattached pair for
|
|
34
|
+
* the ticket. Absent means the pair was already recorded (a replay). */
|
|
35
|
+
attach(ticketId, opencodeSession) {
|
|
36
|
+
const changed = this.store.db
|
|
37
|
+
.prepare(`UPDATE tracker_linear_run_links SET opencode_session=?
|
|
38
|
+
WHERE agent_session = (
|
|
39
|
+
SELECT agent_session FROM tracker_linear_run_links
|
|
40
|
+
WHERE ticket_id=? AND opencode_session IS NULL ORDER BY created_at DESC LIMIT 1)
|
|
41
|
+
AND opencode_session IS NULL`)
|
|
42
|
+
.run(opencodeSession, ticketId);
|
|
43
|
+
return changed.changes > 0;
|
|
44
|
+
}
|
|
45
|
+
byAgentSession(agentSession) {
|
|
46
|
+
const row = this.store.db
|
|
47
|
+
.prepare('SELECT * FROM tracker_linear_run_links WHERE agent_session=?')
|
|
48
|
+
.get(agentSession);
|
|
49
|
+
return row && map(row);
|
|
50
|
+
}
|
|
51
|
+
byOpencodeSession(opencodeSession) {
|
|
52
|
+
const row = this.store.db
|
|
53
|
+
.prepare('SELECT * FROM tracker_linear_run_links WHERE opencode_session=?')
|
|
54
|
+
.get(opencodeSession);
|
|
55
|
+
return row && map(row);
|
|
56
|
+
}
|
|
57
|
+
/** Every pair whose closing has not landed: the boot pass reads the run
|
|
58
|
+
* state for each and catches Linear up wherever the run has ended. A
|
|
59
|
+
* settled pair is absent — its closing spoke Linear's own state once, and
|
|
60
|
+
* asking again forever was the bug (live, 2026-10-06). Follow-up routing
|
|
61
|
+
* never comes through here: it asks `byAgentSession` directly, settled or
|
|
62
|
+
* not. The lane move is not this list's business: it is the orchestrator's
|
|
63
|
+
* own debt in its own ledger. */
|
|
64
|
+
owed() {
|
|
65
|
+
return this.store.db
|
|
66
|
+
.prepare('SELECT * FROM tracker_linear_run_links WHERE opencode_session IS NOT NULL AND ended_at IS NULL ORDER BY created_at')
|
|
67
|
+
.all().map(map);
|
|
68
|
+
}
|
|
69
|
+
/** The closing landed: settle the pair. The row stays — it is still the
|
|
70
|
+
* route a person's later message finds its run through. */
|
|
71
|
+
closingLanded(agentSession, now = Date.now()) {
|
|
72
|
+
this.store.db
|
|
73
|
+
.prepare('UPDATE tracker_linear_run_links SET ended_at=? WHERE agent_session=?')
|
|
74
|
+
.run(now, agentSession);
|
|
75
|
+
}
|
|
76
|
+
/** Retire a pair for good. Its use today is the gone-closing: Linear
|
|
77
|
+
* answers the agent session with `Entity not found` — the ticket was
|
|
78
|
+
* deleted — so there is nothing left to pay and no reason for the next
|
|
79
|
+
* boot to ask again. */
|
|
80
|
+
drop(agentSession) {
|
|
81
|
+
this.store.db.prepare('DELETE FROM tracker_linear_run_links WHERE agent_session=?').run(agentSession);
|
|
82
|
+
}
|
|
83
|
+
}
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Logger } from '@aivi/core';
|
|
2
|
+
import type { LinearClient } from './client.ts';
|
|
3
|
+
export interface LinearMcpOptions {
|
|
4
|
+
/** Loopback port to bind; `0` lets the OS pick one. */
|
|
5
|
+
port: number;
|
|
6
|
+
/** Upstream MCP endpoint; Linear's hosted server by default. */
|
|
7
|
+
upstream?: string;
|
|
8
|
+
log?: Logger;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The Linear MCP: a loopback-only HTTP server, hosted by the linear module
|
|
12
|
+
* itself, that bridges OpenCode's remote MCP connection to Linear's hosted
|
|
13
|
+
* MCP. It forwards every request verbatim, rewriting only the authorization
|
|
14
|
+
* header to the app-actor token minted by the module's own client — writes
|
|
15
|
+
* attribute to the app, never a human. The token is re-minted once on a 401
|
|
16
|
+
* (the app token lives 30 days with no refresh token; Linear's documented
|
|
17
|
+
* pattern). Loopback only: the MCP can write to Linear as the app, so it is
|
|
18
|
+
* never exposed beyond the machine.
|
|
19
|
+
*/
|
|
20
|
+
export declare class LinearMcp {
|
|
21
|
+
private readonly client;
|
|
22
|
+
private readonly port;
|
|
23
|
+
private readonly upstream;
|
|
24
|
+
private readonly log;
|
|
25
|
+
private server;
|
|
26
|
+
constructor(client: LinearClient, options: LinearMcpOptions);
|
|
27
|
+
/** Bind loopback; resolves with the actual port. */
|
|
28
|
+
start(): Promise<number>;
|
|
29
|
+
stop(): Promise<void>;
|
|
30
|
+
private handle;
|
|
31
|
+
private forward;
|
|
32
|
+
}
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createServer, request as httpRequest, } from 'node:http';
|
|
2
|
+
import { request as httpsRequest } from 'node:https';
|
|
3
|
+
import { getLogger } from '@aivi/core';
|
|
4
|
+
import { MODULE_ID } from './config.js';
|
|
5
|
+
const DEFAULT_UPSTREAM = 'https://mcp.linear.app/mcp';
|
|
6
|
+
/** Hop-by-hop and endpoint-local headers that must not travel upstream. */
|
|
7
|
+
const HOP_HEADERS = new Set(['host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'proxy-connection']);
|
|
8
|
+
/**
|
|
9
|
+
* The Linear MCP: a loopback-only HTTP server, hosted by the linear module
|
|
10
|
+
* itself, that bridges OpenCode's remote MCP connection to Linear's hosted
|
|
11
|
+
* MCP. It forwards every request verbatim, rewriting only the authorization
|
|
12
|
+
* header to the app-actor token minted by the module's own client — writes
|
|
13
|
+
* attribute to the app, never a human. The token is re-minted once on a 401
|
|
14
|
+
* (the app token lives 30 days with no refresh token; Linear's documented
|
|
15
|
+
* pattern). Loopback only: the MCP can write to Linear as the app, so it is
|
|
16
|
+
* never exposed beyond the machine.
|
|
17
|
+
*/
|
|
18
|
+
export class LinearMcp {
|
|
19
|
+
client;
|
|
20
|
+
port;
|
|
21
|
+
upstream;
|
|
22
|
+
log;
|
|
23
|
+
server = null;
|
|
24
|
+
constructor(client, options) {
|
|
25
|
+
this.client = client;
|
|
26
|
+
this.port = options.port;
|
|
27
|
+
this.upstream = new URL(options.upstream ?? DEFAULT_UPSTREAM);
|
|
28
|
+
this.log = options.log ?? getLogger(['aivi', MODULE_ID, 'mcp']);
|
|
29
|
+
}
|
|
30
|
+
/** Bind loopback; resolves with the actual port. */
|
|
31
|
+
start() {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const server = createServer((request, response) => {
|
|
34
|
+
this.handle(request, response).catch(error => {
|
|
35
|
+
this.log.warn('linear.mcp.request_failed', { error });
|
|
36
|
+
if (!response.headersSent)
|
|
37
|
+
response.writeHead(502, { 'content-type': 'application/json' });
|
|
38
|
+
response.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32603, message: 'Upstream unreachable' } }));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
this.server = server;
|
|
42
|
+
server.on('error', reject);
|
|
43
|
+
server.listen(this.port, '127.0.0.1', () => resolve(server.address().port));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
stop() {
|
|
47
|
+
const server = this.server;
|
|
48
|
+
this.server = null;
|
|
49
|
+
if (!server)
|
|
50
|
+
return Promise.resolve();
|
|
51
|
+
return new Promise(resolve => {
|
|
52
|
+
server.closeAllConnections();
|
|
53
|
+
server.close(() => resolve());
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async handle(request, response) {
|
|
57
|
+
// The request body is an MCP message: small JSON, buffered so a 401 retry can replay it.
|
|
58
|
+
const body = await readBody(request);
|
|
59
|
+
if (!body) {
|
|
60
|
+
// Over the cap: said in the protocol's own shape, the way every
|
|
61
|
+
// other body reader in aivi refuses what is too big to hold.
|
|
62
|
+
response.writeHead(413, { 'content-type': 'application/json' });
|
|
63
|
+
response.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Request body over the cap' } }));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
await this.forward(request, response, body, false);
|
|
67
|
+
}
|
|
68
|
+
async forward(request, response, body, retried) {
|
|
69
|
+
// `retried` re-mints: the cached token was just refused upstream.
|
|
70
|
+
const token = await this.client.accessToken(retried);
|
|
71
|
+
const headers = {};
|
|
72
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
73
|
+
if (!HOP_HEADERS.has(name) && typeof value === 'string')
|
|
74
|
+
headers[name] = value;
|
|
75
|
+
}
|
|
76
|
+
headers.authorization = `Bearer ${token}`;
|
|
77
|
+
headers.host = this.upstream.host;
|
|
78
|
+
const incoming = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
79
|
+
await new Promise((resolve, reject) => {
|
|
80
|
+
const upstreamRequest = this.upstream.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
81
|
+
const upstream = upstreamRequest({
|
|
82
|
+
hostname: this.upstream.hostname,
|
|
83
|
+
port: this.upstream.port,
|
|
84
|
+
path: `${this.upstream.pathname}${incoming.search}`,
|
|
85
|
+
method: request.method,
|
|
86
|
+
headers,
|
|
87
|
+
}, upstreamResponse => {
|
|
88
|
+
if (upstreamResponse.statusCode === 401 && !retried) {
|
|
89
|
+
upstreamResponse.resume();
|
|
90
|
+
this.forward(request, response, body, true).then(() => resolve(), reject);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
|
|
94
|
+
upstreamResponse.pipe(response);
|
|
95
|
+
upstreamResponse.on('end', () => resolve());
|
|
96
|
+
upstreamResponse.on('error', reject);
|
|
97
|
+
});
|
|
98
|
+
upstream.on('error', reject);
|
|
99
|
+
if (body.length)
|
|
100
|
+
upstream.end(body);
|
|
101
|
+
else
|
|
102
|
+
upstream.end();
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** What one MCP message may weigh: every other body reader in aivi caps
|
|
107
|
+
* (`readCapped`, `MAX_PUBLIC_BODY`, `MAX_DIARY_BODY`); loopback is the
|
|
108
|
+
* threat model, so this is consistency, and the cap costs nothing. */
|
|
109
|
+
const MAX_MCP_BODY = 1024 * 1024;
|
|
110
|
+
/** The body, buffered whole — or `undefined` when it passes the cap and
|
|
111
|
+
* the stream is dropped rather than accumulated without end. */
|
|
112
|
+
const readBody = (request) => new Promise((resolve, reject) => {
|
|
113
|
+
const chunks = [];
|
|
114
|
+
let bytes = 0;
|
|
115
|
+
request.on('data', chunk => {
|
|
116
|
+
bytes += chunk.length;
|
|
117
|
+
if (bytes > MAX_MCP_BODY) {
|
|
118
|
+
request.destroy();
|
|
119
|
+
resolve(undefined);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
chunks.push(chunk);
|
|
123
|
+
});
|
|
124
|
+
request.on('end', () => resolve(Buffer.concat(chunks)));
|
|
125
|
+
request.on('error', reject);
|
|
126
|
+
});
|
package/dist/module.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ConversationStore } from '@aivi/host';
|
|
2
|
+
import type { AiviModule, AiviServices, Store } from '@aivi/plugin';
|
|
3
|
+
import type { ChannelPlatform, Turn } from '@aivi/plugin/channel';
|
|
4
|
+
import type { Platform } from '@aivi/plugin/tracker';
|
|
5
|
+
import type { LinearConfig } from './config.ts';
|
|
6
|
+
import type { LinearBoard } from './work.ts';
|
|
7
|
+
/**
|
|
8
|
+
* The platform: an agent session is a conversation. Its id is `<app>:<agent session id>`
|
|
9
|
+
* so a turn always knows which app's token speaks for it, restarts included. Assistant
|
|
10
|
+
* turns have effects beyond their reply, so a restart mid-turn blocks instead of discarding.
|
|
11
|
+
*/
|
|
12
|
+
export declare const LINEAR: ChannelPlatform;
|
|
13
|
+
export declare function openLinearStore(store: Store): ConversationStore;
|
|
14
|
+
/** The `<issue>` block that accompanies every start when the event brought
|
|
15
|
+
* no context of its own. */
|
|
16
|
+
/** Where the module's tracker comes from: Linear's adapter by default, a
|
|
17
|
+
* fake tracker in a test of the decisions below. */
|
|
18
|
+
export type MakeTracker = (services: AiviServices) => Promise<Platform>;
|
|
19
|
+
/**
|
|
20
|
+
* The module, built on a tracker. What lives here is routing and Linear's
|
|
21
|
+
* voice — the `Tracker` contract decides what a question and an outcome look
|
|
22
|
+
* like on Linear; the run itself — its record, session, tools and exits — is
|
|
23
|
+
* the host orchestrator's, and Linear is one follower among any number it
|
|
24
|
+
* will never know about.
|
|
25
|
+
*/
|
|
26
|
+
/** The board, pull-shaped, for the orchestrator's walk; a test hands its own
|
|
27
|
+
* lanes the way it hands its own tracker. */
|
|
28
|
+
export type MakeBoard = (services: AiviServices) => LinearBoard;
|
|
29
|
+
export declare function createLinearModule(config: LinearConfig, makeTracker?: MakeTracker, makeBoard?: MakeBoard): AiviModule;
|
|
30
|
+
/** For `aivi linear status`: what each assistant conversation is doing.
|
|
31
|
+
* Workers live in the orchestrator's ledger now; their lines join this view
|
|
32
|
+
* when the status command is formalized with the extraction. */
|
|
33
|
+
export declare function describeWorkers(store: ConversationStore): {
|
|
34
|
+
conversation: string;
|
|
35
|
+
agent: string | null;
|
|
36
|
+
issue: string | null;
|
|
37
|
+
turn: Turn;
|
|
38
|
+
}[];
|