@borgee/agents-host 0.2.29 → 0.2.32
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 +82 -27
- package/dist/agents-host.d.ts +1 -1
- package/dist/agents-host.js +45 -18
- package/dist/cli-args.d.ts +3 -1
- package/dist/cli-args.js +18 -2
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +12 -1
- package/dist/compatibility-gates.js +4 -1
- package/dist/config.d.ts +8 -0
- package/dist/config.js +51 -13
- package/dist/context/prompt.js +1 -1
- package/dist/gateway/localhost-gateway.js +34 -2
- package/dist/index.js +4 -1
- package/dist/local-config.js +20 -1
- package/dist/managed-daemon.js +20 -6
- package/dist/policy/authorization-audit.d.ts +1 -0
- package/dist/policy/gateway-authorization.d.ts +1 -1
- package/dist/policy/gateway-authorization.js +27 -4
- package/dist/providers/claude/cli-client.d.ts +55 -16
- package/dist/providers/claude/cli-client.js +811 -345
- package/dist/providers/create-provider.js +8 -13
- package/dist/state-paths.d.ts +5 -0
- package/dist/state-paths.js +12 -0
- package/dist/types.d.ts +1 -0
- package/dist/update/package-installation.d.ts +52 -0
- package/dist/update/package-installation.js +90 -0
- package/dist/update/package-manager.d.ts +33 -0
- package/dist/update/package-manager.js +137 -0
- package/dist/update/semantic-version.d.ts +7 -0
- package/dist/update/semantic-version.js +76 -0
- package/dist/update/update-command.d.ts +9 -0
- package/dist/update/update-command.js +55 -0
- package/dist/update/update-notice.d.ts +39 -0
- package/dist/update/update-notice.js +161 -0
- package/package.json +5 -2
- package/skills/borgee-agent/SKILL.md +5 -3
- package/skills/borgee-agent/borgee-agent.mjs +56 -22
- package/skills/borgee-agent/borgee-agent.py +56 -27
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { HostLogger, resolveAgentsHostDebugMode } from '../debug.js';
|
|
4
|
+
import { resolveUpdateCheckCachePath } from '../state-paths.js';
|
|
5
|
+
import { classifyPackageInstallation, resolvePackageLocation, } from './package-installation.js';
|
|
6
|
+
import { readConfiguredRegistry, readPublishedVersion } from './package-manager.js';
|
|
7
|
+
import { isNewerSemanticVersion, isSemanticVersion } from './semantic-version.js';
|
|
8
|
+
export const DISABLE_UPDATE_CHECK_ENV = 'AGENTS_HOST_DISABLE_UPDATE_CHECK';
|
|
9
|
+
const UPDATE_CHECK_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
10
|
+
const UPDATE_CHECK_TIMEOUT_MS = 5000;
|
|
11
|
+
/**
|
|
12
|
+
* Every failure under the update check is swallowed, so `AGENTS_HOST_DEBUG=1`
|
|
13
|
+
* is the only way an operator can find out why a notice never appeared.
|
|
14
|
+
*/
|
|
15
|
+
function createUpdateCheckLogger(env, deps) {
|
|
16
|
+
return new HostLogger({ debug: resolveAgentsHostDebugMode(false, env), logger: deps.logger });
|
|
17
|
+
}
|
|
18
|
+
async function readCacheFile(path) {
|
|
19
|
+
return fs.readFile(path, 'utf8');
|
|
20
|
+
}
|
|
21
|
+
async function writeCacheFile(path, contents) {
|
|
22
|
+
await fs.mkdir(dirname(path), { recursive: true });
|
|
23
|
+
// Write-then-rename: a concurrent start never observes a half-written entry,
|
|
24
|
+
// and a symlink planted at the cache path is replaced rather than followed.
|
|
25
|
+
const tempPath = `${path}.${process.pid}.tmp`;
|
|
26
|
+
try {
|
|
27
|
+
await fs.writeFile(tempPath, contents, { encoding: 'utf8', mode: 0o600 });
|
|
28
|
+
await fs.rename(tempPath, path);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
await fs.rm(tempPath, { force: true });
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function parseCacheEntry(contents) {
|
|
36
|
+
const parsed = JSON.parse(contents);
|
|
37
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
38
|
+
throw new Error('Update check cache is not an object');
|
|
39
|
+
}
|
|
40
|
+
const { checkedAt, registry, latestVersion } = parsed;
|
|
41
|
+
if (typeof checkedAt !== 'number' || !Number.isFinite(checkedAt)) {
|
|
42
|
+
throw new Error('Update check cache carries no checkedAt timestamp');
|
|
43
|
+
}
|
|
44
|
+
if (typeof registry !== 'string' || registry.length === 0) {
|
|
45
|
+
throw new Error('Update check cache carries no registry');
|
|
46
|
+
}
|
|
47
|
+
if (latestVersion === null) {
|
|
48
|
+
return { checkedAt, registry, latestVersion: null };
|
|
49
|
+
}
|
|
50
|
+
if (typeof latestVersion !== 'string' || !isSemanticVersion(latestVersion)) {
|
|
51
|
+
throw new Error('Update check cache carries no semantic latestVersion');
|
|
52
|
+
}
|
|
53
|
+
return { checkedAt, registry, latestVersion };
|
|
54
|
+
}
|
|
55
|
+
export function isUpdateCheckDisabled(env) {
|
|
56
|
+
return env[DISABLE_UPDATE_CHECK_ENV]?.trim() === '1';
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Resolves the newest published release for the running installation, or `null`
|
|
60
|
+
* when there is nothing worth telling the operator.
|
|
61
|
+
*
|
|
62
|
+
* Source checkouts never check: their version is whatever the working tree
|
|
63
|
+
* says, and `agents-host update` is the wrong advice for a git tree. That is
|
|
64
|
+
* settled from the path alone, so `pnpm dev` never spawns a package manager.
|
|
65
|
+
* Which manager owns a real install is only asked once there is a notice to
|
|
66
|
+
* word, so a start with nothing to report never pays for that either.
|
|
67
|
+
*/
|
|
68
|
+
export async function resolveAvailableUpdate(options = {}, deps = {}) {
|
|
69
|
+
const env = options.env ?? process.env;
|
|
70
|
+
if (isUpdateCheckDisabled(env)) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
const resolveLocation = deps.resolveLocation ?? (() => resolvePackageLocation());
|
|
74
|
+
const location = await resolveLocation();
|
|
75
|
+
if (location.nodeModulesOwnerDir === null) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const latestVersion = await resolveLatestPublishedVersion(env, location, deps);
|
|
79
|
+
if (latestVersion === null || !isNewerSemanticVersion(latestVersion, location.version)) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const classifyInstallation = deps.classifyInstallation ??
|
|
83
|
+
((candidate) => classifyPackageInstallation(candidate));
|
|
84
|
+
return { installation: await classifyInstallation(location), latestVersion };
|
|
85
|
+
}
|
|
86
|
+
async function resolveLatestPublishedVersion(env, location, deps) {
|
|
87
|
+
const now = deps.now ?? Date.now;
|
|
88
|
+
const readCache = deps.readCache ?? readCacheFile;
|
|
89
|
+
const writeCache = deps.writeCache ?? writeCacheFile;
|
|
90
|
+
const readRegistry = deps.readRegistry ?? readConfiguredRegistry;
|
|
91
|
+
const readLatestVersion = deps.readLatestVersion ?? readPublishedVersion;
|
|
92
|
+
const logger = createUpdateCheckLogger(env, deps);
|
|
93
|
+
const cachePath = resolveUpdateCheckCachePath(env);
|
|
94
|
+
const [cached, registry] = await Promise.all([
|
|
95
|
+
readCache(cachePath)
|
|
96
|
+
.then(parseCacheEntry)
|
|
97
|
+
.catch((error) => {
|
|
98
|
+
// A missing or damaged advisory cache only costs one registry request.
|
|
99
|
+
logger.debugError('update check cache unreadable', error);
|
|
100
|
+
return null;
|
|
101
|
+
}),
|
|
102
|
+
readRegistry(UPDATE_CHECK_TIMEOUT_MS),
|
|
103
|
+
]);
|
|
104
|
+
const checkedAt = now();
|
|
105
|
+
if (cached !== null && cached.registry === registry) {
|
|
106
|
+
// An entry stamped in the future — a clock that has since been corrected,
|
|
107
|
+
// or a poisoned cache — would otherwise stay "fresh" forever and pin the
|
|
108
|
+
// operator on a stale release, so only an age inside the window counts.
|
|
109
|
+
const age = checkedAt - cached.checkedAt;
|
|
110
|
+
if (age >= 0 && age < UPDATE_CHECK_CACHE_TTL_MS) {
|
|
111
|
+
return cached.latestVersion;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const persist = (latestVersion) => writeCache(cachePath, `${JSON.stringify({ checkedAt, registry, latestVersion })}\n`).catch((error) => {
|
|
115
|
+
logger.debugError('update check cache not persisted', error);
|
|
116
|
+
});
|
|
117
|
+
let latestVersion;
|
|
118
|
+
try {
|
|
119
|
+
latestVersion = await readLatestVersion(location.packageName, UPDATE_CHECK_TIMEOUT_MS);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
// Record the failed attempt: an unreachable registry must cost the deadline
|
|
123
|
+
// once per TTL, not on every single start.
|
|
124
|
+
await persist(null);
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
await persist(latestVersion);
|
|
128
|
+
return latestVersion;
|
|
129
|
+
}
|
|
130
|
+
function renderUpdateNotice(update) {
|
|
131
|
+
const { installation, latestVersion } = update;
|
|
132
|
+
const headline = `update available: ${installation.version} -> ${latestVersion}`;
|
|
133
|
+
if (installation.kind === 'project') {
|
|
134
|
+
return [
|
|
135
|
+
headline,
|
|
136
|
+
`update ${installation.packageName} in ${installation.projectRootDir} to pick it up`,
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
return [headline, 'run `agents-host update` to install it'];
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Startup-path entry point. The notice is advisory, so every failure below it —
|
|
143
|
+
* offline machine, unreachable registry, unreadable cache — is logged in debug
|
|
144
|
+
* mode and then dropped: a registry outage must never keep an agent from
|
|
145
|
+
* starting. It writes to stderr so machine-readable stdout stays clean.
|
|
146
|
+
*/
|
|
147
|
+
export async function emitUpdateNotice(options = {}, deps = {}) {
|
|
148
|
+
const logger = createUpdateCheckLogger(options.env ?? process.env, deps);
|
|
149
|
+
try {
|
|
150
|
+
const update = await resolveAvailableUpdate(options, deps);
|
|
151
|
+
if (update === null) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (const line of renderUpdateNotice(update)) {
|
|
155
|
+
logger.error(line);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
logger.debugError('update check failed', error);
|
|
160
|
+
}
|
|
161
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@borgee/agents-host",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.32",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -22,11 +22,12 @@
|
|
|
22
22
|
},
|
|
23
23
|
"license": "MIT",
|
|
24
24
|
"dependencies": {
|
|
25
|
+
"@agentclientprotocol/claude-agent-acp": "0.64.0",
|
|
25
26
|
"@agentclientprotocol/codex-acp": "1.1.7",
|
|
26
27
|
"@agentclientprotocol/sdk": "1.3.0",
|
|
27
28
|
"cross-spawn": "^7.0.6",
|
|
28
29
|
"yaml": "^2.8.1",
|
|
29
|
-
"@borgee/plugin-sdk": "0.2.
|
|
30
|
+
"@borgee/plugin-sdk": "0.2.5"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|
|
32
33
|
"@types/cross-spawn": "^6.0.6",
|
|
@@ -47,6 +48,8 @@
|
|
|
47
48
|
"typecheck": "tsc --noEmit",
|
|
48
49
|
"pretest": "pnpm --filter @borgee/plugin-sdk build",
|
|
49
50
|
"test": "vitest run --testTimeout=10000",
|
|
51
|
+
"pretest:e2e": "pnpm --filter @borgee/plugin-sdk build",
|
|
52
|
+
"test:e2e": "vitest run --config vitest.e2e.config.ts --testTimeout=120000",
|
|
50
53
|
"pretypecheck": "pnpm --filter @borgee/plugin-sdk build"
|
|
51
54
|
}
|
|
52
55
|
}
|
|
@@ -23,10 +23,12 @@ Use one of the packaged local CLIs to inspect the current channel bootstrap payl
|
|
|
23
23
|
- Python auxiliary mention: `python3 ./borgee-agent.py --context /absolute/path/to/context.json --auth-path /absolute/path/to/.localhost-gateway-auth.json --turn-execution-id turn-id --send-mention user-id --body "Need review from <@user-id>"`
|
|
24
24
|
- Python auxiliary reply: `python3 ./borgee-agent.py --context /absolute/path/to/context.json --auth-path /absolute/path/to/.localhost-gateway-auth.json --turn-execution-id turn-id --send-message --body "Following up here" --reply-to message-id`
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
The same CLIs also expose the task commands. Which ones apply depends on where the turn runs, which the injected `context.json` reports through `taskAssignmentContext`:
|
|
27
27
|
|
|
28
|
-
-
|
|
29
|
-
-
|
|
28
|
+
- Parent channel (no `taskAssignmentContext.active`): `--create-task --title ...`, `--list-tasks`, `--get-task --task-id ...`, `--update-task --task-id ...`, `--read-task-history --task-id ...`
|
|
29
|
+
- Task-assignment thread (`taskAssignmentContext.active: true`): `--get-task` and `--update-task` may omit `--task-id` and resolve the current thread task through the persisted `currentTaskId` or an agents-host local fallback; `--read-task-history --task-id ...` still works for that thread's own task, while `--create-task` and `--list-tasks` stay disabled and must be run from the parent channel
|
|
30
|
+
|
|
31
|
+
`--read-task-history` reads the messages inside a task's thread and always requires an explicit `--task-id`; inside that task's own thread `--read-history` already reads the same messages, so the turn prompt offers the command in the parent channel only. It accepts the same `--limit` / `--before` / `--after` window as `--read-history` and answers the same not-found error for a task outside the current channel as for a task that does not exist.
|
|
30
32
|
|
|
31
33
|
The private draft snapshot is read-only, collaboration-scoped, and separate from ordinary public channel messages.
|
|
32
34
|
Auxiliary sends are only for short targeted escalation, reply-thread nudges, or mentions. They must not be used for the main final answer body, which still belongs to AgentsHost.
|
|
@@ -6,6 +6,11 @@ const TASK_THREAD_COLLECTION_COMMAND_ERROR
|
|
|
6
6
|
= 'Task assignment threads only support --get-task and --update-task. Create/list tasks belong to the parent channel.';
|
|
7
7
|
const TASK_THREAD_MISSING_TASK_ID_ERROR
|
|
8
8
|
= 'Task assignment thread could not resolve the current task from persisted context or local fallback. Pass --task-id explicitly.';
|
|
9
|
+
// A window value outside the IEEE-754 safe integer range does not survive this CLI: it parses
|
|
10
|
+
// the value to a number and re-stringifies it into the query string, so `1000000000000000000000`
|
|
11
|
+
// leaves as `1e+21` and the gateway's `Number.parseInt(value, 10)` reads it back as `1` — a
|
|
12
|
+
// different page, silently. Both packaged CLIs accept exactly this grammar.
|
|
13
|
+
const INTEGER_ARGUMENT_PATTERN = /^[+-]?[0-9]+$/;
|
|
9
14
|
|
|
10
15
|
function parseArgs(argv) {
|
|
11
16
|
let contextPath;
|
|
@@ -36,10 +41,13 @@ function parseArgs(argv) {
|
|
|
36
41
|
if (value == null) {
|
|
37
42
|
throw new Error(`Missing value after ${flag}`);
|
|
38
43
|
}
|
|
39
|
-
|
|
40
|
-
if (!Number.isFinite(parsed)) {
|
|
44
|
+
if (!INTEGER_ARGUMENT_PATTERN.test(value)) {
|
|
41
45
|
throw new Error(`Invalid integer for ${flag}: ${value}`);
|
|
42
46
|
}
|
|
47
|
+
const parsed = Number(value);
|
|
48
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
49
|
+
throw new Error(`Integer out of range for ${flag}: ${value}`);
|
|
50
|
+
}
|
|
43
51
|
return parsed;
|
|
44
52
|
}
|
|
45
53
|
|
|
@@ -80,6 +88,10 @@ function parseArgs(argv) {
|
|
|
80
88
|
setAction('read-history');
|
|
81
89
|
continue;
|
|
82
90
|
}
|
|
91
|
+
if (arg === '--read-task-history') {
|
|
92
|
+
setAction('read-task-history');
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
83
95
|
if (arg === '--read-draft') {
|
|
84
96
|
setAction('read-draft');
|
|
85
97
|
continue;
|
|
@@ -215,16 +227,39 @@ function ensureVisibleMentions(body, participantIds) {
|
|
|
215
227
|
);
|
|
216
228
|
}
|
|
217
229
|
|
|
218
|
-
function
|
|
219
|
-
|
|
230
|
+
function explicitTaskId(options) {
|
|
231
|
+
const taskId = typeof options.taskId === 'string' ? options.taskId.trim() : '';
|
|
232
|
+
return taskId.length > 0 ? taskId : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function missingTaskIdError(action) {
|
|
236
|
+
return new Error(`Missing required --task-id <value> for --${action}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function requireExplicitTaskId(action, options) {
|
|
240
|
+
const taskId = explicitTaskId(options);
|
|
241
|
+
if (taskId === null) {
|
|
242
|
+
throw missingTaskIdError(action);
|
|
243
|
+
}
|
|
244
|
+
return taskId;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function applyHistoryWindow(url, options) {
|
|
248
|
+
if (options.limit != null) {
|
|
249
|
+
url.searchParams.set('limit', String(options.limit));
|
|
250
|
+
}
|
|
251
|
+
if (options.before != null) {
|
|
252
|
+
url.searchParams.set('before', String(options.before));
|
|
253
|
+
}
|
|
254
|
+
if (options.after != null) {
|
|
255
|
+
url.searchParams.set('after', String(options.after));
|
|
256
|
+
}
|
|
220
257
|
}
|
|
221
258
|
|
|
222
259
|
function resolveTaskId(payload, action, options) {
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (explicitTaskId) {
|
|
227
|
-
return explicitTaskId;
|
|
260
|
+
const taskId = explicitTaskId(options);
|
|
261
|
+
if (taskId !== null) {
|
|
262
|
+
return taskId;
|
|
228
263
|
}
|
|
229
264
|
if (payload.taskAssignmentContext?.active === true) {
|
|
230
265
|
const persistedTaskId = typeof payload.taskAssignmentContext.currentTaskId === 'string'
|
|
@@ -235,7 +270,7 @@ function resolveTaskId(payload, action, options) {
|
|
|
235
270
|
}
|
|
236
271
|
return null;
|
|
237
272
|
}
|
|
238
|
-
throw
|
|
273
|
+
throw missingTaskIdError(action);
|
|
239
274
|
}
|
|
240
275
|
|
|
241
276
|
function resolveGatewayRequest(payload, action, options) {
|
|
@@ -256,15 +291,13 @@ function resolveGatewayRequest(payload, action, options) {
|
|
|
256
291
|
return { url: new URL(`/v1/channels/${encodedChannelId}/me`, gateway.baseUrl), method: 'GET' };
|
|
257
292
|
case 'read-history': {
|
|
258
293
|
const url = new URL(`/v1/channels/${encodedChannelId}/history`, gateway.baseUrl);
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
url.searchParams.set('after', String(options.after));
|
|
267
|
-
}
|
|
294
|
+
applyHistoryWindow(url, options);
|
|
295
|
+
return { url, method: 'GET' };
|
|
296
|
+
}
|
|
297
|
+
case 'read-task-history': {
|
|
298
|
+
const taskId = requireExplicitTaskId(action, options);
|
|
299
|
+
const url = new URL(`/v1/tasks/${encodeURIComponent(taskId)}/history`, gateway.baseUrl);
|
|
300
|
+
applyHistoryWindow(url, options);
|
|
268
301
|
return { url, method: 'GET' };
|
|
269
302
|
}
|
|
270
303
|
case 'read-draft': {
|
|
@@ -298,7 +331,7 @@ function resolveGatewayRequest(payload, action, options) {
|
|
|
298
331
|
return { url: new URL(`/v1/channels/${encodedChannelId}/tasks`, gateway.baseUrl), method: 'GET' };
|
|
299
332
|
case 'get-task': {
|
|
300
333
|
const taskId = resolveTaskId(payload, action, options);
|
|
301
|
-
if (payload.taskAssignmentContext?.active === true &&
|
|
334
|
+
if (payload.taskAssignmentContext?.active === true && explicitTaskId(options) === null) {
|
|
302
335
|
return {
|
|
303
336
|
url: new URL(`/v1/channels/${encodedChannelId}/current-task`, gateway.baseUrl),
|
|
304
337
|
method: 'GET',
|
|
@@ -317,11 +350,12 @@ function resolveGatewayRequest(payload, action, options) {
|
|
|
317
350
|
...(options.status != null ? { status: options.status } : {}),
|
|
318
351
|
...(options.assigneeId != null ? { assigneeId: options.assigneeId } : {}),
|
|
319
352
|
...(options.title != null ? { title: options.title } : {}),
|
|
353
|
+
...(options.description != null ? { description: options.description } : {}),
|
|
320
354
|
};
|
|
321
355
|
if (Object.keys(requestBody).length === 0) {
|
|
322
|
-
throw new Error('At least one of --status, --assignee-id, or --
|
|
356
|
+
throw new Error('At least one of --status, --assignee-id, --title, or --description is required for --update-task');
|
|
323
357
|
}
|
|
324
|
-
if (payload.taskAssignmentContext?.active === true &&
|
|
358
|
+
if (payload.taskAssignmentContext?.active === true && explicitTaskId(options) === null) {
|
|
325
359
|
return {
|
|
326
360
|
url: new URL(`/v1/channels/${encodedChannelId}/current-task`, gateway.baseUrl),
|
|
327
361
|
method: 'PATCH',
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import re
|
|
6
7
|
import sys
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from urllib.error import HTTPError
|
|
@@ -17,15 +18,23 @@ TASK_THREAD_MISSING_TASK_ID_ERROR = (
|
|
|
17
18
|
"Task assignment thread could not resolve the current task from persisted context "
|
|
18
19
|
"or local fallback. Pass --task-id explicitly."
|
|
19
20
|
)
|
|
21
|
+
# Python ints are arbitrary precision, so nothing can be rounded on the way into the query
|
|
22
|
+
# string; the bound is here only so both packaged CLIs accept and reject exactly the same
|
|
23
|
+
# inputs. It is the Node CLI that needs it: there an out-of-range magnitude re-stringifies as
|
|
24
|
+
# `1e+21`, which the gateway's `Number.parseInt(value, 10)` reads back as `1`.
|
|
25
|
+
INTEGER_ARGUMENT_PATTERN = re.compile(r"[+-]?[0-9]+")
|
|
26
|
+
MAX_SAFE_INTEGER = 2**53 - 1
|
|
20
27
|
|
|
21
28
|
|
|
22
29
|
def parse_int(flag: str, value: str | None) -> int:
|
|
23
30
|
if value is None:
|
|
24
31
|
raise ValueError(f"Missing value after {flag}")
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
if not INTEGER_ARGUMENT_PATTERN.fullmatch(value):
|
|
33
|
+
raise ValueError(f"Invalid integer for {flag}: {value}")
|
|
34
|
+
parsed = int(value)
|
|
35
|
+
if abs(parsed) > MAX_SAFE_INTEGER:
|
|
36
|
+
raise ValueError(f"Integer out of range for {flag}: {value}")
|
|
37
|
+
return parsed
|
|
29
38
|
|
|
30
39
|
|
|
31
40
|
def parse_args(argv: list[str]) -> dict[str, object]:
|
|
@@ -80,6 +89,8 @@ def parse_args(argv: list[str]) -> dict[str, object]:
|
|
|
80
89
|
set_action("get-me")
|
|
81
90
|
elif arg == "--read-history":
|
|
82
91
|
set_action("read-history")
|
|
92
|
+
elif arg == "--read-task-history":
|
|
93
|
+
set_action("read-task-history")
|
|
83
94
|
elif arg == "--read-draft":
|
|
84
95
|
set_action("read-draft")
|
|
85
96
|
elif arg == "--list-users":
|
|
@@ -185,22 +196,42 @@ def ensure_visible_mentions(body: str, participant_ids: list[str]) -> str:
|
|
|
185
196
|
return next_body
|
|
186
197
|
|
|
187
198
|
|
|
188
|
-
def
|
|
199
|
+
def explicit_task_id(options: dict[str, object]) -> str | None:
|
|
189
200
|
task_id = options.get("task_id")
|
|
190
|
-
|
|
201
|
+
trimmed = task_id.strip() if isinstance(task_id, str) else ""
|
|
202
|
+
return trimmed if trimmed else None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def missing_task_id_error(action: str) -> ValueError:
|
|
206
|
+
return ValueError(f"Missing required --task-id <value> for --{action}")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def require_explicit_task_id(action: str, options: dict[str, object]) -> str:
|
|
210
|
+
task_id = explicit_task_id(options)
|
|
211
|
+
if task_id is None:
|
|
212
|
+
raise missing_task_id_error(action)
|
|
213
|
+
return task_id
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def history_query_suffix(options: dict[str, object]) -> str:
|
|
217
|
+
query: dict[str, str] = {}
|
|
218
|
+
for key in ("limit", "before", "after"):
|
|
219
|
+
if options.get(key) is not None:
|
|
220
|
+
query[key] = str(options[key])
|
|
221
|
+
return f"?{urlencode(query)}" if query else ""
|
|
191
222
|
|
|
192
223
|
|
|
193
224
|
def resolve_task_id(payload: dict[str, object], action: str, options: dict[str, object]) -> str | None:
|
|
194
|
-
task_id = options
|
|
195
|
-
if
|
|
196
|
-
return task_id
|
|
225
|
+
task_id = explicit_task_id(options)
|
|
226
|
+
if task_id is not None:
|
|
227
|
+
return task_id
|
|
197
228
|
task_assignment_context = payload.get("taskAssignmentContext")
|
|
198
229
|
if isinstance(task_assignment_context, dict) and task_assignment_context.get("active") is True:
|
|
199
230
|
current_task_id = task_assignment_context.get("currentTaskId")
|
|
200
231
|
if isinstance(current_task_id, str) and current_task_id.strip():
|
|
201
232
|
return current_task_id.strip()
|
|
202
233
|
return None
|
|
203
|
-
raise
|
|
234
|
+
raise missing_task_id_error(action)
|
|
204
235
|
|
|
205
236
|
|
|
206
237
|
def resolve_gateway_request(
|
|
@@ -225,15 +256,10 @@ def resolve_gateway_request(
|
|
|
225
256
|
if action == "get-me":
|
|
226
257
|
return f"{base_url}/v1/channels/{channel_id}/me", "GET", None, False
|
|
227
258
|
if action == "read-history":
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
query["before"] = str(options["before"])
|
|
233
|
-
if options.get("after") is not None:
|
|
234
|
-
query["after"] = str(options["after"])
|
|
235
|
-
suffix = f"?{urlencode(query)}" if query else ""
|
|
236
|
-
return f"{base_url}/v1/channels/{channel_id}/history{suffix}", "GET", None, False
|
|
259
|
+
return f"{base_url}/v1/channels/{channel_id}/history{history_query_suffix(options)}", "GET", None, False
|
|
260
|
+
if action == "read-task-history":
|
|
261
|
+
task_id = quote(require_explicit_task_id(action, options), safe="")
|
|
262
|
+
return f"{base_url}/v1/tasks/{task_id}/history{history_query_suffix(options)}", "GET", None, False
|
|
237
263
|
if action == "read-draft":
|
|
238
264
|
turn_execution_id = options.get("turn_execution_id")
|
|
239
265
|
if not isinstance(turn_execution_id, str) or not turn_execution_id.strip():
|
|
@@ -258,7 +284,7 @@ def resolve_gateway_request(
|
|
|
258
284
|
return f"{base_url}/v1/channels/{channel_id}/tasks", "GET", None, False
|
|
259
285
|
if action == "get-task":
|
|
260
286
|
task_id = resolve_task_id(payload, action, options)
|
|
261
|
-
if isinstance(task_assignment_context, dict) and task_assignment_context.get("active") is True and
|
|
287
|
+
if isinstance(task_assignment_context, dict) and task_assignment_context.get("active") is True and explicit_task_id(options) is None:
|
|
262
288
|
return f"{base_url}/v1/channels/{channel_id}/current-task", "GET", None, True
|
|
263
289
|
return f"{base_url}/v1/tasks/{quote(str(task_id), safe='')}", "GET", None, False
|
|
264
290
|
if action == "update-task":
|
|
@@ -270,9 +296,11 @@ def resolve_gateway_request(
|
|
|
270
296
|
request_body["assigneeId"] = options["assignee_id"]
|
|
271
297
|
if options.get("title") is not None:
|
|
272
298
|
request_body["title"] = options["title"]
|
|
299
|
+
if options.get("description") is not None:
|
|
300
|
+
request_body["description"] = options["description"]
|
|
273
301
|
if not request_body:
|
|
274
|
-
raise ValueError("At least one of --status, --assignee-id, or --
|
|
275
|
-
if isinstance(task_assignment_context, dict) and task_assignment_context.get("active") is True and
|
|
302
|
+
raise ValueError("At least one of --status, --assignee-id, --title, or --description is required for --update-task")
|
|
303
|
+
if isinstance(task_assignment_context, dict) and task_assignment_context.get("active") is True and explicit_task_id(options) is None:
|
|
276
304
|
return f"{base_url}/v1/channels/{channel_id}/current-task", "PATCH", request_body, True
|
|
277
305
|
return f"{base_url}/v1/tasks/{quote(str(task_id), safe='')}", "PATCH", request_body, False
|
|
278
306
|
raise ValueError(f"Unsupported gateway action: {action}")
|
|
@@ -294,11 +322,11 @@ def read_gateway_auth(auth_path: Path | None, expected_channel_id: object) -> di
|
|
|
294
322
|
|
|
295
323
|
|
|
296
324
|
def call_gateway(payload: dict[str, object], action: str, options: dict[str, object]) -> object:
|
|
325
|
+
url, method, request_body, uses_current_thread_fallback = resolve_gateway_request(payload, action, options)
|
|
297
326
|
auth = read_gateway_auth(
|
|
298
327
|
options.get("auth_path") if isinstance(options.get("auth_path"), Path) else None,
|
|
299
328
|
payload.get("channelId"),
|
|
300
329
|
)
|
|
301
|
-
url, method, request_body, uses_current_thread_fallback = resolve_gateway_request(payload, action, options)
|
|
302
330
|
|
|
303
331
|
data = None
|
|
304
332
|
headers = {"Authorization": f"Bearer {auth['token']}"}
|
|
@@ -339,11 +367,12 @@ def call_gateway(payload: dict[str, object], action: str, options: dict[str, obj
|
|
|
339
367
|
response_body = response.read().decode("utf-8")
|
|
340
368
|
return json.loads(response_body) if response_body else None
|
|
341
369
|
except HTTPError as exc:
|
|
342
|
-
|
|
370
|
+
error_body = exc.read().decode("utf-8")
|
|
371
|
+
error_payload = json.loads(error_body) if error_body else None
|
|
372
|
+
if uses_current_thread_fallback and isinstance(error_payload, dict) and error_payload.get("error") == "not_found":
|
|
343
373
|
raise RuntimeError(TASK_THREAD_MISSING_TASK_ID_ERROR) from exc
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
raise RuntimeError(f"Gateway request failed: {exc}") from exc
|
|
374
|
+
reported_body = json.dumps(error_payload, separators=(",", ":"), ensure_ascii=False)
|
|
375
|
+
raise RuntimeError(f"Gateway request failed with {exc.code}: {reported_body}") from exc
|
|
347
376
|
|
|
348
377
|
|
|
349
378
|
def redact_bootstrap(payload: dict[str, object], context_path: Path) -> dict[str, object]:
|