mirror of
https://github.com/feder-cr/Jobs_Applier_AI_Agent.git
synced 2026-11-07 10:36:05 -10:00
host: the pieces every package uses live in shared, so a package never depends on an app
Clock, Logger, silentLogger, errorMessage, ControlPlaneError, notFound and the stderr logger move from apps/scheduler to packages/shared; packages/channels and apps/api import them from there, and channels keeps the scheduler for its tests only. One abortableSleep replaces four copies (scheduler, CLI, SDK, WhatsApp); the one shared had slept the whole time on a signal that had already aborted. The API reports its package's version, as the CLI does, instead of a hard-coded 0.1.0.
This commit is contained in:
1 parent
c8cb603941
commit
b0514eac62
25 files changed
+121
-181
No files matched your search
@@ -4,7 +4,7 @@
|
||||
* migrated, and checked against a master key that was lost.
|
||||
*/
|
||||
import { Database, databaseTarget, describeDatabaseTarget } from "@invisible-dots/database";
|
||||
import { errorMessage, type Logger } from "@invisible-dots/scheduler";
|
||||
import { errorMessage, type Logger } from "@invisible-dots/shared";
|
||||
|
||||
export interface OpenDatabaseOptions {
|
||||
env: Record<string, string | undefined>;
|
||||
|
||||
+5
-18
@@ -6,7 +6,7 @@
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import { TELEGRAM_TOKEN_SECRET, type ChannelHub } from "@invisible-dots/channels";
|
||||
import { StreamOverflowError } from "@invisible-dots/events";
|
||||
import { ControlPlaneError, errorMessage, silentLogger, type Logger, type Scheduler } from "@invisible-dots/scheduler";
|
||||
import { type Scheduler } from "@invisible-dots/scheduler";
|
||||
import { STREAM_ERROR_EVENT } from "@invisible-dots/sdk";
|
||||
import type {
|
||||
ApprovalsAnswer,
|
||||
@@ -24,27 +24,14 @@ import type {
|
||||
TasksAnswer,
|
||||
UsageAnswer,
|
||||
} from "@invisible-dots/sdk/types";
|
||||
import {
|
||||
APPROVAL_LIST_LIMIT,
|
||||
CONVERSATION_LIST_LIMIT,
|
||||
APPROVAL_STATUSES,
|
||||
CHANNEL_KINDS,
|
||||
LIST_ORDERS,
|
||||
MAX_EVENT_PAGE,
|
||||
TASK_LIST_LIMIT,
|
||||
type ApprovalStatus,
|
||||
type ChannelKind,
|
||||
type DoctorCheck,
|
||||
type ListOrder,
|
||||
type SkillListAnswer,
|
||||
type McpSecretsAnswer,
|
||||
type ToolListAnswer,
|
||||
} from "@invisible-dots/shared";
|
||||
import { APPROVAL_LIST_LIMIT, APPROVAL_STATUSES, type ApprovalStatus, CHANNEL_KINDS, type ChannelKind, ControlPlaneError, CONVERSATION_LIST_LIMIT, type DoctorCheck, errorMessage, LIST_ORDERS, type ListOrder, type Logger, MAX_EVENT_PAGE, type McpSecretsAnswer, silentLogger, type SkillListAnswer, TASK_LIST_LIMIT, type ToolListAnswer } from "@invisible-dots/shared";
|
||||
import { doctorAnswer } from "@invisible-dots/vm-manager";
|
||||
import { serveFile } from "./file-types.js";
|
||||
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
|
||||
import apiPackage from "../package.json" with { type: "json" };
|
||||
|
||||
export const API_VERSION = "0.1.0";
|
||||
/** The version `GET /api/health` reports: this package's, as the CLI's `--version` reports its own. */
|
||||
export const API_VERSION: string = apiPackage.version;
|
||||
|
||||
/** A comment line on idle SSE connections, so proxies and clients see the connection is alive. */
|
||||
export const SSE_HEARTBEAT_MS = 15_000;
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
} from "@invisible-dots/channels";
|
||||
import type { Database } from "@invisible-dots/database";
|
||||
import type { HostFacts } from "@invisible-dots/sdk/types";
|
||||
import { errorMessage, prefixedStderrLogger, Scheduler, type ComputerDriver, type Logger, type SchedulerOptions } from "@invisible-dots/scheduler";
|
||||
import { ensureDir, ENV, hostPaths, type HostPaths } from "@invisible-dots/shared";
|
||||
import { Scheduler, type ComputerDriver, type SchedulerOptions } from "@invisible-dots/scheduler";
|
||||
import { ensureDir, ENV, errorMessage, hostPaths, type HostPaths, type Logger, prefixedStderrLogger } from "@invisible-dots/shared";
|
||||
import { hostDoctorDeps, openRouterCheck, runDoctor, type DoctorDeps } from "@invisible-dots/vm-manager";
|
||||
import { loadOrCreateApiToken, loadOrGenerateMasterKey, parseListen, saveMasterKey, type ListenAddress } from "./config.js";
|
||||
import { assertNothingEncrypted, openControlPlaneDatabase } from "./database.js";
|
||||
|
||||
@@ -11,18 +11,8 @@
|
||||
*/
|
||||
import { access, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
ComputerDriver,
|
||||
ComputerSpecInput,
|
||||
ComputerState,
|
||||
CreatedComputer,
|
||||
GuestApi,
|
||||
GuestEndpoint,
|
||||
Logger,
|
||||
StartedComputer,
|
||||
WaitForHealthOptions,
|
||||
} from "@invisible-dots/scheduler";
|
||||
import { imageLabel, imageVersionOf, type HealthAnswer, type HostPaths, type ImageKind } from "@invisible-dots/shared";
|
||||
import { type ComputerDriver, type ComputerSpecInput, type ComputerState, type CreatedComputer, type GuestApi, type GuestEndpoint, type StartedComputer, type WaitForHealthOptions } from "@invisible-dots/scheduler";
|
||||
import { type HealthAnswer, type HostPaths, type ImageKind, imageLabel, imageVersionOf, type Logger } from "@invisible-dots/shared";
|
||||
import { VmManager, type StartVmResult, type VmSpec } from "@invisible-dots/vm-manager";
|
||||
|
||||
/** Compare versions such as "2026.10.1" and "2026.9.3" numerically, part by part. */
|
||||
|
||||
@@ -10,9 +10,9 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createTestDatabase, type TestDatabase } from "@invisible-dots/database/testing";
|
||||
import { prefixedStderrLogger, Scheduler } from "@invisible-dots/scheduler";
|
||||
import { Scheduler } from "@invisible-dots/scheduler";
|
||||
import { FAKE_MAX_IDENTITIES, FakeGuest, waitFor } from "@invisible-dots/scheduler/testing";
|
||||
import { computerResources, hostPaths, parseDotConfig, type HostPaths } from "@invisible-dots/shared";
|
||||
import { computerResources, hostPaths, type HostPaths, parseDotConfig, prefixedStderrLogger } from "@invisible-dots/shared";
|
||||
import { silentLogger, VmManager } from "@invisible-dots/vm-manager";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { FakeQemuHost, FakeRunner, type FakeVm } from "../../vm-manager/test/fakes.js";
|
||||
|
||||
+2
-14
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
import { stat } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { allowlistedEnvironment, BASE_CHILD_ENV_VARS, ENV } from "@invisible-dots/shared";
|
||||
import { abortableSleep, allowlistedEnvironment, BASE_CHILD_ENV_VARS, ENV } from "@invisible-dots/shared";
|
||||
import { startProcess, WEB_BUILD_COMMAND, type StartedProcess, type StartProcessOptions, type WebBuild } from "@invisible-dots/vm-manager";
|
||||
|
||||
/**
|
||||
@@ -97,18 +97,6 @@ function answers(url: string): Promise<boolean> {
|
||||
);
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(done, ms);
|
||||
function done() {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", done);
|
||||
resolve();
|
||||
}
|
||||
signal?.addEventListener("abort", done, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the web server and resolve once it answers a request. Throws
|
||||
* WebStartError, with the child already gone, when the build is incomplete,
|
||||
@@ -167,7 +155,7 @@ export async function startWebServer(options: WebServerOptions): Promise<WebServ
|
||||
started = true;
|
||||
return { url, stop };
|
||||
}
|
||||
await sleep(READY_POLL_MS, options.signal);
|
||||
await abortableSleep(READY_POLL_MS, options.signal);
|
||||
}
|
||||
} catch (error) {
|
||||
await stop();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* inbound delivery's job (inbound.ts).
|
||||
*/
|
||||
import type { Database } from "@invisible-dots/database";
|
||||
import { errorMessage, type Clock, type Logger } from "./support.js";
|
||||
import { errorMessage, type Clock, type Logger } from "@invisible-dots/shared";
|
||||
|
||||
// A claim skips a row another transaction holds (SKIP LOCKED); such holds last as long as one guest event's
|
||||
// transaction. A pass that claimed nothing while a task could be claimed comes back this soon, this many times;
|
||||
|
||||
@@ -16,7 +16,7 @@ import { TASK_ENDED_BEFORE_DELIVERY, type Database, type InboundRecord } from "@
|
||||
import type { EventLog } from "@invisible-dots/events";
|
||||
import { guestErrorCode, guestErrorStatus } from "./driver.js";
|
||||
import type { Lifecycle } from "./lifecycle.js";
|
||||
import { errorMessage, type Clock, type Logger } from "./support.js";
|
||||
import { errorMessage, type Clock, type Logger } from "@invisible-dots/shared";
|
||||
|
||||
export interface InboundDeliveryOptions {
|
||||
/** Failed sends with a known outcome before a task.created gives its task up as FAILED. */
|
||||
|
||||
@@ -31,7 +31,8 @@ import {
|
||||
} from "@invisible-dots/shared";
|
||||
import { guestErrorCode, guestErrorStatus, type ComputerDriver, type ComputerSpecInput, type ComputerState, type GuestApi } from "./driver.js";
|
||||
import { applyGuestEvent, dotStatusForAgent, type AppliedGuestEvent } from "./guest-events.js";
|
||||
import { BackgroundWork, ControlPlaneError, errorMessage, KeyedMutex, sleep, type Clock, type Logger } from "./support.js";
|
||||
import { abortableSleep, ControlPlaneError, errorMessage, type Clock, type Logger } from "@invisible-dots/shared";
|
||||
import { BackgroundWork, KeyedMutex } from "./support.js";
|
||||
|
||||
export interface LifecycleOptions {
|
||||
/** How long a started VM may take to pass the READY procedure. Default 10 minutes (a first boot runs cloud-init). */
|
||||
@@ -716,7 +717,7 @@ export class Lifecycle {
|
||||
this.#background.run("recording a VM that stopped by itself", dotId, () => this.#exited(dotId));
|
||||
return;
|
||||
}
|
||||
await sleep(delay, signal);
|
||||
await abortableSleep(delay, signal);
|
||||
delay = Math.min(delay * 2, this.#opts.pumpMaxRetryMs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,29 +24,36 @@ import {
|
||||
checkMcpSecret,
|
||||
checkOpenRouterKey,
|
||||
COMPUTER_STOPPED,
|
||||
ControlPlaneError,
|
||||
computerIsUp,
|
||||
computerResources,
|
||||
CONVERSATION_LIST_LIMIT,
|
||||
DotConfigError,
|
||||
errorMessage,
|
||||
isIdentityAnswer,
|
||||
isStoredEventType,
|
||||
MAX_HOST_FILE_BYTES,
|
||||
newId,
|
||||
notFound,
|
||||
parseDotConfig,
|
||||
parseMessageOrigin,
|
||||
mcpServerNames,
|
||||
parseSize,
|
||||
permissionNames,
|
||||
silentLogger,
|
||||
systemClock,
|
||||
TASK_CANCELLED_SYSTEM_EVENT,
|
||||
TERMINAL_TASK_STATES,
|
||||
vmName,
|
||||
type ApprovalStatus,
|
||||
type BrowserIdentity,
|
||||
type Clock,
|
||||
type CreateBrowserIdentityRequest,
|
||||
type DotConfig,
|
||||
type FilesListAnswer,
|
||||
type InboundEvent,
|
||||
type ListOrder,
|
||||
type Logger,
|
||||
type McpSecretsAnswer,
|
||||
type MessageOrigin,
|
||||
type StoredEvent,
|
||||
@@ -58,16 +65,7 @@ import { Dispatcher } from "./dispatcher.js";
|
||||
import { guestErrorCode, guestErrorStatus, type ComputerDriver, type GuestApi } from "./driver.js";
|
||||
import { InboundDelivery, type InboundDeliveryOptions } from "./inbound.js";
|
||||
import { Lifecycle, MISSING_KEY_MESSAGE, type LifecycleOptions } from "./lifecycle.js";
|
||||
import {
|
||||
BackgroundWork,
|
||||
ControlPlaneError,
|
||||
errorMessage,
|
||||
notFound,
|
||||
silentLogger,
|
||||
systemClock,
|
||||
type Clock,
|
||||
type Logger,
|
||||
} from "./support.js";
|
||||
import { BackgroundWork } from "./support.js";
|
||||
|
||||
export interface SchedulerOptions {
|
||||
db: Database;
|
||||
|
||||
@@ -1,33 +1,5 @@
|
||||
/** Small pieces the scheduler is built from: clock, logger, a per-key lock and the error types the API maps to HTTP. */
|
||||
|
||||
export interface Clock {
|
||||
now(): Date;
|
||||
}
|
||||
|
||||
export const systemClock: Clock = { now: () => new Date() };
|
||||
|
||||
/** The logger shape vm-manager uses too, so one logger serves the whole process. */
|
||||
export interface Logger {
|
||||
debug(message: string, fields?: Record<string, unknown>): void;
|
||||
info(message: string, fields?: Record<string, unknown>): void;
|
||||
warn(message: string, fields?: Record<string, unknown>): void;
|
||||
error(message: string, fields?: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
export function prefixedStderrLogger(prefix: string, debug = false): Logger {
|
||||
const write = (level: string, message: string, fields?: Record<string, unknown>) => {
|
||||
const suffix = fields && Object.keys(fields).length > 0 ? ` ${JSON.stringify(fields)}` : "";
|
||||
process.stderr.write(`${new Date().toISOString()} [${prefix}] ${level} ${message}${suffix}\n`);
|
||||
};
|
||||
return {
|
||||
debug: debug ? (m, f) => write("debug", m, f) : () => {},
|
||||
info: (m, f) => write("info", m, f),
|
||||
warn: (m, f) => write("warn", m, f),
|
||||
error: (m, f) => write("error", m, f),
|
||||
};
|
||||
}
|
||||
|
||||
export const silentLogger: Logger = { debug() {}, info() {}, warn() {}, error() {} };
|
||||
/** The scheduler's own building blocks: a per-key lock, and the work it starts without waiting for it. */
|
||||
import { errorMessage, type Logger } from "@invisible-dots/shared";
|
||||
|
||||
/**
|
||||
* Serializes work per key: lifecycle operations on one Dot (create, start,
|
||||
@@ -53,38 +25,6 @@ export class KeyedMutex {
|
||||
}
|
||||
}
|
||||
|
||||
/** An error with the HTTP status and `error` code the API answers with (section 9.6). */
|
||||
export class ControlPlaneError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ControlPlaneError";
|
||||
}
|
||||
}
|
||||
|
||||
export const notFound = (what: string, id: string) => new ControlPlaneError(404, "not_found", `${what} "${id}" not found`);
|
||||
|
||||
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal?.aborted) return resolve();
|
||||
const done = () => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", done);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(done, ms);
|
||||
signal?.addEventListener("abort", done, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Work a caller starts without waiting for it (a provisioning, a stop, a push to a guest): its failure is logged,
|
||||
* since nobody awaits it, and `settle` waits until all of it has finished (tests and shutdown). After `close` nothing
|
||||
|
||||
@@ -8,7 +8,8 @@ import type { Database } from "@invisible-dots/database";
|
||||
import { createTestDatabase, testAdapters, type TestDatabase } from "@invisible-dots/database/testing";
|
||||
import type { InboundEvent, OutboundEvent, SecretsRequest } from "@invisible-dots/shared";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { Scheduler, type Logger, type SchedulerOptions } from "../src/index.js";
|
||||
import { Scheduler, type SchedulerOptions } from "../src/index.js";
|
||||
import { type Logger } from "@invisible-dots/shared";
|
||||
import type { GuestApi, GuestEndpoint } from "../src/index.js";
|
||||
import { FakeDriver, FakeGuest, FakeGuestError, ManualClock, waitFor, waitUntilSettledReady } from "../src/testing.js";
|
||||
|
||||
|
||||
Generated
+3
-1
@@ -9149,9 +9149,11 @@
|
||||
"dependencies": {
|
||||
"@invisible-dots/database": "0.0.0",
|
||||
"@invisible-dots/events": "0.0.0",
|
||||
"@invisible-dots/scheduler": "0.0.0",
|
||||
"@invisible-dots/shared": "0.0.0",
|
||||
"grammy": "1.46.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@invisible-dots/scheduler": "0.0.0"
|
||||
}
|
||||
},
|
||||
"packages/database": {
|
||||
|
||||
@@ -15,8 +15,10 @@
|
||||
"dependencies": {
|
||||
"@invisible-dots/database": "0.0.0",
|
||||
"@invisible-dots/events": "0.0.0",
|
||||
"@invisible-dots/scheduler": "0.0.0",
|
||||
"@invisible-dots/shared": "0.0.0",
|
||||
"grammy": "1.46.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@invisible-dots/scheduler": "0.0.0"
|
||||
}
|
||||
}
|
||||
@@ -10,21 +10,7 @@
|
||||
import type { ChannelBindingRecord, ChannelPeerRow, Database } from "@invisible-dots/database";
|
||||
import { isUniqueViolation } from "@invisible-dots/database";
|
||||
import type { EventLog } from "@invisible-dots/events";
|
||||
import {
|
||||
CHANNEL_KINDS,
|
||||
type ApprovalRecord,
|
||||
newId,
|
||||
type ChannelChange,
|
||||
type ChannelKind,
|
||||
type ChannelLinkFrame,
|
||||
type ChannelPairingAnswer,
|
||||
type ChannelRecord,
|
||||
type ChannelSettings,
|
||||
type MessageAnswer,
|
||||
type MessageOrigin,
|
||||
type StoredEvent,
|
||||
} from "@invisible-dots/shared";
|
||||
import { ControlPlaneError, errorMessage, notFound, silentLogger, systemClock, type Clock, type Logger } from "@invisible-dots/scheduler";
|
||||
import { type ApprovalRecord, CHANNEL_KINDS, type ChannelChange, type ChannelKind, type ChannelLinkFrame, type ChannelPairingAnswer, type ChannelRecord, type ChannelSettings, type Clock, ControlPlaneError, errorMessage, type Logger, type MessageAnswer, type MessageOrigin, newId, notFound, silentLogger, type StoredEvent, systemClock } from "@invisible-dots/shared";
|
||||
import { DEFAULT_BACKOFF, type BackoffOptions } from "./backoff.js";
|
||||
import { parseApprovalReply } from "./approval-text.js";
|
||||
import { ChannelCredentialsError, type ApprovalAction, type ChannelSink, type ChannelStatusReport, type ChannelType, type InboundChat, type PairingAttempt } from "./channel.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Clock } from "@invisible-dots/scheduler";
|
||||
import { type Clock } from "@invisible-dots/shared";
|
||||
|
||||
/** A token bucket per key: `capacity` messages at once, refilled at `refillPerSecond`. */
|
||||
export class RateLimiter {
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
*/
|
||||
import type { ChannelBindingRecord, Database } from "@invisible-dots/database";
|
||||
import { StreamOverflowError, type EventLog } from "@invisible-dots/events";
|
||||
import { parseMessageOrigin, type ChannelKind, type StoredEvent } from "@invisible-dots/shared";
|
||||
import { errorMessage, sleep, type Logger } from "@invisible-dots/scheduler";
|
||||
import { abortableSleep, type ChannelKind, errorMessage, type Logger, parseMessageOrigin, type StoredEvent } from "@invisible-dots/shared";
|
||||
import { Backoff, type BackoffOptions } from "./backoff.js";
|
||||
import { BindingSecrets } from "./binding-secrets.js";
|
||||
import { approvalOutcomeText, approvalPromptText } from "./approval-text.js";
|
||||
@@ -173,7 +172,7 @@ export class BindingRunner {
|
||||
}
|
||||
this.#o.logger.warn("a channel stopped, starting it again", { binding: this.bindingId, kind: this.kind, reason: failure });
|
||||
this.report({ status: "error", detail: failure });
|
||||
await sleep(Math.max(backoff.next(), asked), this.#signal);
|
||||
await abortableSleep(Math.max(backoff.next(), asked), this.#signal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +191,7 @@ export class BindingRunner {
|
||||
if (this.#signal.aborted) return;
|
||||
this.#o.logger.warn("a channel's event stream failed", { binding: this.bindingId, error: errorMessage(error) });
|
||||
// Falling behind is not a fault of the database: resume from the cursor at once.
|
||||
if (!(error instanceof StreamOverflowError)) await sleep(backoff.next(), this.#signal);
|
||||
if (!(error instanceof StreamOverflowError)) await abortableSleep(backoff.next(), this.#signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,7 +348,7 @@ export class BindingRunner {
|
||||
const backoff = new Backoff(this.#o.backoff);
|
||||
while (!this.#signal.aborted) {
|
||||
if (this.channel) return this.channel;
|
||||
await sleep(backoff.next(), this.#signal);
|
||||
await abortableSleep(backoff.next(), this.#signal);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -380,7 +379,7 @@ export class BindingRunner {
|
||||
}
|
||||
const wait = Math.max(backoff.next(), error instanceof ChannelSendError ? (error.options.retryAfterMs ?? 0) : 0);
|
||||
this.#o.logger.warn("a channel could not send, trying again", { binding: this.bindingId, kind: this.kind, reason, waitMs: wait });
|
||||
await sleep(wait, this.#signal);
|
||||
await abortableSleep(wait, this.#signal);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
* hub could not record is lost: the connection is dropped, the channel shows `error`, and the person writes again.
|
||||
*/
|
||||
import type { ChannelBindingRecord } from "@invisible-dots/database";
|
||||
import { errorMessage } from "@invisible-dots/scheduler";
|
||||
import { abortableSleep, errorMessage } from "@invisible-dots/shared";
|
||||
import { approvalReplyHint } from "../approval-text.js";
|
||||
import {
|
||||
ChannelNeedsRelinkError,
|
||||
@@ -51,7 +51,6 @@ const MAX_RESTARTS = 3;
|
||||
const UNREAD_MAX = 500;
|
||||
|
||||
const defaultPause = () => 400 + Math.random() * 1100;
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
interface Identity {
|
||||
peerId: string;
|
||||
@@ -153,7 +152,7 @@ class WhatsAppChannel implements Channel {
|
||||
await connection.markRead(unread).catch(() => {});
|
||||
}
|
||||
await connection.typing(chatId).catch(() => {});
|
||||
await sleep(this.pauseMs());
|
||||
await abortableSleep(this.pauseMs());
|
||||
return connection.sendText(chatId, text);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
import type { Database } from "@invisible-dots/database";
|
||||
import { createTestDatabase, testAdapters, type TestDatabase } from "@invisible-dots/database/testing";
|
||||
import type { Logger } from "@invisible-dots/scheduler";
|
||||
import { type Logger } from "@invisible-dots/shared";
|
||||
import { waitFor } from "@invisible-dots/scheduler/testing";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_CHANNEL_SETTINGS, TelegramChannelType } from "../src/index.js";
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* section 9.6), built on `fetch` so it runs in Node and in browsers.
|
||||
*/
|
||||
import {
|
||||
abortableSleep,
|
||||
SseParser,
|
||||
type ApprovalStatus,
|
||||
type BrowserIdentity,
|
||||
@@ -501,7 +502,7 @@ export class InvisibleDotsClient {
|
||||
}
|
||||
attempt++;
|
||||
options.onReconnect?.({ after, attempt, error: failure });
|
||||
await delay(Math.min(500 * 2 ** (attempt - 1), 15_000), options.signal);
|
||||
await abortableSleep(Math.min(500 * 2 ** (attempt - 1), 15_000), options.signal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,16 +536,3 @@ async function toApiError(response: Response, route: string): Promise<ApiError>
|
||||
}
|
||||
return new ApiError(response.status, `http_${response.status}`, `${route}: HTTP ${response.status}${text ? `: ${text.slice(0, 300)}` : ""}`);
|
||||
}
|
||||
|
||||
function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal?.aborted) return resolve();
|
||||
const timer = setTimeout(done, ms);
|
||||
function done() {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", done);
|
||||
resolve();
|
||||
}
|
||||
signal?.addEventListener("abort", done, { once: true });
|
||||
});
|
||||
}
|
||||
@@ -10,5 +10,6 @@ export * from "./ids.js";
|
||||
export * from "./protocol.js";
|
||||
export * from "./sse.js";
|
||||
export * from "./states.js";
|
||||
export * from "./support.js";
|
||||
export * from "./labels.js";
|
||||
export * from "./tools.js";
|
||||
@@ -5,6 +5,7 @@
|
||||
* every caller and every test that stands in for the VM layer.
|
||||
*/
|
||||
import type { HealthAnswer } from "./protocol.js";
|
||||
import { abortableSleep } from "./support.js";
|
||||
|
||||
/** "agentd": dot-agentd answers. "agent": the agent behind it also reports status "ok". */
|
||||
export type HealthUntil = "agentd" | "agent";
|
||||
@@ -87,16 +88,3 @@ export async function pollGuestHealth(source: HealthSource, options: PollGuestHe
|
||||
await abortableSleep(interval, options.signal);
|
||||
}
|
||||
}
|
||||
|
||||
/** A sleep that ends early, without an error, when `signal` aborts. */
|
||||
export function abortableSleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(done, ms);
|
||||
function done() {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", done);
|
||||
resolve();
|
||||
}
|
||||
signal?.addEventListener("abort", done, { once: true });
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export * from "./browser.js";
|
||||
export * from "./files.js";
|
||||
export * from "./paths.js";
|
||||
export * from "./sockets.js";
|
||||
export * from "./stderr-logger.js";
|
||||
export * from "./process.js";
|
||||
export * from "./run-process.js";
|
||||
export * from "./replace-file.js";
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Logger } from "./support.js";
|
||||
|
||||
/** Lines on stderr, each with the time and `[prefix]`; debug lines only when `debug` is set. */
|
||||
export function prefixedStderrLogger(prefix: string, debug = false): Logger {
|
||||
const write = (level: string, message: string, fields?: Record<string, unknown>) => {
|
||||
const suffix = fields && Object.keys(fields).length > 0 ? ` ${JSON.stringify(fields)}` : "";
|
||||
process.stderr.write(`${new Date().toISOString()} [${prefix}] ${level} ${message}${suffix}\n`);
|
||||
};
|
||||
return {
|
||||
debug: debug ? (m, f) => write("debug", m, f) : () => {},
|
||||
info: (m, f) => write("info", m, f),
|
||||
warn: (m, f) => write("warn", m, f),
|
||||
error: (m, f) => write("error", m, f),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Small pieces every part of the host is built from: the clock, the logger's shape, an error's message, a sleep that
|
||||
* an abort ends, and the error the API answers with. They live here, below every app and package, so that a package
|
||||
* (channels, the database layer) never depends on an app for them. All of it runs in a browser too.
|
||||
*/
|
||||
|
||||
export interface Clock {
|
||||
now(): Date;
|
||||
}
|
||||
|
||||
export const systemClock: Clock = { now: () => new Date() };
|
||||
|
||||
/** The logger of every part of the control plane process, so one logger serves it all. */
|
||||
export interface Logger {
|
||||
debug(message: string, fields?: Record<string, unknown>): void;
|
||||
info(message: string, fields?: Record<string, unknown>): void;
|
||||
warn(message: string, fields?: Record<string, unknown>): void;
|
||||
error(message: string, fields?: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
export const silentLogger: Logger = { debug() {}, info() {}, warn() {}, error() {} };
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/** A sleep that ends early, without an error, when `signal` aborts, also one that has already aborted. */
|
||||
export function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal?.aborted) return resolve();
|
||||
const timer = setTimeout(done, ms);
|
||||
function done() {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", done);
|
||||
resolve();
|
||||
}
|
||||
signal?.addEventListener("abort", done, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** An error with the HTTP status and `error` code the API answers with (section 9.6). */
|
||||
export class ControlPlaneError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ControlPlaneError";
|
||||
}
|
||||
}
|
||||
|
||||
export const notFound = (what: string, id: string) => new ControlPlaneError(404, "not_found", `${what} "${id}" not found`);
|
||||
Reference in new issue
Block a user