@agentprojectcontext/apx 1.60.1 → 1.62.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/package.json +1 -1
- package/src/core/agent/super-agent.js +7 -1
- package/src/core/agent/tools/handlers/_github.js +22 -0
- package/src/core/agent/tools/handlers/call-runtime.js +185 -76
- package/src/core/agent/tools/handlers/github-create-issue.js +30 -0
- package/src/core/agent/tools/handlers/github-list-repos.js +20 -0
- package/src/core/agent/tools/names.js +6 -0
- package/src/core/agent/tools/registry.js +4 -0
- package/src/core/channels/telegram/dispatch.js +24 -1
- package/src/core/channels/telegram/reply.js +79 -2
- package/src/core/integrations/catalog.js +17 -27
- package/src/core/integrations/plugins/asana.js +22 -0
- package/src/core/integrations/plugins/github.js +160 -0
- package/src/core/stores/runtime-callbacks.js +107 -0
- package/src/host/daemon/callback-reconciler.js +87 -0
- package/src/host/daemon/index.js +7 -0
- package/src/interfaces/web/dist/assets/index-BuII-tAi.css +1 -0
- package/src/interfaces/web/dist/assets/index-CFcs16SV.js +778 -0
- package/src/interfaces/web/dist/assets/index-CFcs16SV.js.map +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +3 -3
- package/src/interfaces/web/src/components/integrations/ComingSoonPlugin.tsx +3 -6
- package/src/interfaces/web/src/components/integrations/PluginConnect.tsx +300 -0
- package/src/interfaces/web/src/components/integrations/PluginToolsSection.tsx +7 -8
- package/src/interfaces/web/src/components/settings/SkillsInspectorPanel.tsx +28 -26
- package/src/interfaces/web/src/i18n/en.ts +57 -0
- package/src/interfaces/web/src/i18n/es.ts +57 -0
- package/src/interfaces/web/src/lib/api/integrations.ts +28 -5
- package/src/interfaces/web/src/screens/project/IntegrationsTab.tsx +28 -38
- package/src/interfaces/web/src/screens/project/McpsTab.tsx +113 -81
- package/src/interfaces/web/dist/assets/index-DFNV6BWh.js +0 -761
- package/src/interfaces/web/dist/assets/index-DFNV6BWh.js.map +0 -1
- package/src/interfaces/web/dist/assets/index-HU-Wt2l9.css +0 -1
- package/src/interfaces/web/src/components/integrations/AsanaPlugin.tsx +0 -275
|
@@ -2,32 +2,27 @@
|
|
|
2
2
|
// which are wired end-to-end. The daemon API + web "Plugins" tab read this.
|
|
3
3
|
//
|
|
4
4
|
// A plugin is "implemented" when it has a service module in ./plugins/ that
|
|
5
|
-
// satisfies the lifecycle contract (configure/validate/status/deactivate)
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
5
|
+
// satisfies the lifecycle contract (configure/validate/status/deactivate) and
|
|
6
|
+
// declares a `ui` descriptor the generic PluginConnect component renders. The
|
|
7
|
+
// rest carry `coming_soon: true`. Adding a plugin = drop a module in ./plugins/
|
|
8
|
+
// + register it in PLUGIN_SERVICES (open/closed) — no API/route changes.
|
|
9
|
+
//
|
|
10
|
+
// Scope of this catalog (per product decision): Asana, GitHub, WhatsApp only.
|
|
11
|
+
// Telegram is intentionally absent — it's a channel, configured under its own
|
|
12
|
+
// surface, not a service plugin. Transcription lives with the desktop STT stack.
|
|
10
13
|
import { asanaPlugin } from "./plugins/asana.js";
|
|
14
|
+
import { githubPlugin } from "./plugins/github.js";
|
|
11
15
|
|
|
12
16
|
// slug -> live plugin service (must implement the lifecycle contract).
|
|
13
17
|
export const PLUGIN_SERVICES = Object.freeze({
|
|
14
18
|
asana: asanaPlugin,
|
|
19
|
+
github: githubPlugin,
|
|
15
20
|
});
|
|
16
21
|
|
|
17
|
-
// Static descriptors for
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// (GitHub outputs↔repos, a WhatsApp-Web bridge, Telegram-voice whisper) that
|
|
21
|
-
// APX models differently — they are placeholders until ported natively.
|
|
22
|
+
// Static descriptors for plugins that are declared but not yet connectable.
|
|
23
|
+
// WhatsApp needs a WhatsApp-Web bridge (QR pairing) that APX doesn't ship yet,
|
|
24
|
+
// so it stays coming-soon rather than pretending to connect.
|
|
22
25
|
const COMING_SOON = [
|
|
23
|
-
{
|
|
24
|
-
slug: "github",
|
|
25
|
-
name: "GitHub",
|
|
26
|
-
type: "source_control",
|
|
27
|
-
description: "Vinculá repos, issues y PRs para que los agentes trabajen sobre tu código",
|
|
28
|
-
auth: "token",
|
|
29
|
-
coming_soon: true,
|
|
30
|
-
},
|
|
31
26
|
{
|
|
32
27
|
slug: "whatsapp",
|
|
33
28
|
name: "WhatsApp",
|
|
@@ -36,17 +31,11 @@ const COMING_SOON = [
|
|
|
36
31
|
auth: "qr",
|
|
37
32
|
coming_soon: true,
|
|
38
33
|
},
|
|
39
|
-
{
|
|
40
|
-
slug: "local-transcription",
|
|
41
|
-
name: "Transcripción Local",
|
|
42
|
-
type: "transcription",
|
|
43
|
-
description: "Transcripción de audio con faster-whisper local — sin depender de una API externa",
|
|
44
|
-
auth: "none",
|
|
45
|
-
coming_soon: true,
|
|
46
|
-
},
|
|
47
34
|
];
|
|
48
35
|
|
|
49
|
-
// The full catalog: implemented plugins first, then coming-soon.
|
|
36
|
+
// The full catalog: implemented plugins first, then coming-soon. Implemented
|
|
37
|
+
// entries carry their `ui` descriptor + tools so the generic component can
|
|
38
|
+
// render their config form.
|
|
50
39
|
export function listCatalog() {
|
|
51
40
|
const implemented = Object.values(PLUGIN_SERVICES).map((p) => ({
|
|
52
41
|
slug: p.slug,
|
|
@@ -55,6 +44,7 @@ export function listCatalog() {
|
|
|
55
44
|
description: p.description,
|
|
56
45
|
auth: p.auth,
|
|
57
46
|
tools: p.tools || [],
|
|
47
|
+
ui: p.ui || null,
|
|
58
48
|
coming_soon: false,
|
|
59
49
|
}));
|
|
60
50
|
return [...implemented, ...COMING_SOON];
|
|
@@ -142,6 +142,28 @@ export const asanaPlugin = {
|
|
|
142
142
|
{ slug: "asana_update_task", desc: "Actualizar estado o campos de una tarea" },
|
|
143
143
|
],
|
|
144
144
|
|
|
145
|
+
// Declarative UI descriptor consumed by the generic PluginConnect component
|
|
146
|
+
// (see web/components/integrations/PluginConnect.tsx). STRUCTURE ONLY — all
|
|
147
|
+
// display text lives in the web i18n dictionaries under integrations.<slug>.*
|
|
148
|
+
// (keyed by field key). configFields render as inputs; `select` is a
|
|
149
|
+
// post-validate picker sourced from an action; connectedFields are the status
|
|
150
|
+
// keys shown once connected. `help_url`/`help_url_label` are non-translatable
|
|
151
|
+
// data kept here.
|
|
152
|
+
ui: {
|
|
153
|
+
accent: "rose",
|
|
154
|
+
configFields: [
|
|
155
|
+
{
|
|
156
|
+
key: "personal_access_token",
|
|
157
|
+
type: "password",
|
|
158
|
+
placeholder: "1/1234567890abcdef:...",
|
|
159
|
+
help_url: "https://app.asana.com/0/my-apps",
|
|
160
|
+
help_url_label: "app.asana.com/0/my-apps",
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
select: { key: "workspace_gid", action: "workspaces", listKey: "workspaces", valueKey: "gid", labelKey: "name" },
|
|
164
|
+
connectedFields: ["user_name", "user_email", "workspace_name"],
|
|
165
|
+
},
|
|
166
|
+
|
|
145
167
|
// Save the PAT and/or the target workspace. Returns a patch to persist.
|
|
146
168
|
configure(record, body = {}) {
|
|
147
169
|
const pat = (body.personal_access_token || "").trim();
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// GitHub integration plugin — Personal Access Token auth. Same shape as the
|
|
2
|
+
// Asana plugin: a pure REST client + a lifecycle descriptor the daemon API
|
|
3
|
+
// dispatches to. Self-contained (GitHub REST over fetch); no GitHub App / OAuth
|
|
4
|
+
// broker yet — a PAT is enough to connect, list repos and open issues.
|
|
5
|
+
|
|
6
|
+
const GITHUB_API_BASE = "https://api.github.com";
|
|
7
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
8
|
+
|
|
9
|
+
function headers(token) {
|
|
10
|
+
return {
|
|
11
|
+
Authorization: `Bearer ${token}`,
|
|
12
|
+
Accept: "application/vnd.github+json",
|
|
13
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
14
|
+
"User-Agent": "apx-integrations",
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function request(token, method, apiPath, { params, payload } = {}) {
|
|
19
|
+
let url = `${GITHUB_API_BASE}${apiPath}`;
|
|
20
|
+
if (params) {
|
|
21
|
+
const qs = new URLSearchParams();
|
|
22
|
+
for (const [k, v] of Object.entries(params)) {
|
|
23
|
+
if (v !== undefined && v !== null && v !== "") qs.set(k, String(v));
|
|
24
|
+
}
|
|
25
|
+
const s = qs.toString();
|
|
26
|
+
if (s) url += `?${s}`;
|
|
27
|
+
}
|
|
28
|
+
const controller = new AbortController();
|
|
29
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
30
|
+
let res;
|
|
31
|
+
try {
|
|
32
|
+
res = await fetch(url, {
|
|
33
|
+
method,
|
|
34
|
+
headers: headers(token),
|
|
35
|
+
body: payload !== undefined ? JSON.stringify(payload) : undefined,
|
|
36
|
+
signal: controller.signal,
|
|
37
|
+
});
|
|
38
|
+
} catch (e) {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
if (e.name === "AbortError") throw new Error("GitHub request timed out");
|
|
41
|
+
throw new Error(`GitHub request failed: ${e.message}`);
|
|
42
|
+
}
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
let detail = `${res.status}`;
|
|
46
|
+
try {
|
|
47
|
+
const body = await res.json();
|
|
48
|
+
detail = body?.message || JSON.stringify(body);
|
|
49
|
+
} catch {
|
|
50
|
+
/* non-JSON error body */
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`GitHub API ${res.status}: ${detail}`);
|
|
53
|
+
}
|
|
54
|
+
return res.json();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── REST client ──────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
export async function validateToken(token) {
|
|
60
|
+
const user = await request(token, "GET", "/user");
|
|
61
|
+
if (!user?.login) throw new Error("GitHub returned an empty user response");
|
|
62
|
+
return user;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function listRepos(token, { affiliation = "owner,collaborator,organization_member", perPage = 30 } = {}) {
|
|
66
|
+
return request(token, "GET", "/user/repos", {
|
|
67
|
+
params: { affiliation, per_page: perPage, sort: "updated" },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function createIssue(token, { owner, repo, title, body }) {
|
|
72
|
+
return request(token, "POST", `/repos/${owner}/${repo}/issues`, { payload: { title, body } });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Plugin descriptor + lifecycle ────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
function safeToken(record) {
|
|
78
|
+
const token = record?.config?.token || "";
|
|
79
|
+
if (!token) throw new Error("GitHub token not configured");
|
|
80
|
+
return token;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const githubPlugin = {
|
|
84
|
+
slug: "github",
|
|
85
|
+
name: "GitHub",
|
|
86
|
+
type: "source_control",
|
|
87
|
+
description: "Conectá GitHub con un token para que los agentes listen repos y abran issues",
|
|
88
|
+
auth: "token",
|
|
89
|
+
tools: [
|
|
90
|
+
{ slug: "github_list_repos", desc: "Listar repositorios accesibles" },
|
|
91
|
+
{ slug: "github_create_issue", desc: "Crear un issue en un repo" },
|
|
92
|
+
],
|
|
93
|
+
// Structure only — display text lives in web i18n (integrations.github.*).
|
|
94
|
+
ui: {
|
|
95
|
+
accent: "slate",
|
|
96
|
+
configFields: [
|
|
97
|
+
{
|
|
98
|
+
key: "token",
|
|
99
|
+
type: "password",
|
|
100
|
+
placeholder: "ghp_... o github_pat_...",
|
|
101
|
+
help_url: "https://github.com/settings/tokens",
|
|
102
|
+
help_url_label: "github.com/settings/tokens",
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
connectedFields: ["user_login", "user_name"],
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
configure(record, body = {}) {
|
|
109
|
+
const token = (body.token || "").trim();
|
|
110
|
+
if (!token && !record) throw new Error("Provide a GitHub token");
|
|
111
|
+
const config = {};
|
|
112
|
+
if (token) config.token = token;
|
|
113
|
+
const patch = { name: "GitHub", type: this.type, description: this.description, config };
|
|
114
|
+
if (token) patch.status = "pending_validation";
|
|
115
|
+
return { patch };
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
async validate(record) {
|
|
119
|
+
const token = safeToken(record);
|
|
120
|
+
let user;
|
|
121
|
+
try {
|
|
122
|
+
user = await validateToken(token);
|
|
123
|
+
} catch (e) {
|
|
124
|
+
return {
|
|
125
|
+
patch: { status: "error", is_enabled: false, config: { last_error: String(e.message || e) } },
|
|
126
|
+
result: { ok: false, error: String(e.message || e) },
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const config = { user_login: user.login, user_name: user.name || null, last_error: null };
|
|
130
|
+
return {
|
|
131
|
+
patch: { status: "active", is_enabled: true, config },
|
|
132
|
+
result: { ok: true, user_login: user.login, user_name: user.name || null },
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
status(record) {
|
|
137
|
+
const config = record?.config || {};
|
|
138
|
+
return {
|
|
139
|
+
slug: this.slug,
|
|
140
|
+
status: record?.status || "disconnected",
|
|
141
|
+
is_enabled: !!record?.is_enabled,
|
|
142
|
+
user_login: config.user_login || null,
|
|
143
|
+
user_name: config.user_name || null,
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
deactivate() {
|
|
148
|
+
return { patch: { status: "inactive", is_enabled: false } };
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
actions: {
|
|
152
|
+
async repos(record) {
|
|
153
|
+
const token = safeToken(record);
|
|
154
|
+
const repos = await listRepos(token);
|
|
155
|
+
return { repos: repos.map((r) => ({ full_name: r.full_name, private: r.private, url: r.html_url })) };
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export default githubPlugin;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Durable pending-callback store for background runtime runs.
|
|
2
|
+
//
|
|
3
|
+
// When call_runtime launches a runtime detached (background mode), the result
|
|
4
|
+
// must reach the originating chat when the runtime finishes — even if the
|
|
5
|
+
// daemon that spawned it dies in the meantime (a crash, a pull, or, as we hit
|
|
6
|
+
// in testing, a task whose very job is to restart the daemon). An in-memory
|
|
7
|
+
// promise can't survive that. So we drop a small durable "IOU" here at launch;
|
|
8
|
+
// a reconciler (host/daemon/callback-reconciler.js) delivers it once the
|
|
9
|
+
// runtime's session record shows the run finished — regardless of WHICH daemon
|
|
10
|
+
// is alive, and regardless of who closed the session (the daemon's own await,
|
|
11
|
+
// or the runtime proactively via `apx session close`).
|
|
12
|
+
//
|
|
13
|
+
// One JSON file per pending callback: <APX_HOME>/pending-callbacks/<id>.json.
|
|
14
|
+
// The in-process fast path deletes the file the moment it takes ownership of
|
|
15
|
+
// delivery; whatever files remain are runs whose spawning daemon never got to
|
|
16
|
+
// deliver — exactly the set the reconciler must handle.
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { APX_HOME } from "#core/config/paths.js";
|
|
20
|
+
import { nowIso } from "#core/util/time.js";
|
|
21
|
+
|
|
22
|
+
export const PENDING_CALLBACKS_DIR = path.join(APX_HOME, "pending-callbacks");
|
|
23
|
+
|
|
24
|
+
const SAFE_ID = /^[A-Za-z0-9._-]+$/;
|
|
25
|
+
|
|
26
|
+
function fileFor(sessionId) {
|
|
27
|
+
return path.join(PENDING_CALLBACKS_DIR, `${sessionId}.json`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Record that a background runtime run owes a callback to a channel. `entry`:
|
|
32
|
+
* { session_id, session_path, channel, chat_id, tg_channel, runtime, agent, who }.
|
|
33
|
+
* Best-effort — a failure to persist must never break the launch.
|
|
34
|
+
*/
|
|
35
|
+
export function writePendingCallback(entry) {
|
|
36
|
+
try {
|
|
37
|
+
if (!entry?.session_id || !SAFE_ID.test(String(entry.session_id))) return;
|
|
38
|
+
fs.mkdirSync(PENDING_CALLBACKS_DIR, { recursive: true });
|
|
39
|
+
fs.writeFileSync(fileFor(entry.session_id), JSON.stringify({ ...entry, created: nowIso() }, null, 2));
|
|
40
|
+
} catch {
|
|
41
|
+
/* best-effort */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Drop the IOU — called the instant an in-process delivery takes ownership. */
|
|
46
|
+
export function deletePendingCallback(sessionId) {
|
|
47
|
+
try {
|
|
48
|
+
if (!sessionId || !SAFE_ID.test(String(sessionId))) return;
|
|
49
|
+
fs.rmSync(fileFor(sessionId), { force: true });
|
|
50
|
+
} catch {
|
|
51
|
+
/* best-effort */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** All outstanding IOUs (parsed). Silently skips unreadable/corrupt files. */
|
|
56
|
+
export function listPendingCallbacks() {
|
|
57
|
+
let files;
|
|
58
|
+
try {
|
|
59
|
+
files = fs.readdirSync(PENDING_CALLBACKS_DIR).filter((f) => f.endsWith(".json"));
|
|
60
|
+
} catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const f of files) {
|
|
65
|
+
try {
|
|
66
|
+
const entry = JSON.parse(fs.readFileSync(path.join(PENDING_CALLBACKS_DIR, f), "utf8"));
|
|
67
|
+
if (entry?.session_id) out.push(entry);
|
|
68
|
+
} catch {
|
|
69
|
+
/* skip corrupt */
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read the finished-state of a runtime session .md by parsing its frontmatter.
|
|
77
|
+
* Returns { exists, done, status, result, completed } — `done` is true once the
|
|
78
|
+
* run has a completed timestamp (set by the daemon's closeRuntimeSession OR by
|
|
79
|
+
* the runtime's proactive `apx session close`).
|
|
80
|
+
*/
|
|
81
|
+
export function readSessionState(sessionPath) {
|
|
82
|
+
let text;
|
|
83
|
+
try {
|
|
84
|
+
text = fs.readFileSync(sessionPath, "utf8");
|
|
85
|
+
} catch {
|
|
86
|
+
return { exists: false, done: false };
|
|
87
|
+
}
|
|
88
|
+
const fm = {};
|
|
89
|
+
if (text.startsWith("---\n")) {
|
|
90
|
+
const end = text.indexOf("\n---", 4);
|
|
91
|
+
if (end !== -1) {
|
|
92
|
+
for (const line of text.slice(4, end).split("\n")) {
|
|
93
|
+
const i = line.indexOf(":");
|
|
94
|
+
if (i > 0) fm[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const completed = fm.completed || "";
|
|
99
|
+
const status = fm.status || "";
|
|
100
|
+
return {
|
|
101
|
+
exists: true,
|
|
102
|
+
done: !!completed && !/in progress/i.test(status),
|
|
103
|
+
status,
|
|
104
|
+
result: fm.result || "",
|
|
105
|
+
completed,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Durable delivery of background-runtime callbacks.
|
|
2
|
+
//
|
|
3
|
+
// Pairs with core/stores/runtime-callbacks.js: call_runtime drops a pending
|
|
4
|
+
// "IOU" when it launches a runtime detached, and deletes it the moment the
|
|
5
|
+
// in-process fast path delivers. Whatever IOUs survive belong to runs whose
|
|
6
|
+
// spawning daemon died before delivering (crash, pull, or a task that restarted
|
|
7
|
+
// the daemon). This reconciler — run once at boot and on an interval — delivers
|
|
8
|
+
// those late, keying on the runtime SESSION being finished rather than on any
|
|
9
|
+
// in-memory promise. That's what makes the callback survive a restart, and also
|
|
10
|
+
// absorbs the "proactive" close: it doesn't care whether the daemon's own await
|
|
11
|
+
// or the runtime's `apx session close` marked the session done.
|
|
12
|
+
//
|
|
13
|
+
// Recovery delivery is a plain channel send of the session's recorded result
|
|
14
|
+
// (after a restart the full stdout is gone — only the one-line result the
|
|
15
|
+
// session close captured remains). The rich A2A relay (Roby re-voicing the
|
|
16
|
+
// result) stays the job of the live in-process path.
|
|
17
|
+
import { listPendingCallbacks, deletePendingCallback, readSessionState } from "#core/stores/runtime-callbacks.js";
|
|
18
|
+
|
|
19
|
+
const GRACE_MS = 30_000; // let the live in-process path win a fresh completion
|
|
20
|
+
const STALE_MS = 24 * 60 * 60 * 1000; // drop IOUs for runs that never finished in a day
|
|
21
|
+
|
|
22
|
+
function deliverText(entry, state) {
|
|
23
|
+
const who = entry.who || entry.runtime || "runtime";
|
|
24
|
+
const result = String(state.result || "").trim();
|
|
25
|
+
const isError = /error|⚠️/i.test(state.status) || /^(failed|error)/i.test(result);
|
|
26
|
+
const head = isError
|
|
27
|
+
? `⚠️ La sesión de ${who} (\`${entry.session_id}\`) terminó con error${result ? `: ${result}` : ""}.`
|
|
28
|
+
: `✅ Terminó la sesión de ${who} (\`${entry.session_id}\`).`;
|
|
29
|
+
const text = !isError && result ? `${head}\n\n${result}` : head;
|
|
30
|
+
return text.slice(0, 3800);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One reconciliation pass. Best-effort per entry; a failure keeps the IOU for
|
|
34
|
+
* the next tick rather than dropping the callback. */
|
|
35
|
+
export async function reconcilePendingCallbacks({ plugins, log }) {
|
|
36
|
+
const pending = listPendingCallbacks();
|
|
37
|
+
if (!pending.length) return;
|
|
38
|
+
const telegram = plugins?.get?.("telegram");
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
|
|
41
|
+
for (const entry of pending) {
|
|
42
|
+
try {
|
|
43
|
+
if (entry.channel !== "telegram") continue; // only telegram delivery for now
|
|
44
|
+
const state = readSessionState(entry.session_path);
|
|
45
|
+
|
|
46
|
+
if (!state.exists) {
|
|
47
|
+
deletePendingCallback(entry.session_id); // session file gone → orphan IOU
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (!state.done) {
|
|
51
|
+
const age = now - Date.parse(entry.created || "");
|
|
52
|
+
if (Number.isFinite(age) && age > STALE_MS) {
|
|
53
|
+
log?.(`callback-reconciler: dropping stale pending ${entry.session_id} (never completed)`);
|
|
54
|
+
deletePendingCallback(entry.session_id);
|
|
55
|
+
}
|
|
56
|
+
continue; // still running — check again next tick
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Finished. Give the live in-process path a grace window to win the
|
|
60
|
+
// normal (no-restart) case, so we don't double-deliver.
|
|
61
|
+
const compAge = now - Date.parse(state.completed || "");
|
|
62
|
+
if (Number.isFinite(compAge) && compAge < GRACE_MS) continue;
|
|
63
|
+
|
|
64
|
+
if (!telegram) continue; // telegram plugin not up this boot — retry next tick
|
|
65
|
+
await telegram.send({
|
|
66
|
+
channel: entry.tg_channel || undefined,
|
|
67
|
+
chat_id: entry.chat_id,
|
|
68
|
+
text: deliverText(entry, state),
|
|
69
|
+
});
|
|
70
|
+
deletePendingCallback(entry.session_id);
|
|
71
|
+
log?.(`callback-reconciler: delivered late callback for ${entry.session_id} → chat ${entry.chat_id}`);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
log?.(`callback-reconciler: delivery failed for ${entry.session_id}: ${e.message}`);
|
|
74
|
+
// keep the IOU; next tick retries
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Start the reconciler: one pass at boot (recovers anything a prior daemon
|
|
80
|
+
* left behind), then every `intervalMs`. Returns { stop }. */
|
|
81
|
+
export function startCallbackReconciler({ plugins, log, intervalMs = 30_000 }) {
|
|
82
|
+
const tick = () => reconcilePendingCallbacks({ plugins, log }).catch(() => {});
|
|
83
|
+
tick();
|
|
84
|
+
const timer = setInterval(tick, intervalMs);
|
|
85
|
+
timer.unref?.();
|
|
86
|
+
return { stop: () => clearInterval(timer) };
|
|
87
|
+
}
|
package/src/host/daemon/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { ProjectManager } from "./db.js";
|
|
|
19
19
|
import { McpRegistry } from "#core/mcp/runner.js";
|
|
20
20
|
import { PluginManager } from "./plugins/index.js";
|
|
21
21
|
import { RoutineScheduler } from "./routines-scheduler.js";
|
|
22
|
+
import { startCallbackReconciler } from "./callback-reconciler.js";
|
|
22
23
|
import { buildApi } from "./api.js";
|
|
23
24
|
import { createTokenStore } from "./token-store.js";
|
|
24
25
|
import { triggerWakeup } from "./wakeup.js";
|
|
@@ -208,12 +209,17 @@ async function main() {
|
|
|
208
209
|
|
|
209
210
|
plugins.installRoutes(app);
|
|
210
211
|
|
|
212
|
+
let callbackReconciler = null;
|
|
211
213
|
const server = app.listen(port, host, () => {
|
|
212
214
|
writePid();
|
|
213
215
|
log(`apx-daemon ${PKG.version} listening on http://${host}:${port}`);
|
|
214
216
|
log(`projects: ${projects.list().length} | plugins: ${Object.keys(plugins.status()).join(", ") || "(none)"}`);
|
|
215
217
|
plugins.startAll();
|
|
216
218
|
scheduler.start();
|
|
219
|
+
// Durable background-runtime callbacks: deliver any results whose spawning
|
|
220
|
+
// daemon died before it could (crash, pull, or a task that restarted the
|
|
221
|
+
// daemon). Runs once now to recover prior IOUs, then on an interval.
|
|
222
|
+
callbackReconciler = startCallbackReconciler({ plugins, log });
|
|
217
223
|
// Cross-channel memory: ensure ~/.apx/memory.md exists, open the vector
|
|
218
224
|
// store, and start the incremental RAG indexer. Best-effort — never blocks
|
|
219
225
|
// boot and never throws into the daemon.
|
|
@@ -280,6 +286,7 @@ async function main() {
|
|
|
280
286
|
function shutdown(signal) {
|
|
281
287
|
log(`received ${signal}, shutting down...`);
|
|
282
288
|
scheduler.stop();
|
|
289
|
+
callbackReconciler?.stop();
|
|
283
290
|
plugins.stopAll();
|
|
284
291
|
stopMemory();
|
|
285
292
|
registries.shutdown();
|