@pellux/goodvibes-daemon 1.28.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/CHANGELOG.md +383 -0
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/bin/goodvibes-daemon +100 -0
- package/bin/launcher-support.js +226 -0
- package/package.json +96 -0
- package/scripts/check-bun.sh +20 -0
- package/scripts/postinstall.js +244 -0
- package/src/cli/command-catalog.ts +828 -0
- package/src/cli/completion.ts +299 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +21 -0
- package/src/cli/parser.ts +55 -0
- package/src/cli/surface-catalog.ts +26 -0
- package/src/cli/types.ts +63 -0
- package/src/cluster/daemon-ws-call.ts +235 -0
- package/src/cluster/raw-reply-route.ts +111 -0
- package/src/config/checkpoint-settings.ts +113 -0
- package/src/config/run-daemon-config-migration.ts +47 -0
- package/src/config/secret-config.ts +175 -0
- package/src/config/secrets.ts +71 -0
- package/src/config/surface.ts +24 -0
- package/src/core/pairing-banner.ts +82 -0
- package/src/daemon/cli.ts +878 -0
- package/src/daemon/config-command.ts +281 -0
- package/src/daemon/handlers/context.ts +29 -0
- package/src/daemon/handlers/contracts.ts +43 -0
- package/src/daemon/handlers/credentials.ts +139 -0
- package/src/daemon/handlers/drafts/draft-store.ts +427 -0
- package/src/daemon/handlers/drafts/index.ts +17 -0
- package/src/daemon/handlers/drafts/register.ts +331 -0
- package/src/daemon/handlers/errors.ts +18 -0
- package/src/daemon/handlers/inbox/aggregator.ts +375 -0
- package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
- package/src/daemon/handlers/inbox/index.ts +221 -0
- package/src/daemon/handlers/inbox/mapping.ts +192 -0
- package/src/daemon/handlers/inbox/poller.ts +239 -0
- package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
- package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
- package/src/daemon/handlers/inbox/providers/email.ts +176 -0
- package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
- package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
- package/src/daemon/handlers/index.ts +117 -0
- package/src/daemon/handlers/register.ts +180 -0
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -0
- package/src/daemon/handlers/remote/backends/docker.ts +79 -0
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
- package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
- package/src/daemon/handlers/remote/backends/ssh.ts +126 -0
- package/src/daemon/handlers/remote/backends/types.ts +97 -0
- package/src/daemon/handlers/remote/dispatcher.ts +181 -0
- package/src/daemon/handlers/remote/index.ts +120 -0
- package/src/daemon/handlers/remote/peer-registry.ts +357 -0
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/handlers/routing/route-store.ts +319 -0
- package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
- package/src/daemon/handlers/sqlite-store.ts +303 -0
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +213 -0
- package/src/daemon/handlers/triage/pipeline.ts +274 -0
- package/src/daemon/handlers/triage/scorer.ts +287 -0
- package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
- package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
- package/src/daemon/handlers/triage/tagger/index.ts +184 -0
- package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
- package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
- package/src/daemon/handlers/triage/types.ts +50 -0
- package/src/daemon/lifecycle.ts +41 -0
- package/src/daemon/local-daemon-state.ts +233 -0
- package/src/daemon/pair-command.ts +301 -0
- package/src/daemon/provision-wake-model.ts +81 -0
- package/src/daemon/send/channels.ts +200 -0
- package/src/daemon/send/command.ts +333 -0
- package/src/daemon/send/composition.ts +100 -0
- package/src/daemon/send/failure-text.ts +93 -0
- package/src/daemon/send/inert-text.ts +225 -0
- package/src/daemon/send/stdin.ts +24 -0
- package/src/daemon/service-commands.ts +530 -0
- package/src/daemon/sessions-command.ts +209 -0
- package/src/daemon/status-command.ts +481 -0
- package/src/daemon/webui-command.ts +339 -0
- package/src/runtime/boot-tasks.ts +110 -0
- package/src/runtime/cluster-composition.ts +124 -0
- package/src/runtime/cluster-group-composition.ts +284 -0
- package/src/runtime/conversation-rewind-port.ts +171 -0
- package/src/runtime/credential-composition.ts +54 -0
- package/src/runtime/daemon-handler-composition.ts +76 -0
- package/src/runtime/device-posture-composition.ts +115 -0
- package/src/runtime/disposal-wiring.ts +101 -0
- package/src/runtime/fleet-needs-input-push.ts +61 -0
- package/src/runtime/fleet-services.ts +41 -0
- package/src/runtime/hosted-session-composition.ts +128 -0
- package/src/runtime/index.ts +100 -0
- package/src/runtime/knowledge-services.ts +101 -0
- package/src/runtime/legacy-daemon-migration.ts +605 -0
- package/src/runtime/legacy-daemon-reconcile.ts +448 -0
- package/src/runtime/mail-composition.ts +65 -0
- package/src/runtime/notification-dispatch.ts +86 -0
- package/src/runtime/plugin-composition.ts +111 -0
- package/src/runtime/runtime-services-types.ts +268 -0
- package/src/runtime/services.ts +756 -0
- package/src/runtime/trigger-services.ts +62 -0
- package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
- package/src/runtime/trust/trust-gated-approvals.ts +169 -0
- package/src/runtime/update-check.ts +61 -0
- package/src/runtime/workspace-checkpointing.ts +116 -0
- package/src/testing/daemon-fixture.ts +276 -0
- package/src/testing/hosted-session-failures.ts +92 -0
- package/src/version.ts +26 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Daemon-internal triage TAGGER (composition).
|
|
3
|
+
//
|
|
4
|
+
// Applies user-defined triage labels back on the provider side:
|
|
5
|
+
// - IMAP : STORE a keyword flag on the message (IMAP4rev1 over TLS).
|
|
6
|
+
// - Slack: reactions.add emoji on the source message.
|
|
7
|
+
// - Discord: real forum thread tags (PATCH applied_tags, merge) when a
|
|
8
|
+
// forum-tag mapping is configured, else a unicode reaction analog.
|
|
9
|
+
//
|
|
10
|
+
// Hard rules honored here:
|
|
11
|
+
// - All provider credentials come ONLY from the daemon credential store.
|
|
12
|
+
// They are never returned in results and never logged.
|
|
13
|
+
// - The whole tagger is gated behind a config flag (surfaces.triage.autoTag);
|
|
14
|
+
// when disabled, applyTags() is a no-op that reports skipped:true.
|
|
15
|
+
// - Provider-side writes are EFFECTFUL; callers must pass an explicitly
|
|
16
|
+
// confirmed request (confirm === true && explicitUserRequest === true).
|
|
17
|
+
// Unconfirmed calls throw HandlerError(REQUIRE_CONFIRM).
|
|
18
|
+
//
|
|
19
|
+
// Contract-fidelity note: the triage surface (`inbox.triage.*`) is daemon-
|
|
20
|
+
// internal and NOT a published operator method, so there is no external
|
|
21
|
+
// request/response schema to certify these tag shapes against. What is
|
|
22
|
+
// guaranteed is the provider-side behavior: no silent data loss (Discord thread
|
|
23
|
+
// tags are merged, never blindly overwritten) and no command injection (IMAP
|
|
24
|
+
// quoting rejects control characters).
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
import type { HandlerContext } from '../../context.ts';
|
|
28
|
+
import type { DaemonCredentialStore } from '../../credentials.ts';
|
|
29
|
+
import { HandlerError, REQUIRE_CONFIRM } from '../../errors.ts';
|
|
30
|
+
import { labelToTag } from '../scorer.ts';
|
|
31
|
+
import { applyImap, imapStoreFlagOverTls, makeRetryingImapStoreFlag } from './imap.ts';
|
|
32
|
+
import type { ImapRetryOptions, ImapStoreFlag } from './imap.ts';
|
|
33
|
+
import { applySlack } from './slack.ts';
|
|
34
|
+
import { applyDiscord } from './discord.ts';
|
|
35
|
+
import type { ApplyTagsRequest, ApplyTagsResult, TaggerProviderConfig } from './shared.ts';
|
|
36
|
+
|
|
37
|
+
export type {
|
|
38
|
+
ApplyTagsRequest,
|
|
39
|
+
ApplyTagsResult,
|
|
40
|
+
TaggerProviderConfig,
|
|
41
|
+
} from './shared.ts';
|
|
42
|
+
export type { ImapRetryOptions, ImapStoreArgs, ImapStoreFlag } from './imap.ts';
|
|
43
|
+
|
|
44
|
+
export const TRIAGE_AUTOTAG_FLAG = 'surfaces.triage.autoTag';
|
|
45
|
+
|
|
46
|
+
export interface TriageTaggerOptions {
|
|
47
|
+
credentials?: DaemonCredentialStore;
|
|
48
|
+
/** Override the autotag flag lookup (used in tests). */
|
|
49
|
+
autoTagEnabled?: boolean;
|
|
50
|
+
/** Per-surface provider config; usually derived from configManager. */
|
|
51
|
+
providers?: TaggerProviderConfig;
|
|
52
|
+
/** Injectable fetch (Slack/Discord HTTP). Defaults to global fetch. */
|
|
53
|
+
fetchImpl?: typeof fetch;
|
|
54
|
+
/** Injectable IMAP flag-setter (used in tests to avoid a live socket). */
|
|
55
|
+
imapStoreFlag?: ImapStoreFlag;
|
|
56
|
+
/**
|
|
57
|
+
* Transient-failure retry policy for the default IMAP store implementation.
|
|
58
|
+
* Ignored when imapStoreFlag is injected and succeeds first-try.
|
|
59
|
+
*/
|
|
60
|
+
imapRetry?: ImapRetryOptions;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface TriageTagger {
|
|
64
|
+
/** Whether provider-side tagging is currently enabled. */
|
|
65
|
+
enabled(): boolean;
|
|
66
|
+
applyTags(request: ApplyTagsRequest): Promise<ApplyTagsResult>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readBoolFlag(
|
|
70
|
+
configManager: HandlerContext['configManager'],
|
|
71
|
+
key: string,
|
|
72
|
+
): boolean {
|
|
73
|
+
try {
|
|
74
|
+
const value = configManager.get(key as never) as unknown;
|
|
75
|
+
return value === true || value === 'true' || value === 1;
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function safeGet(configManager: HandlerContext['configManager'], key: string): unknown {
|
|
82
|
+
try {
|
|
83
|
+
return configManager.get(key as never) as unknown;
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolveProvidersFromConfig(
|
|
90
|
+
configManager: HandlerContext['configManager'],
|
|
91
|
+
): TaggerProviderConfig {
|
|
92
|
+
const out: TaggerProviderConfig = {};
|
|
93
|
+
const slackToken = safeGet(configManager, 'surfaces.slack.botToken');
|
|
94
|
+
if (typeof slackToken === 'string' && slackToken.length > 0) {
|
|
95
|
+
out.slack = { tokenConfigKey: 'surfaces.slack.botToken' };
|
|
96
|
+
}
|
|
97
|
+
const discordToken = safeGet(configManager, 'surfaces.discord.botToken');
|
|
98
|
+
if (typeof discordToken === 'string' && discordToken.length > 0) {
|
|
99
|
+
out.discord = { tokenConfigKey: 'surfaces.discord.botToken' };
|
|
100
|
+
}
|
|
101
|
+
const imapHost = safeGet(configManager, 'surfaces.email.imap.host');
|
|
102
|
+
const imapUser = safeGet(configManager, 'surfaces.email.imap.user');
|
|
103
|
+
if (typeof imapHost === 'string' && imapHost.length > 0 && typeof imapUser === 'string') {
|
|
104
|
+
const portRaw = safeGet(configManager, 'surfaces.email.imap.port');
|
|
105
|
+
const mailbox = safeGet(configManager, 'surfaces.email.imap.mailbox');
|
|
106
|
+
out.imap = {
|
|
107
|
+
host: imapHost,
|
|
108
|
+
port: typeof portRaw === 'number' ? portRaw : 993,
|
|
109
|
+
user: imapUser,
|
|
110
|
+
passwordConfigKey: 'surfaces.email.imap.password',
|
|
111
|
+
mailbox: typeof mailbox === 'string' && mailbox.length > 0 ? mailbox : 'INBOX',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function resolveTags(request: ApplyTagsRequest): string[] {
|
|
118
|
+
if (request.tags && request.tags.length > 0) {
|
|
119
|
+
return [...new Set(request.tags.map((t) => t.trim()).filter((t) => t.length > 0))];
|
|
120
|
+
}
|
|
121
|
+
if (request.label) return [labelToTag(request.label)];
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Create the triage tagger. Reads the autotag flag and provider config from the
|
|
127
|
+
* handler context; credentials are resolved lazily, per apply, from the daemon
|
|
128
|
+
* credential store.
|
|
129
|
+
*/
|
|
130
|
+
export function createTriageTagger(
|
|
131
|
+
ctx: HandlerContext,
|
|
132
|
+
options: TriageTaggerOptions = {},
|
|
133
|
+
): TriageTagger {
|
|
134
|
+
const credentials = options.credentials ?? ctx.credentials;
|
|
135
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
136
|
+
const providers = options.providers ?? resolveProvidersFromConfig(ctx.configManager);
|
|
137
|
+
// Retry wraps whichever store impl is in use (default TLS client OR an
|
|
138
|
+
// injected one), so transient failures are retried uniformly.
|
|
139
|
+
const imapStoreFlag = makeRetryingImapStoreFlag(
|
|
140
|
+
options.imapStoreFlag ?? imapStoreFlagOverTls,
|
|
141
|
+
options.imapRetry,
|
|
142
|
+
);
|
|
143
|
+
const enabled = (): boolean =>
|
|
144
|
+
options.autoTagEnabled ?? readBoolFlag(ctx.configManager, TRIAGE_AUTOTAG_FLAG);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
enabled,
|
|
148
|
+
async applyTags(request: ApplyTagsRequest): Promise<ApplyTagsResult> {
|
|
149
|
+
const { item } = request;
|
|
150
|
+
const tags = resolveTags(request);
|
|
151
|
+
const base: ApplyTagsResult = {
|
|
152
|
+
surface: item.surface,
|
|
153
|
+
itemId: item.id,
|
|
154
|
+
appliedTags: [],
|
|
155
|
+
skipped: true,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
if (!enabled()) {
|
|
159
|
+
return { ...base, reason: 'autotag-disabled' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Provider-side mutation is effectful — require explicit confirmation.
|
|
163
|
+
if (request.confirm !== true || request.explicitUserRequest !== true) {
|
|
164
|
+
throw new HandlerError(
|
|
165
|
+
'Provider-side triage tagging requires explicit user confirmation.',
|
|
166
|
+
REQUIRE_CONFIRM,
|
|
167
|
+
403,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
switch (item.surface) {
|
|
172
|
+
case 'email':
|
|
173
|
+
case 'imap':
|
|
174
|
+
return applyImap(item, tags, providers, credentials, imapStoreFlag, base);
|
|
175
|
+
case 'slack':
|
|
176
|
+
return applySlack(item, tags, providers, credentials, fetchImpl, ctx, base);
|
|
177
|
+
case 'discord':
|
|
178
|
+
return applyDiscord(item, tags, providers, credentials, fetchImpl, ctx, base);
|
|
179
|
+
default:
|
|
180
|
+
return { ...base, reason: `unsupported-surface:${item.surface}` };
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Triage tagger — shared types and provider-agnostic helpers.
|
|
3
|
+
//
|
|
4
|
+
// Provider config shapes, the apply request/result contract, and the tag
|
|
5
|
+
// normalization helpers used by the IMAP/Slack/Discord modules. No I/O here.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
import type { InboundChannelItem, TriageLabel } from '../types.ts';
|
|
9
|
+
|
|
10
|
+
export interface TaggerProviderConfig {
|
|
11
|
+
/** IMAP host:port (default port 993, TLS). Credentials resolved separately. */
|
|
12
|
+
imap?: { host: string; port?: number; user: string; passwordConfigKey: string; mailbox?: string };
|
|
13
|
+
/** Slack bot token config key (resolved from credential store). */
|
|
14
|
+
slack?: { tokenConfigKey: string };
|
|
15
|
+
/**
|
|
16
|
+
* Discord bot token config key (resolved from credential store), plus an
|
|
17
|
+
* optional forum-tag mapping. When `forumTagIds` maps a GoodVibes triage tag
|
|
18
|
+
* (e.g. 'GoodVibes/Spam') to a forum tag SNOWFLAKE id, items that target a
|
|
19
|
+
* forum/media-channel thread get that REAL thread tag applied (PATCH
|
|
20
|
+
* applied_tags) — exact fidelity to the contract's "Discord thread tags".
|
|
21
|
+
* Without a mapping (or for non-thread messages) tagging degrades to a
|
|
22
|
+
* unicode reaction analog.
|
|
23
|
+
*/
|
|
24
|
+
discord?: { tokenConfigKey: string; forumTagIds?: Record<string, string> };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ApplyTagsRequest {
|
|
28
|
+
item: InboundChannelItem;
|
|
29
|
+
/** Provider-side tags to apply. Defaults to [labelToTag(label)] when omitted. */
|
|
30
|
+
tags?: readonly string[];
|
|
31
|
+
label?: TriageLabel;
|
|
32
|
+
/** Must be true — provider-side mutation requires explicit confirmation. */
|
|
33
|
+
confirm?: boolean;
|
|
34
|
+
/** Mirror of the operator invocation context flag. */
|
|
35
|
+
explicitUserRequest?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ApplyTagsResult {
|
|
39
|
+
surface: string;
|
|
40
|
+
itemId: string;
|
|
41
|
+
appliedTags: string[];
|
|
42
|
+
/** True when the autotag flag is disabled or no provider matched. */
|
|
43
|
+
skipped: boolean;
|
|
44
|
+
reason?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** IMAP keywords cannot contain spaces or '/'; normalize the canonical tag. */
|
|
48
|
+
export function imapKeywordForTag(tag: string): string {
|
|
49
|
+
return tag.replace(/[^A-Za-z0-9_]+/g, '_');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function slackEmojiForTag(tag: string): string {
|
|
53
|
+
const lower = tag.toLowerCase();
|
|
54
|
+
if (lower.includes('spam')) return 'no_entry_sign';
|
|
55
|
+
if (lower.includes('priority')) return 'rotating_light';
|
|
56
|
+
return 'inbox_tray';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function discordEmojiForTag(tag: string): string {
|
|
60
|
+
const lower = tag.toLowerCase();
|
|
61
|
+
if (lower.includes('spam')) return '\u{1F6AB}';
|
|
62
|
+
if (lower.includes('priority')) return '\u{1F6A8}';
|
|
63
|
+
return '\u{1F4E5}';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Read a non-empty string from the item's opaque metadata bag. */
|
|
67
|
+
export function stringMeta(item: InboundChannelItem, key: string): string | undefined {
|
|
68
|
+
const value = item.metadata?.[key];
|
|
69
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
70
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Triage tagger — Slack provider.
|
|
3
|
+
//
|
|
4
|
+
// Applies a triage label as a Slack message reaction (reactions.add). The bot
|
|
5
|
+
// token is resolved per-apply from the daemon credential store and is never
|
|
6
|
+
// logged or returned. `already_reacted` is treated as idempotent success.
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
import type { HandlerContext } from '../../context.ts';
|
|
10
|
+
import type { DaemonCredentialStore } from '../../credentials.ts';
|
|
11
|
+
import { HandlerError } from '../../errors.ts';
|
|
12
|
+
import type { InboundChannelItem } from '../types.ts';
|
|
13
|
+
import type { ApplyTagsResult, TaggerProviderConfig } from './shared.ts';
|
|
14
|
+
import { slackEmojiForTag, stringMeta } from './shared.ts';
|
|
15
|
+
|
|
16
|
+
export async function applySlack(
|
|
17
|
+
item: InboundChannelItem,
|
|
18
|
+
tags: string[],
|
|
19
|
+
providers: TaggerProviderConfig,
|
|
20
|
+
credentials: DaemonCredentialStore,
|
|
21
|
+
fetchImpl: typeof fetch,
|
|
22
|
+
ctx: HandlerContext,
|
|
23
|
+
base: ApplyTagsResult,
|
|
24
|
+
): Promise<ApplyTagsResult> {
|
|
25
|
+
const cfg = providers.slack;
|
|
26
|
+
if (!cfg) return { ...base, reason: 'slack-not-configured' };
|
|
27
|
+
const channel = stringMeta(item, 'channelId') ?? item.conversationId;
|
|
28
|
+
const ts = stringMeta(item, 'ts') ?? stringMeta(item, 'messageTs');
|
|
29
|
+
if (!channel || !ts) return { ...base, reason: 'slack-missing-target' };
|
|
30
|
+
if (tags.length === 0) return { ...base, reason: 'no-tags' };
|
|
31
|
+
|
|
32
|
+
const token = await credentials.resolveConfigSecret(cfg.tokenConfigKey);
|
|
33
|
+
if (!token) return { ...base, reason: 'slack-no-credentials' };
|
|
34
|
+
|
|
35
|
+
const applied: string[] = [];
|
|
36
|
+
for (const tag of tags) {
|
|
37
|
+
const emoji = slackEmojiForTag(tag);
|
|
38
|
+
const response = await fetchImpl('https://slack.com/api/reactions.add', {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: {
|
|
41
|
+
Authorization: `Bearer ${token}`,
|
|
42
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
43
|
+
},
|
|
44
|
+
body: JSON.stringify({ channel, timestamp: ts, name: emoji }),
|
|
45
|
+
});
|
|
46
|
+
const payload = (await response.json().catch(() => ({}))) as {
|
|
47
|
+
ok?: boolean;
|
|
48
|
+
error?: string;
|
|
49
|
+
};
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
throw new HandlerError(
|
|
52
|
+
`Slack reactions.add HTTP ${response.status}`,
|
|
53
|
+
'TRIAGE_SLACK_TAG_FAILED',
|
|
54
|
+
502,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
// 'already_reacted' is an idempotent success for our purposes.
|
|
58
|
+
if (payload.ok !== true && payload.error !== 'already_reacted') {
|
|
59
|
+
ctx.logger.warn('triage: slack reaction rejected', { error: payload.error });
|
|
60
|
+
throw new HandlerError(
|
|
61
|
+
`Slack reactions.add rejected: ${payload.error ?? 'unknown'}`,
|
|
62
|
+
'TRIAGE_SLACK_TAG_FAILED',
|
|
63
|
+
502,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
applied.push(tag);
|
|
67
|
+
}
|
|
68
|
+
return { surface: item.surface, itemId: item.id, appliedTags: applied, skipped: false };
|
|
69
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Daemon-internal triage domain types.
|
|
3
|
+
//
|
|
4
|
+
// `InboundChannelItem` is the daemon-internal shape the inbox poller produces
|
|
5
|
+
// and the triage pipeline scores. It is NOT an SDK catalog contract: the
|
|
6
|
+
// published `channels.inbox.list` output schema (CHANNEL_INBOX_ITEM_SCHEMA) is
|
|
7
|
+
// owned by the SDK and never re-declared here. This is the internal poller
|
|
8
|
+
// item per the handoff doc — it carries `fromDigest` (never a raw sender id),
|
|
9
|
+
// `subjectPreview`/`bodyPreview` (PII-stripped, length-bounded) and an opaque
|
|
10
|
+
// `metadata` bag the tagger reads provider targeting from (imapUid, channelId,
|
|
11
|
+
// Slack ts, Discord messageId/threadId).
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/** Triage label assigned by the scorer. */
|
|
15
|
+
export type TriageLabel = 'spam' | 'priority' | 'normal';
|
|
16
|
+
|
|
17
|
+
/** 1:1 vs group/channel/thread conversation hint (priority signal). */
|
|
18
|
+
export type ConversationKind = 'direct' | 'group' | 'channel' | 'thread' | 'service';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Internal inbound feed item. Mirrors the handoff `InboundChannelItem` shape
|
|
22
|
+
* plus the optional fields the scorer/tagger consult. `surface` is the provider
|
|
23
|
+
* family ('email' | 'imap' | 'slack' | 'discord' | ...) the tagger dispatches
|
|
24
|
+
* on; `provider` is the handoff-facing provider id. They are usually equal.
|
|
25
|
+
*/
|
|
26
|
+
export interface InboundChannelItem {
|
|
27
|
+
/** Stable, provider-scoped dedup key. */
|
|
28
|
+
readonly id: string;
|
|
29
|
+
/** Provider family the tagger dispatches on (email/imap/slack/discord/...). */
|
|
30
|
+
readonly surface: string;
|
|
31
|
+
/** Handoff-facing provider id ("slack" | "discord" | "email" | ...). */
|
|
32
|
+
readonly provider?: string;
|
|
33
|
+
readonly kind?: 'dm' | 'thread' | 'mention' | 'reaction';
|
|
34
|
+
/** SHA-256 first-N of sender external id — NEVER a raw identifier. */
|
|
35
|
+
readonly fromDigest?: string;
|
|
36
|
+
/** Conversation id (Slack/Discord channel, IMAP mailbox-scoped). */
|
|
37
|
+
readonly conversationId?: string;
|
|
38
|
+
readonly conversationKind?: ConversationKind;
|
|
39
|
+
/** Display subject (<= 200 chars). */
|
|
40
|
+
readonly subject?: string;
|
|
41
|
+
/** Display body preview (<= 500 chars, PII-stripped). Alias: bodyPreview. */
|
|
42
|
+
readonly snippet?: string;
|
|
43
|
+
/** Optional daemon route binding id. */
|
|
44
|
+
readonly routeId?: string;
|
|
45
|
+
/** Unix ms. */
|
|
46
|
+
readonly receivedAt?: number;
|
|
47
|
+
readonly unread?: boolean;
|
|
48
|
+
/** Opaque provider targeting bag (imapUid/uid, channelId, ts, messageId, threadId). */
|
|
49
|
+
readonly metadata?: Record<string, unknown>;
|
|
50
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lifecycle.ts — resolve THIS binary's update-artifact identity for the SDK
|
|
3
|
+
* DaemonServer facade's auto-update lifecycle.
|
|
4
|
+
*
|
|
5
|
+
* The SDK facade now runs the entire self-update loop itself when handed a
|
|
6
|
+
* DaemonUpdateArtifact ({version, execPath}): it compares the HOST binary's
|
|
7
|
+
* version against the release tags (no longer the version-blind sdk-package
|
|
8
|
+
* comparison that used to restart-loop), swaps only at an idle moment, keeps
|
|
9
|
+
* the outgoing file at `<path>.previous`, and leaves a receipt in the store it
|
|
10
|
+
* serves on /status. When the artifact is ABSENT, updates are host-managed —
|
|
11
|
+
* the facade runs no loop (the safe embedded default).
|
|
12
|
+
*
|
|
13
|
+
* The one guard the facade does NOT apply is install-kind: it must never swap a
|
|
14
|
+
* dev `bun run daemon` interpreter or a bun-global package install. This helper
|
|
15
|
+
* is that guard — it hands the facade an artifact ONLY for a compiled binary
|
|
16
|
+
* install, so a dev run resolves to `undefined` (host-managed, no loop) and
|
|
17
|
+
* only a real self-contained binary self-updates.
|
|
18
|
+
*/
|
|
19
|
+
import type { DaemonUpdateArtifact } from '@pellux/goodvibes-sdk/platform/daemon';
|
|
20
|
+
import { VERSION } from '../version.ts';
|
|
21
|
+
import { detectInstallKind } from '../runtime/update-check.ts';
|
|
22
|
+
|
|
23
|
+
export interface ResolveDaemonUpdateArtifactOptions {
|
|
24
|
+
/** The executable to identify; defaults to process.execPath. */
|
|
25
|
+
readonly execPath?: string;
|
|
26
|
+
/** Injectable so tests pin a fixture version — never the live build VERSION. */
|
|
27
|
+
readonly version?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The update-artifact identity to hand the DaemonServer facade, or `undefined`
|
|
32
|
+
* for a non-binary install (dev/source or bun-global package) — in which case
|
|
33
|
+
* the facade keeps updates host-managed and runs no swap loop.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveDaemonUpdateArtifact(
|
|
36
|
+
options: ResolveDaemonUpdateArtifactOptions = {},
|
|
37
|
+
): DaemonUpdateArtifact | undefined {
|
|
38
|
+
const execPath = options.execPath ?? process.execPath;
|
|
39
|
+
if (detectInstallKind(execPath) !== 'binary') return undefined;
|
|
40
|
+
return { version: options.version ?? VERSION, execPath };
|
|
41
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* local-daemon-state.ts — the two files a daemon writes about ITSELF.
|
|
3
|
+
*
|
|
4
|
+
* `status` and `update` want to report things no control-plane verb answers:
|
|
5
|
+
* how long the daemon has been up, whether the last start followed a crash,
|
|
6
|
+
* which version an automatic rollback rejected, and what the daemon has
|
|
7
|
+
* written receipts about. All of that lives on the daemon's own host, in two
|
|
8
|
+
* JSON files beside its control-plane state:
|
|
9
|
+
*
|
|
10
|
+
* <control-plane config dir>/control-plane/daemon-lifecycle.json
|
|
11
|
+
* <control-plane config dir>/control-plane/daemon-receipts.json
|
|
12
|
+
*
|
|
13
|
+
* They are READ here and never written, and the receipts are never marked
|
|
14
|
+
* delivered — `/status?receipts=consume` hands each receipt to the first
|
|
15
|
+
* consuming reader exactly once, and a status command that quietly consumed
|
|
16
|
+
* them would take them away from the surface they were written for.
|
|
17
|
+
*
|
|
18
|
+
* Which is also why this is honestly local-only: a `status --host other-box`
|
|
19
|
+
* has no access to that box's filesystem, and these lines are reported as
|
|
20
|
+
* unavailable rather than guessed at. See `describeLocalDaemonState`.
|
|
21
|
+
*
|
|
22
|
+
* The SDK owns the writers (`platform/daemon/lifecycle-marker.ts`,
|
|
23
|
+
* `platform/daemon/receipts.ts`) and its readers are not exported from the
|
|
24
|
+
* published package this repository pins, so the readers below are this
|
|
25
|
+
* repository's own — bounded and content-validated the same way, and no more
|
|
26
|
+
* trusting of the file than the SDK is. The shared-piece lane can re-point them
|
|
27
|
+
* at the SDK's own readers once those are exported.
|
|
28
|
+
*/
|
|
29
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
|
|
32
|
+
/** Mirrors the SDK's own clamp: a hand-edited counter is bounded, not trusted. */
|
|
33
|
+
const MAX_TRACKED_FAILED_STARTS = 32;
|
|
34
|
+
/** Mirrors the SDK's own clamp on a persisted version string. */
|
|
35
|
+
const MAX_TRACKED_VERSION_LENGTH = 64;
|
|
36
|
+
/** Enough receipts to explain a restart; a status page is not a log viewer. */
|
|
37
|
+
const MAX_REPORTED_RECEIPTS = 10;
|
|
38
|
+
|
|
39
|
+
export interface DaemonLifecycleMarker {
|
|
40
|
+
readonly state: 'running' | 'clean-shutdown';
|
|
41
|
+
readonly at: number;
|
|
42
|
+
readonly pid: number | undefined;
|
|
43
|
+
readonly failedStarts: number;
|
|
44
|
+
readonly version: string | undefined;
|
|
45
|
+
/** The version an automatic rollback moved AWAY from — the build that crash looped. */
|
|
46
|
+
readonly rejectedVersion: string | undefined;
|
|
47
|
+
/** When an automatic rollback last restored the kept previous binary. */
|
|
48
|
+
readonly autoRollbackAt: number | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DaemonReceipt {
|
|
52
|
+
readonly id: string;
|
|
53
|
+
readonly text: string;
|
|
54
|
+
readonly at: number;
|
|
55
|
+
readonly deliveredAt: number | undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface LocalDaemonStatePaths {
|
|
59
|
+
readonly markerPath: string;
|
|
60
|
+
readonly receiptsPath: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The two paths, derived the same way the daemon facade derives them. */
|
|
64
|
+
export function localDaemonStatePaths(controlPlaneConfigDir: string): LocalDaemonStatePaths {
|
|
65
|
+
return {
|
|
66
|
+
markerPath: join(controlPlaneConfigDir, 'control-plane', 'daemon-lifecycle.json'),
|
|
67
|
+
receiptsPath: join(controlPlaneConfigDir, 'control-plane', 'daemon-receipts.json'),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface LocalStateIo {
|
|
72
|
+
read(path: string): string | null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const realLocalStateIo: LocalStateIo = {
|
|
76
|
+
read(path: string): string | null {
|
|
77
|
+
try {
|
|
78
|
+
return existsSync(path) ? readFileSync(path, 'utf-8') : null;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function boundedString(value: unknown, max: number): string | undefined {
|
|
86
|
+
return typeof value === 'string' && value.length > 0 && value.length <= max ? value : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
90
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The marker as it stands, or null when there is none or it does not validate. */
|
|
94
|
+
export function readDaemonLifecycleMarker(
|
|
95
|
+
markerPath: string,
|
|
96
|
+
io: LocalStateIo = realLocalStateIo,
|
|
97
|
+
): DaemonLifecycleMarker | null {
|
|
98
|
+
const raw = io.read(markerPath);
|
|
99
|
+
if (raw === null) return null;
|
|
100
|
+
let parsed: unknown;
|
|
101
|
+
try {
|
|
102
|
+
parsed = JSON.parse(raw);
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
107
|
+
const record = parsed as Record<string, unknown>;
|
|
108
|
+
const state = record['state'];
|
|
109
|
+
if (state !== 'running' && state !== 'clean-shutdown') return null;
|
|
110
|
+
const at = finiteNumber(record['at']);
|
|
111
|
+
if (at === undefined) return null;
|
|
112
|
+
const failedStarts = finiteNumber(record['failedStarts']) ?? 0;
|
|
113
|
+
return {
|
|
114
|
+
state,
|
|
115
|
+
at,
|
|
116
|
+
pid: finiteNumber(record['pid']),
|
|
117
|
+
failedStarts: Math.max(0, Math.min(MAX_TRACKED_FAILED_STARTS, Math.trunc(failedStarts))),
|
|
118
|
+
version: boundedString(record['version'], MAX_TRACKED_VERSION_LENGTH),
|
|
119
|
+
rejectedVersion: boundedString(record['rejectedVersion'], MAX_TRACKED_VERSION_LENGTH),
|
|
120
|
+
autoRollbackAt: finiteNumber(record['autoRollbackAt']),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The receipts the daemon has written, newest last, capped.
|
|
126
|
+
*
|
|
127
|
+
* Read-only: nothing here marks a receipt delivered. The store's own
|
|
128
|
+
* consume path is what a surface uses when it means to claim them.
|
|
129
|
+
*/
|
|
130
|
+
export function readDaemonReceipts(
|
|
131
|
+
receiptsPath: string,
|
|
132
|
+
io: LocalStateIo = realLocalStateIo,
|
|
133
|
+
): readonly DaemonReceipt[] {
|
|
134
|
+
const raw = io.read(receiptsPath);
|
|
135
|
+
if (raw === null) return [];
|
|
136
|
+
let parsed: unknown;
|
|
137
|
+
try {
|
|
138
|
+
parsed = JSON.parse(raw);
|
|
139
|
+
} catch {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
// The store persists either a bare array or `{ receipts: [...] }` depending
|
|
143
|
+
// on its version; both are read rather than one being assumed.
|
|
144
|
+
const list = Array.isArray(parsed)
|
|
145
|
+
? parsed
|
|
146
|
+
: parsed && typeof parsed === 'object' && Array.isArray((parsed as { receipts?: unknown }).receipts)
|
|
147
|
+
? (parsed as { receipts: unknown[] }).receipts
|
|
148
|
+
: [];
|
|
149
|
+
const receipts: DaemonReceipt[] = [];
|
|
150
|
+
for (const entry of list) {
|
|
151
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
152
|
+
const record = entry as Record<string, unknown>;
|
|
153
|
+
const text = typeof record['text'] === 'string' ? record['text'] : undefined;
|
|
154
|
+
const at = finiteNumber(record['at']);
|
|
155
|
+
if (text === undefined || at === undefined) continue;
|
|
156
|
+
receipts.push({
|
|
157
|
+
id: typeof record['id'] === 'string' ? record['id'] : `${at}`,
|
|
158
|
+
text,
|
|
159
|
+
at,
|
|
160
|
+
deliveredAt: finiteNumber(record['deliveredAt']),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return receipts.slice(-MAX_REPORTED_RECEIPTS);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface LocalDaemonState {
|
|
167
|
+
/** False when the caller asked about another machine — nothing below was read. */
|
|
168
|
+
readonly available: boolean;
|
|
169
|
+
/** Why it is unavailable, when it is. */
|
|
170
|
+
readonly unavailableReason: string | undefined;
|
|
171
|
+
readonly marker: DaemonLifecycleMarker | null;
|
|
172
|
+
readonly receipts: readonly DaemonReceipt[];
|
|
173
|
+
/** Milliseconds since the marker said the daemon started, when it says it is running. */
|
|
174
|
+
readonly uptimeMs: number | undefined;
|
|
175
|
+
/** True when an automatic rollback is in force and no clean start has cleared it. */
|
|
176
|
+
readonly rolledBack: boolean;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface DescribeLocalDaemonStateInput {
|
|
180
|
+
/** False for a remote target: nothing is read and the lines say why. */
|
|
181
|
+
readonly isLocal: boolean;
|
|
182
|
+
readonly controlPlaneConfigDir: string;
|
|
183
|
+
readonly now?: (() => number) | undefined;
|
|
184
|
+
readonly io?: LocalStateIo | undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* What this machine's own files say about the daemon.
|
|
189
|
+
*
|
|
190
|
+
* Never throws and never asserts: an absent marker is an absent marker, which
|
|
191
|
+
* is the ordinary state on a host where the daemon has not started yet.
|
|
192
|
+
*/
|
|
193
|
+
export function describeLocalDaemonState(input: DescribeLocalDaemonStateInput): LocalDaemonState {
|
|
194
|
+
if (!input.isLocal) {
|
|
195
|
+
return {
|
|
196
|
+
available: false,
|
|
197
|
+
unavailableReason:
|
|
198
|
+
'the uptime, update receipts and rollback state are read from files on the daemon\'s own host — '
|
|
199
|
+
+ 'run this command on that machine to see them',
|
|
200
|
+
marker: null,
|
|
201
|
+
receipts: [],
|
|
202
|
+
uptimeMs: undefined,
|
|
203
|
+
rolledBack: false,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
const io = input.io ?? realLocalStateIo;
|
|
207
|
+
const paths = localDaemonStatePaths(input.controlPlaneConfigDir);
|
|
208
|
+
const marker = readDaemonLifecycleMarker(paths.markerPath, io);
|
|
209
|
+
const receipts = readDaemonReceipts(paths.receiptsPath, io);
|
|
210
|
+
const now = input.now?.() ?? Date.now();
|
|
211
|
+
const uptimeMs = marker && marker.state === 'running' && marker.at <= now ? now - marker.at : undefined;
|
|
212
|
+
return {
|
|
213
|
+
available: true,
|
|
214
|
+
unavailableReason: undefined,
|
|
215
|
+
marker,
|
|
216
|
+
receipts,
|
|
217
|
+
uptimeMs,
|
|
218
|
+
rolledBack: marker?.autoRollbackAt !== undefined,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** `3d 4h`, `4h 12m`, `12m 3s`, `9s` — two units, never more. */
|
|
223
|
+
export function formatDuration(milliseconds: number): string {
|
|
224
|
+
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000));
|
|
225
|
+
const days = Math.floor(totalSeconds / 86_400);
|
|
226
|
+
const hours = Math.floor((totalSeconds % 86_400) / 3_600);
|
|
227
|
+
const minutes = Math.floor((totalSeconds % 3_600) / 60);
|
|
228
|
+
const seconds = totalSeconds % 60;
|
|
229
|
+
if (days > 0) return `${days}d ${hours}h`;
|
|
230
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
231
|
+
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
|
232
|
+
return `${seconds}s`;
|
|
233
|
+
}
|