aibroker 0.58.0 → 0.59.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/dist/adapters/iterm/core.d.ts +26 -1
- package/dist/adapters/iterm/core.d.ts.map +1 -1
- package/dist/adapters/iterm/core.js +71 -4
- package/dist/adapters/iterm/core.js.map +1 -1
- package/dist/adapters/iterm/sessions.d.ts.map +1 -1
- package/dist/adapters/iterm/sessions.js +36 -4
- package/dist/adapters/iterm/sessions.js.map +1 -1
- package/dist/adapters/pailot/gateway.d.ts.map +1 -1
- package/dist/adapters/pailot/gateway.js +7 -4
- package/dist/adapters/pailot/gateway.js.map +1 -1
- package/dist/adapters/pailot/mqtt-broker.d.ts +6 -0
- package/dist/adapters/pailot/mqtt-broker.d.ts.map +1 -1
- package/dist/adapters/pailot/mqtt-broker.js +137 -119
- package/dist/adapters/pailot/mqtt-broker.js.map +1 -1
- package/dist/core/call-timing.d.ts +10 -0
- package/dist/core/call-timing.d.ts.map +1 -0
- package/dist/core/call-timing.js +42 -0
- package/dist/core/call-timing.js.map +1 -0
- package/dist/core/env-bootstrap.d.ts +14 -0
- package/dist/core/env-bootstrap.d.ts.map +1 -0
- package/dist/core/env-bootstrap.js +21 -0
- package/dist/core/env-bootstrap.js.map +1 -0
- package/dist/core/session-discovery.d.ts +3 -1
- package/dist/core/session-discovery.d.ts.map +1 -1
- package/dist/core/session-discovery.js +2 -2
- package/dist/core/session-discovery.js.map +1 -1
- package/dist/core/state.d.ts +7 -1
- package/dist/core/state.d.ts.map +1 -1
- package/dist/core/state.js +13 -3
- package/dist/core/state.js.map +1 -1
- package/dist/daemon/cli.d.ts +1 -1
- package/dist/daemon/cli.d.ts.map +1 -1
- package/dist/daemon/cli.js +1 -0
- package/dist/daemon/cli.js.map +1 -1
- package/dist/daemon/core-handlers.d.ts +28 -0
- package/dist/daemon/core-handlers.d.ts.map +1 -1
- package/dist/daemon/core-handlers.js +150 -50
- package/dist/daemon/core-handlers.js.map +1 -1
- package/dist/daemon/dispatch.d.ts +39 -0
- package/dist/daemon/dispatch.d.ts.map +1 -1
- package/dist/daemon/dispatch.js +331 -2
- package/dist/daemon/dispatch.js.map +1 -1
- package/dist/daemon/index.d.ts.map +1 -1
- package/dist/daemon/index.js +10 -0
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/manage.d.ts +37 -0
- package/dist/daemon/manage.d.ts.map +1 -1
- package/dist/daemon/manage.js +164 -18
- package/dist/daemon/manage.js.map +1 -1
- package/dist/daemon/session-content.d.ts +3 -1
- package/dist/daemon/session-content.d.ts.map +1 -1
- package/dist/daemon/session-content.js +35 -2
- package/dist/daemon/session-content.js.map +1 -1
- package/dist/daemon/todoist-pending.d.ts +45 -0
- package/dist/daemon/todoist-pending.d.ts.map +1 -0
- package/dist/daemon/todoist-pending.js +58 -0
- package/dist/daemon/todoist-pending.js.map +1 -0
- package/dist/daemon/todoist-reply.d.ts +10 -0
- package/dist/daemon/todoist-reply.d.ts.map +1 -1
- package/dist/daemon/todoist-reply.js +1 -0
- package/dist/daemon/todoist-reply.js.map +1 -1
- package/dist/daemon/todoist-webhook.d.ts +29 -0
- package/dist/daemon/todoist-webhook.d.ts.map +1 -1
- package/dist/daemon/todoist-webhook.js +331 -208
- package/dist/daemon/todoist-webhook.js.map +1 -1
- package/dist/ipc/client.d.ts.map +1 -1
- package/dist/ipc/client.js +24 -3
- package/dist/ipc/client.js.map +1 -1
- package/dist/mcp/index.js +19 -2
- package/dist/mcp/index.js.map +1 -1
- package/dist/transport/sync-facade.d.ts +15 -2
- package/dist/transport/sync-facade.d.ts.map +1 -1
- package/dist/transport/sync-facade.js +16 -3
- package/dist/transport/sync-facade.js.map +1 -1
- package/dist/transport/tmux.d.ts.map +1 -1
- package/dist/transport/tmux.js +20 -2
- package/dist/transport/tmux.js.map +1 -1
- package/hooks/drain-mailbox.mjs +34 -8
- package/package.json +1 -1
|
@@ -42,6 +42,7 @@ import { audit } from "./audit.js";
|
|
|
42
42
|
import { handleOAuthCallback } from "./todoist-oauth.js";
|
|
43
43
|
import { handleA2A, a2aConfigured } from "../a2a/server.js";
|
|
44
44
|
import { funnelHostname } from "./funnel-watchdog.js";
|
|
45
|
+
import { queuePendingEvent, removePendingEvent, bumpPendingEvent, listPendingEvents, PENDING_MAX_AGE_MS, } from "./todoist-pending.js";
|
|
45
46
|
const __a2a_dirname = dirname(fileURLToPath(import.meta.url));
|
|
46
47
|
function a2aPackageVersion() {
|
|
47
48
|
try {
|
|
@@ -565,6 +566,102 @@ async function handleInbound(name, req, res) {
|
|
|
565
566
|
}
|
|
566
567
|
/** Mirrors inbound.ts — kept local so this module has no load-time dependency on it. */
|
|
567
568
|
const HOOK_PREFIX = "/hook/";
|
|
569
|
+
/**
|
|
570
|
+
* Transient (network blip, timeout, 5xx, 429) vs permanent (404, 401/403 —
|
|
571
|
+
* the task or the grant is actually gone). `fetchParentTask` encodes an HTTP
|
|
572
|
+
* status in its message for anything that reached Todoist; anything without
|
|
573
|
+
* one — "fetch failed", a timeout, DNS — never got an answer at all, so it
|
|
574
|
+
* cannot mean "no such task" and is treated as transient.
|
|
575
|
+
*/
|
|
576
|
+
export function isTransientTodoistError(err) {
|
|
577
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
578
|
+
const status = /task lookup failed with (\d+)/.exec(msg)?.[1];
|
|
579
|
+
if (!status)
|
|
580
|
+
return true;
|
|
581
|
+
const code = Number(status);
|
|
582
|
+
return code === 429 || code >= 500;
|
|
583
|
+
}
|
|
584
|
+
/** One attempt at resolving the parent, mutating `event.event_data` in place on success. */
|
|
585
|
+
async function resolveParentOnce(event, kind, parentId, fetchImpl = fetch) {
|
|
586
|
+
try {
|
|
587
|
+
const { fetchParentTask } = await import("./todoist-reply.js");
|
|
588
|
+
const parent = await fetchParentTask(parentId, fetchImpl);
|
|
589
|
+
event.event_data = {
|
|
590
|
+
...event.event_data,
|
|
591
|
+
id: parentId,
|
|
592
|
+
project_id: parent.projectId,
|
|
593
|
+
labels: parent.labels,
|
|
594
|
+
// A reminder has no text of its own, and routing needs some: the
|
|
595
|
+
// task IS the instruction here, so its title is the content. A
|
|
596
|
+
// comment brings its own text and keeps it — the title is only the
|
|
597
|
+
// context that text makes sense in.
|
|
598
|
+
//
|
|
599
|
+
// The reminder branch also carries `due`: a reminder on a recurring
|
|
600
|
+
// trigger must be claimable as one, and isTrigger() reads due.is_recurring
|
|
601
|
+
// off the merged event data.
|
|
602
|
+
...(kind === "reminder"
|
|
603
|
+
? { content: parent.content, due: parent.due }
|
|
604
|
+
: { description: parent.content ? `(comment on "${parent.content}")` : "" }),
|
|
605
|
+
};
|
|
606
|
+
return { ok: true };
|
|
607
|
+
}
|
|
608
|
+
catch (err) {
|
|
609
|
+
const reason = `could not resolve the task a ${kind} belongs to — ${err instanceof Error ? err.message : String(err)}`;
|
|
610
|
+
return { ok: false, transient: isTransientTodoistError(err), reason };
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
/** Spread over ~1 minute: enough to ride out a blocked event loop or a short outage. */
|
|
614
|
+
const INLINE_RETRY_DELAYS_MS = [5_000, 15_000, 40_000];
|
|
615
|
+
/**
|
|
616
|
+
* The inline attempts a live request gets before its event is handed to the
|
|
617
|
+
* disk queue. A permanent error (or the last attempt) returns immediately;
|
|
618
|
+
* `sleep` is injected so tests do not wait a minute for it.
|
|
619
|
+
*/
|
|
620
|
+
export async function resolveParentWithRetry(event, kind, parentId, delaysMs = INLINE_RETRY_DELAYS_MS, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), fetchImpl = fetch) {
|
|
621
|
+
for (let attempt = 0;; attempt++) {
|
|
622
|
+
const result = await resolveParentOnce(event, kind, parentId, fetchImpl);
|
|
623
|
+
if (result.ok || !result.transient || attempt >= delaysMs.length)
|
|
624
|
+
return result;
|
|
625
|
+
await sleep(delaysMs[attempt]);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Drain the disk queue: one resolve attempt per pending event, no inline
|
|
630
|
+
* backoff — the interval between sweeps already spaces attempts out. Runs
|
|
631
|
+
* once shortly after the listener starts (covers a daemon restart) and again
|
|
632
|
+
* on every tick after that (covers an outage longer than the inline retries).
|
|
633
|
+
*/
|
|
634
|
+
export async function sweepPendingEvents(cfg, deps, fetchImpl = fetch) {
|
|
635
|
+
for (const p of listPendingEvents()) {
|
|
636
|
+
const ageMs = Date.now() - Date.parse(p.firstSeenAt);
|
|
637
|
+
if (ageMs > PENDING_MAX_AGE_MS) {
|
|
638
|
+
removePendingEvent(p.key);
|
|
639
|
+
audit({
|
|
640
|
+
action: "webhook", actor: "todoist", target: "aibroker", outcome: "ignored",
|
|
641
|
+
reason: `${p.lastError} — still unresolved after ${Math.round(ageMs / 3600_000)}h, giving up`,
|
|
642
|
+
meta: { event: p.event.event_name, taskId: p.parentId },
|
|
643
|
+
});
|
|
644
|
+
log(`todoist-webhook: giving up on a ${p.kind} for ${p.parentId}, unresolved after ${Math.round(ageMs / 3600_000)}h`);
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
const result = await resolveParentOnce(p.event, p.kind, p.parentId, fetchImpl);
|
|
648
|
+
if (result.ok) {
|
|
649
|
+
removePendingEvent(p.key);
|
|
650
|
+
await processResolvedEvent(p.event, cfg, deps);
|
|
651
|
+
}
|
|
652
|
+
else if (!result.transient) {
|
|
653
|
+
removePendingEvent(p.key);
|
|
654
|
+
audit({
|
|
655
|
+
action: "webhook", actor: "todoist", target: "aibroker", outcome: "ignored",
|
|
656
|
+
reason: result.reason, meta: { event: p.event.event_name, taskId: p.parentId },
|
|
657
|
+
});
|
|
658
|
+
log(`todoist-webhook: ${result.reason}`);
|
|
659
|
+
}
|
|
660
|
+
else {
|
|
661
|
+
bumpPendingEvent(p.key, result.reason);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
568
665
|
/**
|
|
569
666
|
* `cfg` is null when Todoist itself is not configured (or was refused —
|
|
570
667
|
* see the empty-ingress case in `startTodoistWebhook`). The listener still
|
|
@@ -696,221 +793,236 @@ export function createWebhookServer(cfg, deps) {
|
|
|
696
793
|
});
|
|
697
794
|
return;
|
|
698
795
|
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
// context that text makes sense in.
|
|
711
|
-
...(isReminder
|
|
712
|
-
? { content: parent.content }
|
|
713
|
-
: { description: parent.content ? `(comment on "${parent.content}")` : "" }),
|
|
714
|
-
};
|
|
715
|
-
}
|
|
716
|
-
catch (err) {
|
|
717
|
-
const reason = `could not resolve the task a ${kind} belongs to — ${err instanceof Error ? err.message : String(err)}`;
|
|
718
|
-
audit({
|
|
719
|
-
action: "webhook", actor: "todoist", target: "aibroker",
|
|
720
|
-
outcome: "ignored", reason, meta: { event: event.event_name, taskId: parentId },
|
|
721
|
-
});
|
|
722
|
-
log(`todoist-webhook: ${reason}`);
|
|
723
|
-
return;
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
// A comment written on a MIRROR entry is meant for the real task.
|
|
727
|
-
//
|
|
728
|
-
// The mirror is where the reader's attention is, so replying there rather
|
|
729
|
-
// than on the source is the natural mistake — and Todoist has no way to
|
|
730
|
-
// deep-link an individual comment that would make the source easier to
|
|
731
|
-
// reach. Carry it across instead of losing it in a project nothing reads.
|
|
732
|
-
// Checked before routing: the mirror project is deliberately NOT an
|
|
733
|
-
// ingress project, so routing would refuse this and say nothing useful.
|
|
734
|
-
if (event.event_name === "note:added") {
|
|
735
|
-
const { mirrorProjectId, mirrorBack } = await import("./todoist-mirror.js");
|
|
736
|
-
const mirror = mirrorProjectId();
|
|
737
|
-
if (mirror && String(event.event_data?.project_id ?? "") === mirror) {
|
|
738
|
-
const r = await mirrorBack({
|
|
739
|
-
taskId: String(event.event_data?.id ?? ""),
|
|
740
|
-
text: String(event.event_data?.content ?? ""),
|
|
741
|
-
projectId: mirror,
|
|
742
|
-
});
|
|
743
|
-
audit({
|
|
744
|
-
action: "webhook", actor: "todoist", target: "aibroker",
|
|
745
|
-
outcome: r.carried ? "carried-back" : "ignored",
|
|
746
|
-
reason: r.reason ?? "comment on a mirror entry carried to its source task",
|
|
747
|
-
});
|
|
748
|
-
return;
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
const isComment = event.event_name === "note:added";
|
|
752
|
-
const parentId = String(event.event_data?.id ?? "");
|
|
753
|
-
const { ownerOf, rememberOwner } = await import("./todoist-owners.js");
|
|
754
|
-
// Grants made since the daemon started take effect now, not at the next
|
|
755
|
-
// restart. A project created and granted while you are using the system
|
|
756
|
-
// has to work immediately, or the grant is indistinguishable from a
|
|
757
|
-
// project that routes nowhere.
|
|
758
|
-
const { applyGrants, expandThroughSubtree } = await import("./todoist-ingress.js");
|
|
759
|
-
let live = applyGrants(cfg);
|
|
760
|
-
// A sub-project is a folder, not a second owner. Before this, organising
|
|
761
|
-
// tasks into "Task Bus / Archive" moved them outside the
|
|
762
|
-
// allowlist and every one was refused — silently, and precisely when
|
|
763
|
-
// someone tidied up. Only ancestors granted WITH a subtree flag apply, so
|
|
764
|
-
// nothing becomes an ingress that nobody granted.
|
|
765
|
-
const eventProject = String(event.event_data?.project_id ?? "");
|
|
766
|
-
if (eventProject && !live.ingressProjectIds.has(eventProject)) {
|
|
767
|
-
try {
|
|
768
|
-
const { projectTree, ancestorsOf } = await import("./todoist-projects.js");
|
|
769
|
-
const tree = await projectTree();
|
|
770
|
-
live = expandThroughSubtree(live, eventProject, ancestorsOf(eventProject, tree), {
|
|
771
|
-
name: tree.get(eventProject)?.name,
|
|
772
|
-
known: await (deps.knownOwners?.() ?? []),
|
|
773
|
-
});
|
|
774
|
-
}
|
|
775
|
-
catch (err) {
|
|
776
|
-
log(`todoist-webhook: subtree lookup failed — ${err instanceof Error ? err.message : String(err)}`);
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
const decision = route(event, live, await (deps.knownOwners?.() ?? []), isComment ? ownerOf(parentId) : undefined);
|
|
780
|
-
const who = event.initiator?.email ?? event.initiator?.full_name ?? "todoist";
|
|
781
|
-
if (decision.nearMiss) {
|
|
782
|
-
// Recorded whether or not the task was delivered: the point is that
|
|
783
|
-
// something was asked for and not honoured.
|
|
784
|
-
log(`todoist-webhook: near miss — ${decision.nearMiss}`);
|
|
785
|
-
}
|
|
786
|
-
// A completion has nothing to dispatch, but it is not nothing. The
|
|
787
|
-
// comment thread on a ticked task leaves every list at that moment, so a
|
|
788
|
-
// configured hook gets its chance before this is recorded as "ignored" —
|
|
789
|
-
// and its exit code decides what the record says.
|
|
790
|
-
let hook = { ran: false, ok: true };
|
|
791
|
-
if (event.event_name === "item:completed") {
|
|
792
|
-
const { runCompletedHook } = await import("./todoist-completed-hook.js");
|
|
793
|
-
hook = await runCompletedHook(String(event.event_data?.id ?? ""));
|
|
794
|
-
}
|
|
795
|
-
if (!decision.act) {
|
|
796
|
-
audit({
|
|
797
|
-
action: "webhook", actor: `todoist:${who}`, target: "aibroker",
|
|
798
|
-
outcome: hook.ran ? (hook.ok ? "archived" : "hook-failed") : "ignored",
|
|
799
|
-
reason: hook.ran
|
|
800
|
-
? (hook.ok ? `${decision.reason} — completion hook ran` : `${decision.reason} — completion hook FAILED: ${hook.detail}`)
|
|
801
|
-
: decision.reason,
|
|
802
|
-
meta: {
|
|
803
|
-
event: event.event_name,
|
|
804
|
-
task: event.event_data?.content ?? undefined,
|
|
805
|
-
nearMiss: decision.nearMiss,
|
|
806
|
-
},
|
|
807
|
-
});
|
|
808
|
-
return;
|
|
809
|
-
}
|
|
810
|
-
// Claim a trigger before dispatching, and release it if the dispatch
|
|
811
|
-
// does not land. Honouring another runner's claim is only half an
|
|
812
|
-
// interlock: a path that dispatches and leaves the task unclaimed lets
|
|
813
|
-
// the next poller see an advanced due date with nothing on it, conclude
|
|
814
|
-
// the box was ticked, and run the same sweep again.
|
|
815
|
-
const claiming = event.event_name === "item:completed" && isTrigger(event.event_data ?? {});
|
|
816
|
-
if (claiming) {
|
|
817
|
-
try {
|
|
818
|
-
const { setTaskLabel } = await import("./todoist-reply.js");
|
|
819
|
-
await setTaskLabel(decision.taskId, RUNNING_LABEL, true);
|
|
820
|
-
audit({
|
|
821
|
-
action: "todoist-claim", actor: "aibroker", target: `todoist:task:${decision.taskId}`,
|
|
822
|
-
outcome: "claimed",
|
|
823
|
-
});
|
|
824
|
-
// Remember when, so a claim nobody comes back for can be released.
|
|
825
|
-
const { recordClaim } = await import("./todoist-claims.js");
|
|
826
|
-
recordClaim(decision.taskId, undefined,
|
|
827
|
-
// Todoist has already advanced the due date by the time a completion
|
|
828
|
-
// reaches us, so this is the NEXT occurrence — the point past which
|
|
829
|
-
// a surviving claim would block the very trigger it guards.
|
|
830
|
-
event.event_data?.due?.date);
|
|
831
|
-
}
|
|
832
|
-
catch (err) {
|
|
833
|
-
// Audited, not just logged. A successful claim and a failed one used
|
|
834
|
-
// to be indistinguishable from outside — the label was simply absent,
|
|
835
|
-
// and the only record was a line in a log file nobody reads until
|
|
836
|
-
// something has already gone wrong downstream. The dispatch still
|
|
837
|
-
// proceeds: the claim is a hint, and refusing to run the sweep
|
|
838
|
-
// because a label write failed would be the worse trade.
|
|
839
|
-
const reason = err instanceof Error ? err.message : String(err);
|
|
840
|
-
audit({
|
|
841
|
-
action: "todoist-claim", actor: "aibroker", target: `todoist:task:${decision.taskId}`,
|
|
842
|
-
outcome: "failed",
|
|
843
|
-
reason: `${reason} — dispatching UNCLAIMED, a poller may read this tick as a fresh request`,
|
|
844
|
-
});
|
|
845
|
-
log(`todoist-webhook: could not claim ${decision.taskId} — ${reason}`);
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
try {
|
|
849
|
-
// The id rides along so the session can answer on the task it came
|
|
850
|
-
// from. Without it the reply has nowhere to go but a terminal the
|
|
851
|
-
// asker is not looking at.
|
|
852
|
-
// Warn when the title is not unique in its project. Two tasks with the
|
|
853
|
-
// same name are indistinguishable in a list, so an answer posted on one
|
|
854
|
-
// looks — to whoever is watching the other — exactly like being ignored.
|
|
855
|
-
// The session is told, so it can say which id it answered on.
|
|
856
|
-
let twins = 0;
|
|
857
|
-
if (!isComment && !claiming) {
|
|
858
|
-
try {
|
|
859
|
-
const { countTasksWithTitle } = await import("./todoist-reply.js");
|
|
860
|
-
twins = await countTasksWithTitle(String(event.event_data?.project_id ?? ""), String(event.event_data?.content ?? ""));
|
|
796
|
+
const result = await resolveParentWithRetry(event, kind, parentId);
|
|
797
|
+
if (!result.ok) {
|
|
798
|
+
if (result.transient) {
|
|
799
|
+
// A daemon restart mid-retry must not lose this either, so it goes
|
|
800
|
+
// to disk, not just to another in-process timer.
|
|
801
|
+
queuePendingEvent({ key, event, kind, parentId, lastError: result.reason });
|
|
802
|
+
audit({
|
|
803
|
+
action: "webhook", actor: "todoist", target: "aibroker", outcome: "queued",
|
|
804
|
+
reason: `${result.reason} — queued for retry`, meta: { event: event.event_name, taskId: parentId },
|
|
805
|
+
});
|
|
806
|
+
log(`todoist-webhook: ${result.reason} — queued for retry`);
|
|
861
807
|
}
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
// follow-up otherwise has to guess which project is "its own", and a
|
|
869
|
-
// guess from its alias creates a second project the user never sees.
|
|
870
|
-
const fromProject = String(event.event_data?.project_id ?? "");
|
|
871
|
-
const delivered = `${decision.body}${twinWarning}\n\n[todoist:${decision.taskId}${fromProject ? ` in:${fromProject}` : ""}]`;
|
|
872
|
-
const r = await deps.deliver(decision.project, delivered, isComment ? { prefix: "[Task:comment]" } : undefined);
|
|
873
|
-
// Remember who took it, so a later comment reaches the same session.
|
|
874
|
-
// Recorded on the way out and only on a real delivery: a task nobody
|
|
875
|
-
// accepted has no owner to inherit.
|
|
876
|
-
if (!isComment && r.outcome === "delivered")
|
|
877
|
-
rememberOwner(decision.taskId, decision.project);
|
|
878
|
-
// Release a claim the dispatch did not earn. "queued" keeps it — the
|
|
879
|
-
// work IS in flight, the session is simply mid-turn — but a trigger
|
|
880
|
-
// that never reached anyone must go back to being tickable, or the
|
|
881
|
-
// button is dead until someone removes the label by hand.
|
|
882
|
-
if (claiming && r.outcome !== "delivered" && r.outcome !== "queued" && r.outcome !== "spawned") {
|
|
883
|
-
try {
|
|
884
|
-
const { setTaskLabel } = await import("./todoist-reply.js");
|
|
885
|
-
await setTaskLabel(decision.taskId, RUNNING_LABEL, false);
|
|
886
|
-
const { forgetClaim } = await import("./todoist-claims.js");
|
|
887
|
-
forgetClaim(decision.taskId);
|
|
888
|
-
log(`todoist-webhook: released ${RUNNING_LABEL} on ${decision.taskId} — dispatch was ${r.outcome}`);
|
|
808
|
+
else {
|
|
809
|
+
audit({
|
|
810
|
+
action: "webhook", actor: "todoist", target: "aibroker",
|
|
811
|
+
outcome: "ignored", reason: result.reason, meta: { event: event.event_name, taskId: parentId },
|
|
812
|
+
});
|
|
813
|
+
log(`todoist-webhook: ${result.reason}`);
|
|
889
814
|
}
|
|
890
|
-
|
|
815
|
+
return;
|
|
891
816
|
}
|
|
892
|
-
audit({
|
|
893
|
-
action: "webhook", actor: `todoist:${who}`, target: r.session || decision.project,
|
|
894
|
-
outcome: r.outcome, body: decision.body, reason: r.reason,
|
|
895
|
-
meta: {
|
|
896
|
-
event: event.event_name, taskId: decision.taskId,
|
|
897
|
-
rule: decision.rule, nearMiss: decision.nearMiss,
|
|
898
|
-
duplicateTitles: twins > 1 ? twins : undefined,
|
|
899
|
-
},
|
|
900
|
-
});
|
|
901
|
-
}
|
|
902
|
-
catch (err) {
|
|
903
|
-
const reason = err instanceof Error ? err.message : String(err);
|
|
904
|
-
audit({
|
|
905
|
-
action: "webhook", actor: `todoist:${who}`, target: decision.project,
|
|
906
|
-
outcome: "failed", body: decision.body, reason,
|
|
907
|
-
meta: { event: event.event_name, taskId: decision.taskId },
|
|
908
|
-
});
|
|
909
|
-
log(`todoist-webhook: delivery failed — ${reason}`);
|
|
910
817
|
}
|
|
818
|
+
await processResolvedEvent(event, cfg, deps);
|
|
911
819
|
})();
|
|
912
820
|
});
|
|
913
821
|
}
|
|
822
|
+
/**
|
|
823
|
+
* Everything that happens once an event carries what routing needs — either
|
|
824
|
+
* because it always did (item:added, item:completed, project:deleted), or
|
|
825
|
+
* because `resolveParentWithRetry`/the pending-event sweep just resolved it.
|
|
826
|
+
* The one function both the live request path and the disk-queue retry call,
|
|
827
|
+
* so a fix here covers every event kind that reaches it, not just reminders.
|
|
828
|
+
*/
|
|
829
|
+
async function processResolvedEvent(event, cfg, deps) {
|
|
830
|
+
// A comment written on a MIRROR entry is meant for the real task.
|
|
831
|
+
//
|
|
832
|
+
// The mirror is where the reader's attention is, so replying there rather
|
|
833
|
+
// than on the source is the natural mistake — and Todoist has no way to
|
|
834
|
+
// deep-link an individual comment that would make the source easier to
|
|
835
|
+
// reach. Carry it across instead of losing it in a project nothing reads.
|
|
836
|
+
// Checked before routing: the mirror project is deliberately NOT an
|
|
837
|
+
// ingress project, so routing would refuse this and say nothing useful.
|
|
838
|
+
if (event.event_name === "note:added") {
|
|
839
|
+
const { mirrorProjectId, mirrorBack } = await import("./todoist-mirror.js");
|
|
840
|
+
const mirror = mirrorProjectId();
|
|
841
|
+
if (mirror && String(event.event_data?.project_id ?? "") === mirror) {
|
|
842
|
+
const r = await mirrorBack({
|
|
843
|
+
taskId: String(event.event_data?.id ?? ""),
|
|
844
|
+
text: String(event.event_data?.content ?? ""),
|
|
845
|
+
projectId: mirror,
|
|
846
|
+
});
|
|
847
|
+
audit({
|
|
848
|
+
action: "webhook", actor: "todoist", target: "aibroker",
|
|
849
|
+
outcome: r.carried ? "carried-back" : "ignored",
|
|
850
|
+
reason: r.reason ?? "comment on a mirror entry carried to its source task",
|
|
851
|
+
});
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
const isComment = event.event_name === "note:added";
|
|
856
|
+
const parentId = String(event.event_data?.id ?? "");
|
|
857
|
+
const { ownerOf, rememberOwner } = await import("./todoist-owners.js");
|
|
858
|
+
// Grants made since the daemon started take effect now, not at the next
|
|
859
|
+
// restart. A project created and granted while you are using the system
|
|
860
|
+
// has to work immediately, or the grant is indistinguishable from a
|
|
861
|
+
// project that routes nowhere.
|
|
862
|
+
const { applyGrants, expandThroughSubtree } = await import("./todoist-ingress.js");
|
|
863
|
+
let live = applyGrants(cfg);
|
|
864
|
+
// A sub-project is a folder, not a second owner. Before this, organising
|
|
865
|
+
// tasks into "Task Bus / Archive" moved them outside the
|
|
866
|
+
// allowlist and every one was refused — silently, and precisely when
|
|
867
|
+
// someone tidied up. Only ancestors granted WITH a subtree flag apply, so
|
|
868
|
+
// nothing becomes an ingress that nobody granted.
|
|
869
|
+
const eventProject = String(event.event_data?.project_id ?? "");
|
|
870
|
+
if (eventProject && !live.ingressProjectIds.has(eventProject)) {
|
|
871
|
+
try {
|
|
872
|
+
const { projectTree, ancestorsOf } = await import("./todoist-projects.js");
|
|
873
|
+
const tree = await projectTree();
|
|
874
|
+
live = expandThroughSubtree(live, eventProject, ancestorsOf(eventProject, tree), {
|
|
875
|
+
name: tree.get(eventProject)?.name,
|
|
876
|
+
known: await (deps.knownOwners?.() ?? []),
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
catch (err) {
|
|
880
|
+
log(`todoist-webhook: subtree lookup failed — ${err instanceof Error ? err.message : String(err)}`);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
const decision = route(event, live, await (deps.knownOwners?.() ?? []), isComment ? ownerOf(parentId) : undefined);
|
|
884
|
+
const who = event.initiator?.email ?? event.initiator?.full_name ?? "todoist";
|
|
885
|
+
if (decision.nearMiss) {
|
|
886
|
+
// Recorded whether or not the task was delivered: the point is that
|
|
887
|
+
// something was asked for and not honoured.
|
|
888
|
+
log(`todoist-webhook: near miss — ${decision.nearMiss}`);
|
|
889
|
+
}
|
|
890
|
+
// A completion has nothing to dispatch, but it is not nothing. The
|
|
891
|
+
// comment thread on a ticked task leaves every list at that moment, so a
|
|
892
|
+
// configured hook gets its chance before this is recorded as "ignored" —
|
|
893
|
+
// and its exit code decides what the record says.
|
|
894
|
+
let hook = { ran: false, ok: true };
|
|
895
|
+
if (event.event_name === "item:completed") {
|
|
896
|
+
const { runCompletedHook } = await import("./todoist-completed-hook.js");
|
|
897
|
+
hook = await runCompletedHook(String(event.event_data?.id ?? ""));
|
|
898
|
+
}
|
|
899
|
+
if (!decision.act) {
|
|
900
|
+
audit({
|
|
901
|
+
action: "webhook", actor: `todoist:${who}`, target: "aibroker",
|
|
902
|
+
outcome: hook.ran ? (hook.ok ? "archived" : "hook-failed") : "ignored",
|
|
903
|
+
reason: hook.ran
|
|
904
|
+
? (hook.ok ? `${decision.reason} — completion hook ran` : `${decision.reason} — completion hook FAILED: ${hook.detail}`)
|
|
905
|
+
: decision.reason,
|
|
906
|
+
meta: {
|
|
907
|
+
event: event.event_name,
|
|
908
|
+
task: event.event_data?.content ?? undefined,
|
|
909
|
+
nearMiss: decision.nearMiss,
|
|
910
|
+
},
|
|
911
|
+
});
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
// Claim a trigger before dispatching, and release it if the dispatch
|
|
915
|
+
// does not land. Honouring another runner's claim is only half an
|
|
916
|
+
// interlock: a path that dispatches and leaves the task unclaimed lets
|
|
917
|
+
// the next poller see an advanced due date with nothing on it, conclude
|
|
918
|
+
// the box was ticked, and run the same sweep again.
|
|
919
|
+
//
|
|
920
|
+
// A `reminder:fired` dispatch must claim too (incident 6hccprFMww4pX3M9,
|
|
921
|
+
// 24./25.09): the session it reaches finishes with `pai task done`, and that
|
|
922
|
+
// completion arriving on an UNCLAIMED recurring trigger is indistinguishable
|
|
923
|
+
// from a human tick — route() dispatched the same sweep a second time. With
|
|
924
|
+
// the claim set, route() sees pai-running and ignores the run's own
|
|
925
|
+
// completion, exactly as it already does for the poller path.
|
|
926
|
+
const claiming = (event.event_name === "item:completed" || event.event_name === "reminder:fired")
|
|
927
|
+
&& isTrigger(event.event_data ?? {});
|
|
928
|
+
if (claiming) {
|
|
929
|
+
try {
|
|
930
|
+
const { setTaskLabel } = await import("./todoist-reply.js");
|
|
931
|
+
await setTaskLabel(decision.taskId, RUNNING_LABEL, true);
|
|
932
|
+
audit({
|
|
933
|
+
action: "todoist-claim", actor: "aibroker", target: `todoist:task:${decision.taskId}`,
|
|
934
|
+
outcome: "claimed",
|
|
935
|
+
});
|
|
936
|
+
// Remember when, so a claim nobody comes back for can be released.
|
|
937
|
+
const { recordClaim } = await import("./todoist-claims.js");
|
|
938
|
+
recordClaim(decision.taskId, undefined,
|
|
939
|
+
// Todoist has already advanced the due date by the time a completion
|
|
940
|
+
// reaches us, so this is the NEXT occurrence — the point past which
|
|
941
|
+
// a surviving claim would block the very trigger it guards. A
|
|
942
|
+
// reminder's due is the occurrence being run NOW; claimDeadline's
|
|
943
|
+
// two-hour floor is what keeps that from expiring the claim early.
|
|
944
|
+
event.event_data?.due?.date);
|
|
945
|
+
}
|
|
946
|
+
catch (err) {
|
|
947
|
+
// Audited, not just logged. A successful claim and a failed one used
|
|
948
|
+
// to be indistinguishable from outside — the label was simply absent,
|
|
949
|
+
// and the only record was a line in a log file nobody reads until
|
|
950
|
+
// something has already gone wrong downstream. The dispatch still
|
|
951
|
+
// proceeds: the claim is a hint, and refusing to run the sweep
|
|
952
|
+
// because a label write failed would be the worse trade.
|
|
953
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
954
|
+
audit({
|
|
955
|
+
action: "todoist-claim", actor: "aibroker", target: `todoist:task:${decision.taskId}`,
|
|
956
|
+
outcome: "failed",
|
|
957
|
+
reason: `${reason} — dispatching UNCLAIMED, a poller may read this tick as a fresh request`,
|
|
958
|
+
});
|
|
959
|
+
log(`todoist-webhook: could not claim ${decision.taskId} — ${reason}`);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
try {
|
|
963
|
+
// The id rides along so the session can answer on the task it came
|
|
964
|
+
// from. Without it the reply has nowhere to go but a terminal the
|
|
965
|
+
// asker is not looking at.
|
|
966
|
+
// Warn when the title is not unique in its project. Two tasks with the
|
|
967
|
+
// same name are indistinguishable in a list, so an answer posted on one
|
|
968
|
+
// looks — to whoever is watching the other — exactly like being ignored.
|
|
969
|
+
// The session is told, so it can say which id it answered on.
|
|
970
|
+
let twins = 0;
|
|
971
|
+
if (!isComment && !claiming) {
|
|
972
|
+
try {
|
|
973
|
+
const { countTasksWithTitle } = await import("./todoist-reply.js");
|
|
974
|
+
twins = await countTasksWithTitle(String(event.event_data?.project_id ?? ""), String(event.event_data?.content ?? ""));
|
|
975
|
+
}
|
|
976
|
+
catch { /* a lookup failure must not block a delivery */ }
|
|
977
|
+
}
|
|
978
|
+
const twinWarning = twins > 1
|
|
979
|
+
? `\n\n[note: ${twins} open tasks in this project share this title — say which id you answered on]`
|
|
980
|
+
: "";
|
|
981
|
+
// The project rides along with the task id. A session asked to file a
|
|
982
|
+
// follow-up otherwise has to guess which project is "its own", and a
|
|
983
|
+
// guess from its alias creates a second project the user never sees.
|
|
984
|
+
const fromProject = String(event.event_data?.project_id ?? "");
|
|
985
|
+
const delivered = `${decision.body}${twinWarning}\n\n[todoist:${decision.taskId}${fromProject ? ` in:${fromProject}` : ""}]`;
|
|
986
|
+
const r = await deps.deliver(decision.project, delivered, isComment ? { prefix: "[Task:comment]" } : undefined);
|
|
987
|
+
// Remember who took it, so a later comment reaches the same session.
|
|
988
|
+
// Recorded on the way out and only on a real delivery: a task nobody
|
|
989
|
+
// accepted has no owner to inherit.
|
|
990
|
+
if (!isComment && r.outcome === "delivered")
|
|
991
|
+
rememberOwner(decision.taskId, decision.project);
|
|
992
|
+
// Release a claim the dispatch did not earn. "queued" keeps it — the
|
|
993
|
+
// work IS in flight, the session is simply mid-turn — but a trigger
|
|
994
|
+
// that never reached anyone must go back to being tickable, or the
|
|
995
|
+
// button is dead until someone removes the label by hand.
|
|
996
|
+
if (claiming && r.outcome !== "delivered" && r.outcome !== "queued" && r.outcome !== "spawned") {
|
|
997
|
+
try {
|
|
998
|
+
const { setTaskLabel } = await import("./todoist-reply.js");
|
|
999
|
+
await setTaskLabel(decision.taskId, RUNNING_LABEL, false);
|
|
1000
|
+
const { forgetClaim } = await import("./todoist-claims.js");
|
|
1001
|
+
forgetClaim(decision.taskId);
|
|
1002
|
+
log(`todoist-webhook: released ${RUNNING_LABEL} on ${decision.taskId} — dispatch was ${r.outcome}`);
|
|
1003
|
+
}
|
|
1004
|
+
catch { /* the claim is a hint, not a lock; a stuck one is visible */ }
|
|
1005
|
+
}
|
|
1006
|
+
audit({
|
|
1007
|
+
action: "webhook", actor: `todoist:${who}`, target: r.session || decision.project,
|
|
1008
|
+
outcome: r.outcome, body: decision.body, reason: r.reason,
|
|
1009
|
+
meta: {
|
|
1010
|
+
event: event.event_name, taskId: decision.taskId,
|
|
1011
|
+
rule: decision.rule, nearMiss: decision.nearMiss,
|
|
1012
|
+
duplicateTitles: twins > 1 ? twins : undefined,
|
|
1013
|
+
},
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
catch (err) {
|
|
1017
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1018
|
+
audit({
|
|
1019
|
+
action: "webhook", actor: `todoist:${who}`, target: decision.project,
|
|
1020
|
+
outcome: "failed", body: decision.body, reason,
|
|
1021
|
+
meta: { event: event.event_name, taskId: decision.taskId },
|
|
1022
|
+
});
|
|
1023
|
+
log(`todoist-webhook: delivery failed — ${reason}`);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
914
1026
|
/** Read config from the environment. Returns null when not configured. */
|
|
915
1027
|
export function webhookConfigFromEnv() {
|
|
916
1028
|
const secret = process.env.TODOIST_CLIENT_SECRET;
|
|
@@ -987,6 +1099,17 @@ export function startTodoistWebhook(deps) {
|
|
|
987
1099
|
const port = Number(process.env.AIBROKER_A2A_PORT ?? process.env.TODOIST_WEBHOOK_PORT) || 8766;
|
|
988
1100
|
const bind = process.env.TODOIST_WEBHOOK_BIND ?? "127.0.0.1";
|
|
989
1101
|
const server = createWebhookServer(cfg, deps);
|
|
1102
|
+
// Retries what a live request could not: shortly after start (a daemon
|
|
1103
|
+
// restart must not strand what was already queued) and on every tick after
|
|
1104
|
+
// that (an outage longer than the inline backoff in resolveParentWithRetry).
|
|
1105
|
+
if (cfg) {
|
|
1106
|
+
const liveCfg = cfg;
|
|
1107
|
+
const sweep = () => {
|
|
1108
|
+
void sweepPendingEvents(liveCfg, deps).catch((e) => log(`todoist-webhook: pending-event sweep failed — ${e instanceof Error ? e.message : String(e)}`));
|
|
1109
|
+
};
|
|
1110
|
+
setTimeout(sweep, 20_000).unref();
|
|
1111
|
+
setInterval(sweep, 60_000).unref();
|
|
1112
|
+
}
|
|
990
1113
|
if (cfg && bind !== "127.0.0.1" && bind !== "localhost") {
|
|
991
1114
|
log(`todoist-webhook: WARNING binding ${bind}, not loopback — this puts an execution ingress ` +
|
|
992
1115
|
`directly on the network. Prefer loopback with a TLS proxy (Tailscale Funnel, Cloudflare Tunnel, Caddy) in front.`);
|