linmux 0.1.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/LICENSE +21 -0
- package/README.md +240 -0
- package/bin/run.js +4 -0
- package/dist/commands/comment/create.js +94 -0
- package/dist/commands/comment/delete.js +74 -0
- package/dist/commands/comment/list.js +84 -0
- package/dist/commands/comment/update.js +80 -0
- package/dist/commands/cycle/current.js +78 -0
- package/dist/commands/cycle/list.js +84 -0
- package/dist/commands/cycle/move.js +91 -0
- package/dist/commands/describe.js +65 -0
- package/dist/commands/graphql/index.js +92 -0
- package/dist/commands/install-skill.js +54 -0
- package/dist/commands/issue/archive.js +75 -0
- package/dist/commands/issue/create.js +115 -0
- package/dist/commands/issue/get.js +84 -0
- package/dist/commands/issue/list.js +93 -0
- package/dist/commands/issue/purge.js +81 -0
- package/dist/commands/issue/search.js +109 -0
- package/dist/commands/issue/transition.js +91 -0
- package/dist/commands/issue/trash.js +75 -0
- package/dist/commands/issue/update.js +126 -0
- package/dist/commands/label/create.js +91 -0
- package/dist/commands/label/list.js +76 -0
- package/dist/commands/list-tools.js +47 -0
- package/dist/commands/me.js +71 -0
- package/dist/commands/project/create.js +101 -0
- package/dist/commands/project/get.js +83 -0
- package/dist/commands/project/list.js +75 -0
- package/dist/commands/project/update-status.js +99 -0
- package/dist/commands/project/update.js +99 -0
- package/dist/commands/raw/batch.js +85 -0
- package/dist/commands/raw/index.js +72 -0
- package/dist/commands/schema.js +69 -0
- package/dist/commands/state/list.js +77 -0
- package/dist/commands/team/get.js +73 -0
- package/dist/commands/team/list.js +73 -0
- package/dist/commands/whoami.js +71 -0
- package/dist/commands/workspace/add.js +97 -0
- package/dist/commands/workspace/list.js +47 -0
- package/dist/commands/workspace/remove.js +63 -0
- package/dist/commands/workspace/replace-token.js +89 -0
- package/dist/commands/workspace/use.js +54 -0
- package/dist/core/client/factory.js +28 -0
- package/dist/core/client/index.js +2 -0
- package/dist/core/config/index.js +4 -0
- package/dist/core/config/paths.js +30 -0
- package/dist/core/config/schema.js +36 -0
- package/dist/core/config/store.js +149 -0
- package/dist/core/errors/error.js +142 -0
- package/dist/core/errors/exit-codes.js +70 -0
- package/dist/core/output/envelope.js +53 -0
- package/dist/core/output/format.js +42 -0
- package/dist/core/output/index.js +3 -0
- package/dist/core/pagination/flags.js +29 -0
- package/dist/core/pagination/index.js +2 -0
- package/dist/core/projection/presets.js +116 -0
- package/dist/core/projection/project.js +282 -0
- package/dist/core/redact/redact.js +45 -0
- package/dist/core/resolvers/cycle.js +60 -0
- package/dist/core/resolvers/index.js +7 -0
- package/dist/core/resolvers/label.js +54 -0
- package/dist/core/resolvers/project-status.js +42 -0
- package/dist/core/resolvers/project.js +43 -0
- package/dist/core/resolvers/state.js +46 -0
- package/dist/core/resolvers/team.js +50 -0
- package/dist/core/transport/fetch-interceptor.js +109 -0
- package/dist/core/transport/index.js +3 -0
- package/dist/core/transport/rate-limit.js +167 -0
- package/dist/core/workspace/resolver.js +70 -0
- package/dist/core/workspace/write-guard.js +43 -0
- package/dist/generated/graphql.js +89428 -0
- package/dist/generated/operations.js +3013 -0
- package/dist/lib/comment-create-runtime.js +96 -0
- package/dist/lib/comment-delete-runtime.js +46 -0
- package/dist/lib/comment-list-runtime.js +182 -0
- package/dist/lib/comment-update-runtime.js +93 -0
- package/dist/lib/cycle-current-runtime.js +90 -0
- package/dist/lib/cycle-list-runtime.js +151 -0
- package/dist/lib/cycle-move-runtime.js +142 -0
- package/dist/lib/describe-runtime.js +180 -0
- package/dist/lib/filter-heuristics.js +59 -0
- package/dist/lib/graphql-runtime.js +202 -0
- package/dist/lib/include-fragments.js +73 -0
- package/dist/lib/install-skill-runtime.js +228 -0
- package/dist/lib/introspection-registry.js +488 -0
- package/dist/lib/issue-archive-runtime.js +89 -0
- package/dist/lib/issue-create-runtime.js +175 -0
- package/dist/lib/issue-get-runtime.js +153 -0
- package/dist/lib/issue-list-runtime.js +164 -0
- package/dist/lib/issue-purge-runtime.js +89 -0
- package/dist/lib/issue-search-runtime.js +114 -0
- package/dist/lib/issue-transition-runtime.js +131 -0
- package/dist/lib/issue-trash-runtime.js +84 -0
- package/dist/lib/issue-update-runtime.js +164 -0
- package/dist/lib/label-create-runtime.js +113 -0
- package/dist/lib/label-list-runtime.js +97 -0
- package/dist/lib/levenshtein.js +42 -0
- package/dist/lib/list-tools-runtime.js +38 -0
- package/dist/lib/me-runtime.js +55 -0
- package/dist/lib/project-create-runtime.js +103 -0
- package/dist/lib/project-get-runtime.js +134 -0
- package/dist/lib/project-list-runtime.js +84 -0
- package/dist/lib/project-update-runtime.js +110 -0
- package/dist/lib/project-update-status-runtime.js +91 -0
- package/dist/lib/raw-batch-runtime.js +229 -0
- package/dist/lib/raw-runtime.js +171 -0
- package/dist/lib/schema-loader.js +41 -0
- package/dist/lib/schema-runtime.js +65 -0
- package/dist/lib/state-list-runtime.js +93 -0
- package/dist/lib/team-get-runtime.js +55 -0
- package/dist/lib/team-list-runtime.js +52 -0
- package/dist/lib/workspace-runtime.js +112 -0
- package/dist/operations/_registry.zod.js +5337 -0
- package/oclif.manifest.json +3631 -0
- package/package.json +99 -0
- package/schema.graphql +30772 -0
- package/skills/linmux/SKILL.md +186 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { LinearAgentError } from "../errors/error.js";
|
|
2
|
+
import { withRateLimitRetry } from "../transport/rate-limit.js";
|
|
3
|
+
import { UUID_RE } from "../../lib/filter-heuristics.js";
|
|
4
|
+
//#region src/core/resolvers/project-status.ts
|
|
5
|
+
const cache = /* @__PURE__ */ new Map();
|
|
6
|
+
/**
|
|
7
|
+
* Resolve a project-status name (or UUID) to a `ProjectStatus.id` for the
|
|
8
|
+
* active workspace. Throws `PROJECT_NOT_FOUND` (re-used taxonomy) on miss.
|
|
9
|
+
*/
|
|
10
|
+
async function resolveProjectStatusId(client, workspaceName, nameOrId, retryOpts) {
|
|
11
|
+
if (UUID_RE.test(nameOrId)) return nameOrId;
|
|
12
|
+
let map = cache.get(workspaceName);
|
|
13
|
+
if (!map) {
|
|
14
|
+
map = (async () => {
|
|
15
|
+
const conn = await withRateLimitRetry(() => client.projectStatuses({ first: 50 }), retryOpts);
|
|
16
|
+
const m = /* @__PURE__ */ new Map();
|
|
17
|
+
for (const s of conn.nodes) m.set(s.name.toLowerCase(), s.id);
|
|
18
|
+
return m;
|
|
19
|
+
})();
|
|
20
|
+
cache.set(workspaceName, map);
|
|
21
|
+
}
|
|
22
|
+
let m;
|
|
23
|
+
try {
|
|
24
|
+
m = await map;
|
|
25
|
+
} catch (e) {
|
|
26
|
+
cache.delete(workspaceName);
|
|
27
|
+
throw e;
|
|
28
|
+
}
|
|
29
|
+
const id = m.get(nameOrId.toLowerCase());
|
|
30
|
+
if (!id) throw new LinearAgentError({
|
|
31
|
+
code: "PROJECT_NOT_FOUND",
|
|
32
|
+
message: `project status not found: ${nameOrId}`,
|
|
33
|
+
details: {
|
|
34
|
+
workspace: workspaceName,
|
|
35
|
+
requested: nameOrId,
|
|
36
|
+
available: [...m.keys()].sort()
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
return id;
|
|
40
|
+
}
|
|
41
|
+
//#endregion
|
|
42
|
+
export { resolveProjectStatusId };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { LinearAgentError } from "../errors/error.js";
|
|
2
|
+
import { withRateLimitRetry } from "../transport/rate-limit.js";
|
|
3
|
+
import { UUID_RE } from "../../lib/filter-heuristics.js";
|
|
4
|
+
//#region src/core/resolvers/project.ts
|
|
5
|
+
const cache = /* @__PURE__ */ new Map();
|
|
6
|
+
/**
|
|
7
|
+
* Resolve a project name (or UUID) to a project UUID for the active
|
|
8
|
+
* workspace. Throws `PROJECT_NOT_FOUND` on miss with the cached project name
|
|
9
|
+
* list so the agent can self-correct.
|
|
10
|
+
*/
|
|
11
|
+
async function resolveProjectId(client, workspaceName, nameOrId, retryOpts) {
|
|
12
|
+
if (UUID_RE.test(nameOrId)) return nameOrId;
|
|
13
|
+
let map = cache.get(workspaceName);
|
|
14
|
+
if (!map) {
|
|
15
|
+
map = (async () => {
|
|
16
|
+
const conn = await withRateLimitRetry(() => client.projects({ first: 250 }), retryOpts);
|
|
17
|
+
const m = /* @__PURE__ */ new Map();
|
|
18
|
+
for (const p of conn.nodes) m.set(p.name.toLowerCase(), p.id);
|
|
19
|
+
return m;
|
|
20
|
+
})();
|
|
21
|
+
cache.set(workspaceName, map);
|
|
22
|
+
}
|
|
23
|
+
let m;
|
|
24
|
+
try {
|
|
25
|
+
m = await map;
|
|
26
|
+
} catch (e) {
|
|
27
|
+
cache.delete(workspaceName);
|
|
28
|
+
throw e;
|
|
29
|
+
}
|
|
30
|
+
const id = m.get(nameOrId.toLowerCase());
|
|
31
|
+
if (!id) throw new LinearAgentError({
|
|
32
|
+
code: "PROJECT_NOT_FOUND",
|
|
33
|
+
message: `project not found: ${nameOrId}`,
|
|
34
|
+
details: {
|
|
35
|
+
workspace: workspaceName,
|
|
36
|
+
requested: nameOrId,
|
|
37
|
+
available: [...m.keys()].sort()
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
return id;
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
43
|
+
export { resolveProjectId };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { LinearAgentError } from "../errors/error.js";
|
|
2
|
+
import { withRateLimitRetry } from "../transport/rate-limit.js";
|
|
3
|
+
import { UUID_RE } from "../../lib/filter-heuristics.js";
|
|
4
|
+
//#region src/core/resolvers/state.ts
|
|
5
|
+
const cache = /* @__PURE__ */ new Map();
|
|
6
|
+
/**
|
|
7
|
+
* Resolve a state-name (or UUID) to a workflow-state UUID, scoped to one
|
|
8
|
+
* `${workspace}:${teamId}` pair.
|
|
9
|
+
*/
|
|
10
|
+
async function resolveStateNameToId(client, workspaceName, teamId, nameOrId, retryOpts) {
|
|
11
|
+
if (UUID_RE.test(nameOrId)) return nameOrId;
|
|
12
|
+
const key = `${workspaceName}:${teamId}`;
|
|
13
|
+
let map = cache.get(key);
|
|
14
|
+
if (!map) {
|
|
15
|
+
map = (async () => {
|
|
16
|
+
const conn = await withRateLimitRetry(() => client.workflowStates({
|
|
17
|
+
filter: { team: { id: { eq: teamId } } },
|
|
18
|
+
first: 50
|
|
19
|
+
}), retryOpts);
|
|
20
|
+
const m = /* @__PURE__ */ new Map();
|
|
21
|
+
for (const s of conn.nodes) m.set(s.name.toLowerCase(), s.id);
|
|
22
|
+
return m;
|
|
23
|
+
})();
|
|
24
|
+
cache.set(key, map);
|
|
25
|
+
}
|
|
26
|
+
let m;
|
|
27
|
+
try {
|
|
28
|
+
m = await map;
|
|
29
|
+
} catch (e) {
|
|
30
|
+
cache.delete(key);
|
|
31
|
+
throw e;
|
|
32
|
+
}
|
|
33
|
+
const id = m.get(nameOrId.toLowerCase());
|
|
34
|
+
if (!id) throw new LinearAgentError({
|
|
35
|
+
code: "WORKFLOW_STATE_NOT_FOUND",
|
|
36
|
+
message: `workflow state not found: ${nameOrId}`,
|
|
37
|
+
details: {
|
|
38
|
+
teamId,
|
|
39
|
+
requested: nameOrId,
|
|
40
|
+
available: [...m.keys()].sort()
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
return id;
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
export { resolveStateNameToId };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { LinearAgentError } from "../errors/error.js";
|
|
2
|
+
import { withRateLimitRetry } from "../transport/rate-limit.js";
|
|
3
|
+
import { TEAM_KEY_RE, UUID_RE } from "../../lib/filter-heuristics.js";
|
|
4
|
+
//#region src/core/resolvers/team.ts
|
|
5
|
+
const cache = /* @__PURE__ */ new Map();
|
|
6
|
+
/**
|
|
7
|
+
* Resolve a team key, name, or UUID to a team UUID for the active workspace.
|
|
8
|
+
*/
|
|
9
|
+
async function resolveTeamId(client, workspaceName, keyOrIdOrName, retryOpts) {
|
|
10
|
+
if (UUID_RE.test(keyOrIdOrName)) return keyOrIdOrName;
|
|
11
|
+
let entry = cache.get(workspaceName);
|
|
12
|
+
if (!entry) {
|
|
13
|
+
entry = (async () => {
|
|
14
|
+
const conn = await withRateLimitRetry(() => client.teams({ first: 250 }), retryOpts);
|
|
15
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
16
|
+
const byName = /* @__PURE__ */ new Map();
|
|
17
|
+
for (const t of conn.nodes) {
|
|
18
|
+
byKey.set(t.key.toLowerCase(), t.id);
|
|
19
|
+
byName.set(t.name.toLowerCase(), t.id);
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
byKey,
|
|
23
|
+
byName
|
|
24
|
+
};
|
|
25
|
+
})();
|
|
26
|
+
cache.set(workspaceName, entry);
|
|
27
|
+
}
|
|
28
|
+
let resolved;
|
|
29
|
+
try {
|
|
30
|
+
resolved = await entry;
|
|
31
|
+
} catch (e) {
|
|
32
|
+
cache.delete(workspaceName);
|
|
33
|
+
throw e;
|
|
34
|
+
}
|
|
35
|
+
const lower = keyOrIdOrName.toLowerCase();
|
|
36
|
+
const id = TEAM_KEY_RE.test(keyOrIdOrName) ? resolved.byKey.get(lower) ?? resolved.byName.get(lower) : resolved.byName.get(lower) ?? resolved.byKey.get(lower);
|
|
37
|
+
if (!id) throw new LinearAgentError({
|
|
38
|
+
code: "TEAM_NOT_FOUND",
|
|
39
|
+
message: `team not found: ${keyOrIdOrName}`,
|
|
40
|
+
details: {
|
|
41
|
+
workspace: workspaceName,
|
|
42
|
+
requested: keyOrIdOrName,
|
|
43
|
+
availableKeys: [...resolved.byKey.keys()].sort(),
|
|
44
|
+
availableNames: [...resolved.byName.keys()].sort()
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
return id;
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
export { resolveTeamId };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
//#region src/core/transport/fetch-interceptor.ts
|
|
3
|
+
/**
|
|
4
|
+
* Fetch interceptor for capturing Linear's complexity-meter response headers
|
|
5
|
+
* (Phase 2 PLAN 02-01, RAT-02).
|
|
6
|
+
*
|
|
7
|
+
* Linear surfaces rate-limit pressure on every successful response via:
|
|
8
|
+
* - `x-complexity` (cost of this query)
|
|
9
|
+
* - `x-ratelimit-complexity-remaining` (remaining budget in window)
|
|
10
|
+
*
|
|
11
|
+
* The SDK does not expose these headers via its public API, so the only
|
|
12
|
+
* lever to surface them in our envelope is patching `globalThis.fetch` for
|
|
13
|
+
* the lifetime of the SDK call. To stay safe under concurrent invocations
|
|
14
|
+
* (parallel workspace add/replace-token, parallel agents), the patched
|
|
15
|
+
* fetch's "last response" state lives in an `AsyncLocalStorage` context —
|
|
16
|
+
* each `withFetchInterception(fn)` call gets its own ALS frame.
|
|
17
|
+
*
|
|
18
|
+
* Snapshot-drift safety (see RESEARCH § Pitfall 8):
|
|
19
|
+
* `getLastComplexity()` returns `undefined` whenever no patched fetch has
|
|
20
|
+
* observed a response — typically because tests mock at the SDK class
|
|
21
|
+
* boundary (vi.mock('@linear/sdk')) and never go through `globalThis.fetch`.
|
|
22
|
+
* Runtimes spread `meta.complexity` only when the value is present:
|
|
23
|
+
* `...(getLastComplexity() && { complexity: getLastComplexity() })`
|
|
24
|
+
* This keeps Phase 1 snapshots byte-identical: the spread is a no-op in
|
|
25
|
+
* mocked tests, so `meta.complexity` is absent from the serialized envelope.
|
|
26
|
+
*
|
|
27
|
+
* Restoration invariant: the original `globalThis.fetch` is restored in a
|
|
28
|
+
* `finally` block. Even if `fn` throws, the global is restored to the
|
|
29
|
+
* captured `original` reference (Test 17). The patch is also nested-safe:
|
|
30
|
+
* each `withFetchInterception` saves the current fetch (which may itself be
|
|
31
|
+
* a previous patch) and restores it on exit.
|
|
32
|
+
*/
|
|
33
|
+
const als = new AsyncLocalStorage();
|
|
34
|
+
/**
|
|
35
|
+
* Read the most-recent complexity headers captured inside the current
|
|
36
|
+
* `withFetchInterception(fn)` ALS frame. Returns `undefined` when the
|
|
37
|
+
* interceptor was never engaged (e.g. SDK-mocked unit tests) or no fetch
|
|
38
|
+
* response carried the headers.
|
|
39
|
+
*/
|
|
40
|
+
function getLastComplexity() {
|
|
41
|
+
return als.getStore()?.last;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Marker we attach to our patched fetch so nested `withFetchInterception`
|
|
45
|
+
* calls can recognise an existing patch and reuse the *real* fetch
|
|
46
|
+
* underneath rather than chaining writes into the wrong ALS frame.
|
|
47
|
+
*
|
|
48
|
+
* Without this, two parallel `withFetchInterception(fnA), withFetchInterception(fnB)`
|
|
49
|
+
* would chain patches: B's patched fetch → A's patched fetch → real fetch.
|
|
50
|
+
* A's closure-captured `ctx` would receive writes from B's responses,
|
|
51
|
+
* breaking ALS isolation. The marker lets B's patch detect that the current
|
|
52
|
+
* `globalThis.fetch` is already a patch and skip past it to whatever it
|
|
53
|
+
* wraps — so each patch sits between the **real** fetch and exactly the
|
|
54
|
+
* `fn` call tree it was created for.
|
|
55
|
+
*/
|
|
56
|
+
const PATCH_MARKER = Symbol.for("linmux.fetch-interceptor.patch");
|
|
57
|
+
const RAW_FETCH_KEY = Symbol.for("linmux.fetch-interceptor.rawFetch");
|
|
58
|
+
function unwrapToRealFetch(f) {
|
|
59
|
+
const cursor = f;
|
|
60
|
+
if (cursor[PATCH_MARKER] === true && cursor[RAW_FETCH_KEY] !== void 0) return cursor[RAW_FETCH_KEY];
|
|
61
|
+
return f;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Run `fn` with `globalThis.fetch` patched so that every response within
|
|
65
|
+
* `fn`'s call tree updates the ALS-scoped "last complexity" record for
|
|
66
|
+
* THIS frame only. The original fetch is restored unconditionally (success
|
|
67
|
+
* or throw).
|
|
68
|
+
*
|
|
69
|
+
* Concurrent invocations are isolated via `AsyncLocalStorage`. The patch
|
|
70
|
+
* itself reads `als.getStore()` on each call — so even if two parallel
|
|
71
|
+
* frames share the same global patch via interleaved scheduling, each
|
|
72
|
+
* fetch's complexity write lands in the ALS frame whose `fn` is currently
|
|
73
|
+
* on the stack (not the closure-captured ctx of whichever patch happens to
|
|
74
|
+
* be the "outermost" wrapper).
|
|
75
|
+
*/
|
|
76
|
+
async function withFetchInterception(fn) {
|
|
77
|
+
return als.run({}, async () => {
|
|
78
|
+
const previous = globalThis.fetch;
|
|
79
|
+
const realFetch = unwrapToRealFetch(previous);
|
|
80
|
+
const patched = async (...args) => {
|
|
81
|
+
const res = await realFetch(...args);
|
|
82
|
+
const cost = parseHeaderInt(res.headers.get("x-complexity"));
|
|
83
|
+
const remaining = parseHeaderInt(res.headers.get("x-ratelimit-complexity-remaining"));
|
|
84
|
+
if (cost !== void 0 && remaining !== void 0) {
|
|
85
|
+
const store = als.getStore();
|
|
86
|
+
if (store) store.last = {
|
|
87
|
+
cost,
|
|
88
|
+
remaining
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return res;
|
|
92
|
+
};
|
|
93
|
+
patched[PATCH_MARKER] = true;
|
|
94
|
+
patched[RAW_FETCH_KEY] = realFetch;
|
|
95
|
+
globalThis.fetch = patched;
|
|
96
|
+
try {
|
|
97
|
+
return await fn();
|
|
98
|
+
} finally {
|
|
99
|
+
globalThis.fetch = previous;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
function parseHeaderInt(value) {
|
|
104
|
+
if (value === null) return void 0;
|
|
105
|
+
const n = Number(value);
|
|
106
|
+
return Number.isFinite(n) ? n : void 0;
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
export { getLastComplexity, withFetchInterception };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { LinearAgentError } from "../errors/error.js";
|
|
2
|
+
import { AuthenticationLinearError, InvalidInputLinearError, LinearError, NetworkLinearError, RatelimitedLinearError } from "@linear/sdk";
|
|
3
|
+
//#region src/core/transport/rate-limit.ts
|
|
4
|
+
/**
|
|
5
|
+
* Rate-limit-aware transport wrapper (Phase 2 PLAN 02-01, RAT-01 / RAT-03).
|
|
6
|
+
*
|
|
7
|
+
* Every Phase 2 SDK call routes through `withRateLimitRetry`. The wrapper
|
|
8
|
+
* 1. retries on `RatelimitedLinearError` and `NetworkLinearError` with
|
|
9
|
+
* exponential backoff + full jitter (RAT-01 default policy);
|
|
10
|
+
* 2. classifies all other thrown SDK errors into `LinearAgentError`
|
|
11
|
+
* instances via `classifySdkError` (RAT-03) so the kernel envelope
|
|
12
|
+
* shape stays canonical.
|
|
13
|
+
*
|
|
14
|
+
* The classifier discriminates on `@linear/sdk` typed error classes
|
|
15
|
+
* (`RatelimitedLinearError`, `AuthenticationLinearError`, `NetworkLinearError`,
|
|
16
|
+
* `InvalidInputLinearError`, `LinearError`) — NOT regex on `err.message` and
|
|
17
|
+
* NOT `errors[].extensions.code`. Phase 1's two regex-based message-substring
|
|
18
|
+
* classifiers (formerly in issue-list-runtime + workspace-runtime) are
|
|
19
|
+
* retired in Plan 02-01 Task 3; this module is the canonical replacement.
|
|
20
|
+
*
|
|
21
|
+
* Default backoff policy (RAT-01):
|
|
22
|
+
* - Rate-limit: base 250ms doubling (250 → 500 → 1000) with full jitter,
|
|
23
|
+
* OR `err.retryAfter * 1000` capped at `4 × base`. 3 attempts.
|
|
24
|
+
* - Network: base 100ms doubling (100 → 200 → 400) with full jitter.
|
|
25
|
+
* 3 attempts.
|
|
26
|
+
* - Auth/Validation/Other: NO retry — surfaced immediately.
|
|
27
|
+
*
|
|
28
|
+
* Test seams: callers may inject `opts.sleep` and `opts.random` to make
|
|
29
|
+
* timing deterministic in unit tests. The `retryOptsOverride?: RetryOpts`
|
|
30
|
+
* field on per-runtime input interfaces (issue-list-runtime, etc.) is the
|
|
31
|
+
* test-only entry point — production call sites pass nothing.
|
|
32
|
+
*/
|
|
33
|
+
const DEFAULTS = {
|
|
34
|
+
maxAttempts: 3,
|
|
35
|
+
rateLimitBaseMs: 250,
|
|
36
|
+
networkBaseMs: 100,
|
|
37
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
38
|
+
random: () => Math.random()
|
|
39
|
+
};
|
|
40
|
+
const RATELIMIT_DEFAULT_RETRY_MS = 3e4;
|
|
41
|
+
function resolveOpts(opts) {
|
|
42
|
+
const resolved = {
|
|
43
|
+
maxAttempts: (opts?.maxAttempts ?? DEFAULTS.maxAttempts) + Math.max(0, opts?.extraAttempts ?? 0),
|
|
44
|
+
rateLimitBaseMs: opts?.rateLimitBaseMs ?? DEFAULTS.rateLimitBaseMs,
|
|
45
|
+
networkBaseMs: opts?.networkBaseMs ?? DEFAULTS.networkBaseMs,
|
|
46
|
+
sleep: opts?.sleep ?? DEFAULTS.sleep,
|
|
47
|
+
random: opts?.random ?? DEFAULTS.random
|
|
48
|
+
};
|
|
49
|
+
if (opts?.onRetry !== void 0) resolved.onRetry = opts.onRetry;
|
|
50
|
+
return resolved;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Wrap a thunk that performs an SDK call so it transparently retries on
|
|
54
|
+
* rate-limit and network errors and classifies any thrown error into a
|
|
55
|
+
* `LinearAgentError` before re-throwing.
|
|
56
|
+
*
|
|
57
|
+
* Successful inner calls return their value unchanged.
|
|
58
|
+
*
|
|
59
|
+
* Errors thrown by the inner call are funneled through `classifySdkError`
|
|
60
|
+
* after the retry loop exhausts (or immediately for non-retryable cases).
|
|
61
|
+
* If the inner call throws a `LinearAgentError` directly, it passes through
|
|
62
|
+
* unchanged (idempotent — no double-wrapping).
|
|
63
|
+
*/
|
|
64
|
+
async function withRateLimitRetry(call, opts) {
|
|
65
|
+
const o = resolveOpts(opts);
|
|
66
|
+
if (o.maxAttempts < 1) throw LinearAgentError.usage(`withRateLimitRetry: maxAttempts must be >= 1 (got ${o.maxAttempts})`);
|
|
67
|
+
let attempt = 0;
|
|
68
|
+
let lastErr;
|
|
69
|
+
while (attempt < o.maxAttempts) try {
|
|
70
|
+
return await call();
|
|
71
|
+
} catch (err) {
|
|
72
|
+
lastErr = err;
|
|
73
|
+
if (err instanceof RatelimitedLinearError) {
|
|
74
|
+
if (attempt === o.maxAttempts - 1) break;
|
|
75
|
+
const base = o.rateLimitBaseMs * 2 ** attempt;
|
|
76
|
+
const hint = err.retryAfter !== void 0 ? err.retryAfter * 1e3 : void 0;
|
|
77
|
+
const sleepMs = hint !== void 0 ? Math.max(Math.min(hint, base * 4), base) : base + o.random() * base;
|
|
78
|
+
o.onRetry?.({
|
|
79
|
+
attempt: attempt + 1,
|
|
80
|
+
total: o.maxAttempts,
|
|
81
|
+
code: "RATELIMITED",
|
|
82
|
+
backoffMs: Math.round(sleepMs)
|
|
83
|
+
});
|
|
84
|
+
await o.sleep(sleepMs);
|
|
85
|
+
attempt++;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (err instanceof NetworkLinearError) {
|
|
89
|
+
if (attempt === o.maxAttempts - 1) break;
|
|
90
|
+
const base = o.networkBaseMs * 2 ** attempt;
|
|
91
|
+
const sleepMs = base + o.random() * base;
|
|
92
|
+
o.onRetry?.({
|
|
93
|
+
attempt: attempt + 1,
|
|
94
|
+
total: o.maxAttempts,
|
|
95
|
+
code: "NETWORK_ERROR",
|
|
96
|
+
backoffMs: Math.round(sleepMs)
|
|
97
|
+
});
|
|
98
|
+
await o.sleep(sleepMs);
|
|
99
|
+
attempt++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
const classified = classifySdkError(lastErr);
|
|
105
|
+
if (lastErr instanceof LinearAgentError && classified === lastErr) throw classified;
|
|
106
|
+
if (!(opts?.extraAttempts !== void 0 && opts.extraAttempts > 0 || opts?.onRetry !== void 0)) throw classified;
|
|
107
|
+
throw withAttempts(classified, attempt + 1);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Return a `LinearAgentError` byte-identical to `err` except for an added
|
|
111
|
+
* `details.attempts: count` key. Used to tag final-exhaustion attempt
|
|
112
|
+
* counts without mutating `LinearAgentError`'s readonly `details`.
|
|
113
|
+
*/
|
|
114
|
+
function withAttempts(err, count) {
|
|
115
|
+
const mergedDetails = {
|
|
116
|
+
...err.details ?? {},
|
|
117
|
+
attempts: count
|
|
118
|
+
};
|
|
119
|
+
const init = {
|
|
120
|
+
code: err.code,
|
|
121
|
+
message: err.message,
|
|
122
|
+
transient: err.transient,
|
|
123
|
+
details: mergedDetails
|
|
124
|
+
};
|
|
125
|
+
if (err.retryAfterMs !== void 0) init.retryAfterMs = err.retryAfterMs;
|
|
126
|
+
return new LinearAgentError(init);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Classify any thrown SDK error into the canonical `LinearAgentError`
|
|
130
|
+
* taxonomy. Idempotent: a `LinearAgentError` argument passes through
|
|
131
|
+
* unchanged.
|
|
132
|
+
*
|
|
133
|
+
* Discrimination uses `instanceof` on `@linear/sdk` typed error classes —
|
|
134
|
+
* NOT message regex, NOT `errors[].extensions.code`. The SDK already does
|
|
135
|
+
* that work for us in `parseLinearError`.
|
|
136
|
+
*/
|
|
137
|
+
function classifySdkError(err) {
|
|
138
|
+
if (err instanceof LinearAgentError) return err;
|
|
139
|
+
if (err instanceof RatelimitedLinearError) {
|
|
140
|
+
const retryAfterMs = err.retryAfter !== void 0 ? err.retryAfter * 1e3 : RATELIMIT_DEFAULT_RETRY_MS;
|
|
141
|
+
const details = {};
|
|
142
|
+
if (err.complexityRemaining !== void 0) details.complexityRemaining = err.complexityRemaining;
|
|
143
|
+
if (err.complexityLimit !== void 0) details.complexityLimit = err.complexityLimit;
|
|
144
|
+
if (err.complexityResetAt !== void 0) details.complexityResetAt = err.complexityResetAt;
|
|
145
|
+
return LinearAgentError.rateLimited(retryAfterMs, Object.keys(details).length > 0 ? details : void 0);
|
|
146
|
+
}
|
|
147
|
+
if (err instanceof NetworkLinearError) return LinearAgentError.network("network error during Linear API call");
|
|
148
|
+
if (err instanceof AuthenticationLinearError) return LinearAgentError.auth.invalid("token rejected by Linear");
|
|
149
|
+
if (err instanceof InvalidInputLinearError) {
|
|
150
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
151
|
+
return LinearAgentError.validation.failed("Linear rejected the request payload", { cause: msg });
|
|
152
|
+
}
|
|
153
|
+
if (err instanceof LinearError) {
|
|
154
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
155
|
+
return LinearAgentError.linear.apiError({
|
|
156
|
+
message: "Linear API call failed",
|
|
157
|
+
details: { cause: msg }
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
161
|
+
return LinearAgentError.linear.apiError({
|
|
162
|
+
message: "Linear API call failed",
|
|
163
|
+
details: { cause: msg }
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
//#endregion
|
|
167
|
+
export { classifySdkError, withRateLimitRetry };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { LinearAgentError } from "../errors/error.js";
|
|
2
|
+
//#region src/core/workspace/resolver.ts
|
|
3
|
+
/**
|
|
4
|
+
* Pure resolver that translates `(flags, env, config)` into a single
|
|
5
|
+
* `ResolvedWorkspace`. Implements the 6-step precedence chain from
|
|
6
|
+
* `01-CONTEXT.md § Workspace Resolution Precedence`:
|
|
7
|
+
*
|
|
8
|
+
* 1. `flags.workspace` (--workspace flag)
|
|
9
|
+
* 2. `env.LINEAR_WORKSPACE` (LINEAR_WORKSPACE env var)
|
|
10
|
+
* 3. `config.active` (active default in config)
|
|
11
|
+
* 4. single-workspace short-circuit (only one entry in config.workspaces)
|
|
12
|
+
* 5. `env.LINEAR_API_KEY` (env-key bypass — no config consulted)
|
|
13
|
+
* 6. throw `WORKSPACE_NOT_RESOLVED` (no input matched)
|
|
14
|
+
*
|
|
15
|
+
* Steps 1–3 also throw `WORKSPACE_NOT_FOUND` when the requested name is not
|
|
16
|
+
* registered. This is critical for tenancy isolation (PITFALLS § Pitfall 2):
|
|
17
|
+
* `--workspace ghost` MUST fail loudly rather than silently fall through to
|
|
18
|
+
* the active default.
|
|
19
|
+
*
|
|
20
|
+
* Purity guarantees:
|
|
21
|
+
* - No `process.env` reads (caller passes `env`)
|
|
22
|
+
* - No filesystem I/O (caller passes `config`)
|
|
23
|
+
* - No mutation of any input field (read-only walk)
|
|
24
|
+
* - Same input -> deeply-equal output, every time
|
|
25
|
+
*/
|
|
26
|
+
function resolveWorkspace(input) {
|
|
27
|
+
const { flags, env, config } = input;
|
|
28
|
+
if (flags.workspace) return loadOrThrow(config, flags.workspace, "flag");
|
|
29
|
+
if (env.LINEAR_WORKSPACE) return loadOrThrow(config, env.LINEAR_WORKSPACE, "env");
|
|
30
|
+
if (config.active) return loadOrThrow(config, config.active, "active");
|
|
31
|
+
const names = Object.keys(config.workspaces);
|
|
32
|
+
if (names.length === 1) {
|
|
33
|
+
const sole = names[0];
|
|
34
|
+
if (sole !== void 0) return loadOrThrow(config, sole, "single");
|
|
35
|
+
}
|
|
36
|
+
if (env.LINEAR_API_KEY) return {
|
|
37
|
+
name: null,
|
|
38
|
+
token: env.LINEAR_API_KEY,
|
|
39
|
+
organizationId: null,
|
|
40
|
+
source: "api-key-env"
|
|
41
|
+
};
|
|
42
|
+
throw new LinearAgentError({
|
|
43
|
+
code: "WORKSPACE_NOT_RESOLVED",
|
|
44
|
+
message: "no workspace selected: pass --workspace <name>, set LINEAR_WORKSPACE, or run `linmux workspace use <name>`",
|
|
45
|
+
details: {
|
|
46
|
+
configuredWorkspaces: names,
|
|
47
|
+
remediation: names.length === 0 ? "run `linmux workspace add <name> --token <api-key>` to register a workspace" : "run `linmux workspace use <name>` to set an active default, or pass --workspace <name>"
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function loadOrThrow(config, name, source) {
|
|
52
|
+
const entry = config.workspaces[name];
|
|
53
|
+
if (!entry) throw new LinearAgentError({
|
|
54
|
+
code: "WORKSPACE_NOT_FOUND",
|
|
55
|
+
message: `workspace not found: ${name}`,
|
|
56
|
+
details: {
|
|
57
|
+
requested: name,
|
|
58
|
+
configured: Object.keys(config.workspaces),
|
|
59
|
+
source
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
return {
|
|
63
|
+
name: entry.name,
|
|
64
|
+
token: entry.token,
|
|
65
|
+
organizationId: entry.organizationId,
|
|
66
|
+
source
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
//#endregion
|
|
70
|
+
export { resolveWorkspace };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { LinearAgentError } from "../errors/error.js";
|
|
2
|
+
//#region src/core/workspace/write-guard.ts
|
|
3
|
+
/**
|
|
4
|
+
* WSP-06 enforcement for write commands (mutations).
|
|
5
|
+
*
|
|
6
|
+
* A write command requires an *explicit* workspace selector to prevent
|
|
7
|
+
* cross-workspace data leakage (PITFALLS § Pitfall 2 — the #1 tenancy risk).
|
|
8
|
+
* Of the five `WorkspaceSource` values, three are considered explicit:
|
|
9
|
+
*
|
|
10
|
+
* - `flag` — caller passed `--workspace <name>` for THIS invocation
|
|
11
|
+
* - `env` — caller set `LINEAR_WORKSPACE` in the env for THIS invocation
|
|
12
|
+
* - `api-key-env` — `LINEAR_API_KEY` is the selector itself; there is no
|
|
13
|
+
* ambiguity about which workspace the token targets, and
|
|
14
|
+
* setting it is an explicit per-invocation act
|
|
15
|
+
*
|
|
16
|
+
* The other two are NOT explicit:
|
|
17
|
+
*
|
|
18
|
+
* - `active` — silently inherits the user's persisted active default
|
|
19
|
+
* - `single` — auto-picks the only registered workspace (still implicit)
|
|
20
|
+
*
|
|
21
|
+
* For `active` and `single`, this guard throws `WORKSPACE_REQUIRED_FOR_WRITE`
|
|
22
|
+
* BEFORE any SDK call is made — unless the caller passes
|
|
23
|
+
* `allowActiveOptIn=true` (mapped from the per-invocation
|
|
24
|
+
* `--allow-active-workspace-write` flag).
|
|
25
|
+
*
|
|
26
|
+
* The opt-in is per-invocation only. There is no persisted config flag that
|
|
27
|
+
* relaxes this rule globally.
|
|
28
|
+
*/
|
|
29
|
+
function requireExplicitWorkspaceForWrite(resolved, allowActiveOptIn) {
|
|
30
|
+
if (allowActiveOptIn) return;
|
|
31
|
+
if (resolved.source === "flag" || resolved.source === "env" || resolved.source === "api-key-env") return;
|
|
32
|
+
throw new LinearAgentError({
|
|
33
|
+
code: "WORKSPACE_REQUIRED_FOR_WRITE",
|
|
34
|
+
message: "write commands require an explicit workspace selector: pass --workspace <name>, set LINEAR_WORKSPACE, or pass --allow-active-workspace-write to opt in to using the active default for this invocation",
|
|
35
|
+
details: {
|
|
36
|
+
resolvedWorkspace: resolved.name,
|
|
37
|
+
resolvedFrom: resolved.source,
|
|
38
|
+
remediation: "pass --workspace <name>, set LINEAR_WORKSPACE=<name>, or pass --allow-active-workspace-write"
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
43
|
+
export { requireExplicitWorkspaceForWrite };
|