meeting-confirm implementation
Migration entry
The manifest entry exports migrate(db: PluginDb<never>): Promise<void> beside register.
The daemon loader always awaits it before registration for this reads: ['db'] plugin;
the DB CLI calls the same export without registering tools, routes, timers or services.
The export runs only the existing transactional plugin SQL through db.migrate.
A failed step rolls back its SQL and ledger entry and prevents registration. Repeated
calls skip applied versions. Runtime store construction never invokes migrations.
Tests that bypass the loader explicitly await this export before using their fixture.
Problem audiences
User settings, missing human service accounts or helper models, invalid salesperson email, typed call/conversation refusals and structured integration HTTP refusals remain local. Exhausted delivery retains its original causes until audience selection: all-local failures do not report; a software failure remains eligible. An agent that does not record before its deadline is a local execution outcome. Response-contract defects remain reportable. Counters, deadline, owner alerts and diagnostic text are unchanged. This requires the elowen-plugin-shared/errors seam that core 0.30.6 first exposes, under the plugin's core 0.30.7 floor.
Retry reporting
Scheduler, preparation and discovery retries use ConsecutiveFailures from
elowen-plugin-shared/consecutiveFailures. The plugin requires shared API 14 for asynchronous PostgreSQL persistence. The scheduler
has one counter; preparation and discovery each count per meeting. Discovery and refresh share the
same meeting counter. Only failure three exports the existing code; all other attempts remain local,
and success resets the counter. Response-contract validation failures remain immediate. Typed configuration errors for a missing
human service account or unavailable contact-extraction model stay local. These counters do not
replace the persisted delivery retry budget or its immediate exhaustion report.
An unset serviceAccount is an unfinished first setup, not a failure: each tick still runs retention
cleanup, then skips delivery, discovery and calls, logs one info line until the setting is chosen and
wakes again after five minutes. A configured value that fails validation writes a local
meeting-confirm.scheduler_failed diagnostic.
Delivery and recovery
The attempt is inserted before calling. Result, attempt state, next time and pending operation flags are committed in one database transaction. There is no independent attempt counter: only dialing, done and unknown rows for the current meeting time count. A refused call has state not_placed.
Pending Raynet writes and Teams notices survive restart; an answered call still waiting for the agent becomes unclear at its ten-minute deadline. Delivery only happens from the running service. A failure leaves its flag pending for a five-minute retry, with at most three delivery passes per result. The job persists the pass count across restarts. Intermediate failures are logged as progress; the last failed pass writes one meeting-confirm.delivery_exhausted error without exporting CRM or message text. After exhaustion the delivery deadline is cleared and failed flags stay stored for diagnosis; the same result is not retried automatically. A new verdict or moved meeting starts a fresh budget. Teams uses an explicit personal destination by owner email and clears the notice only when delivery resolves. This depends on the Teams plugin reporting an undeliverable explicit destination as an error. A lost Teams acknowledgement can deliver a duplicate notice; Teams offers no idempotency receipt through this seam.
A scheduled callback or other-person retry remains blocked while its preceding result has active delivery work. Once that delivery exhausts its three-pass budget, the scheduled call becomes eligible again even if the failed Raynet flag remains set. The same eligibility condition drives due-call selection and the next timer wake, including after service restart. No failed write is marked delivered. The call still requires a valid phone, a live service account, an allowed calling slot and an unused phone attempt; CRM reads or phone normalization can still prevent dialing. Without a scheduled follow-up, exhaustion schedules no new call. Failure flags describe the current result until a new verdict or moved meeting replaces it; the exhaustion log and recorded attempt verdict remain the historical evidence.
Each Raynet note contains [Elowen #<attemptId>]. Missing phone, unreachable without an attempt and pilot outcomes use a generated operation ID instead. The service rereads the description before retrying a write; an existing marker prevents duplicate notes after a lost acknowledgement. Tags are merged and the previous description is retained. A pending result is delivered or exhausts its budget before a moved meeting replaces that result.
Boot reconciliation changes unfinished dialing attempts to unknown, counts them as used, closes the job and stores a notice. It sends nothing during boot. An explicit daemon_restart end reason always takes precedence over a normal result.
Data and costs
The plugin owns "plugin_meeting-confirm".jobs and "plugin_meeting-confirm".attempts in PostgreSQL. register awaits ctx.db() and openStore before publishing its service, hooks or tool. Every store operation returns a Promise; the scheduler and record tool await reads, commits, cleanup and recovery. Baseline migration 6 creates the current layout; installed ledgers already record it, so it is never renumbered. IDs and epoch-millisecond fields use bigint; result_json remains exact text. It stores meeting references, the contact name and phone read from the deal note with the note hash, the normalized phone, owner email, paying account, outcome message, pending delivery state, who recorded it and each attempt's call and conversation ids. Call transcripts are kept only in the conversation, where the account's normal conversation deletion removes them. The PostgreSQL layout contains no transcript copy. The transcriptDays setting is retired (elowen-plugin.json retiredConfigKeys; its label is no longer in the manifest or i18n/). Agent verdicts are stored in result_json; the removed summary, person_id, transcript_json, outcome_json, created_at, updated_at, ended_at and seconds columns are absent. The running delivery loop records unclear and queues Raynet and Teams in the configured language when an unanswered held deadline expires, without another call. Deleting an account removes its jobs and attempts. Attempt insertion locks its existing job, so removal racing admission cannot leave an orphan attempt. Startup and daily cleanup also remove orphan accounts missed while the plugin was disabled.
The account captured when the meeting is discovered pays its attempts; changing the setting applies to new jobs and moved meeting times. The phone plugin and core own admission, voice access, spend limits and usage accounting. This plugin keeps no billing counter. Origin-attributed cost is read only from the host's usage_by_origin, under the phone provider and paying account.
Developer seams
| Seam | Use and timing | Absent behavior, limits and costs |
|---|---|---|
Manifest configSchema / lib/config.mjs::readConfig |
serviceAccount is a core user field (an account picker storing the username); callingDays multi-select defaults to Monday through Friday; settings UI/API and runtime use the same unique array of weekday options |
readConfig derives defaults from manifest configSchema and validates merged settings with Zod. The non-empty serviceAccount field has no default and is required by the runtime Zod schema; the manifest declares required runtime controls through requiresControls and host authority through capabilities. Core validates multi-select defaults against declared options. A saved selection replaces the default; empty, duplicate, unknown or serialized text selections fail runtime validation. Only the service account requires explicit configuration. |
ctx.control('raynetMeetings') |
list({from,to,signal}) returns raw meetings; get(id) a raw meeting; person(id) the owner's raw person; businessCase(id) the raw deal with its note; update(id,{tags,description}) writes through Raynet's prepare/send path |
Required control; no local HTTP client or credentials. Discovery and every attempt read CRM; failed reads are logged and leave work pending. |
ctx.control('phoneCalls') |
normalize(raw) gives {e164} or {invalid:true}, and throws on outage. call({to,accountUserId,language,brief}) returns {dial,refused?,callId,seconds,transcript,endReason}; dial is answered, no_answer, busy, voicemail, failed or refused. A refusal is {dial:'refused',refused}. |
Required control; no local telephony or voice engine. Provider owns dialing and external cost. Discovery reuses an unchanged contact's persisted normalized phone; every attempt normalizes again before dialing. This conversation's agent owns the answered call's result. Refusals count zero. |
ctx.host.defaultInference() (reads:['inference']) |
One tool-free completion per changed deal note to read the customer contact | The workspace categorization model, or null outside a turn when none is configured; null leaves the job pending. Runs without an account: no spend gate and no account attribution. |
elowen-plugin-shared/zonedTime |
Shared zonedParts, zonedTimeToMs, WEEKDAYS for all schedule calculations |
No copied time-zone helper. Invalid configured zones are rejected before use. |
await ctx.db(), db.migrate/transaction |
Quoted db.schema, PostgreSQL $n parameters and awaited transaction-bound tx operations for attempt/result/outbox changes |
Migration 6 creates two plugin-owned tables. record locks the job with FOR UPDATE, rechecks the operation and awaiting flag, and commits one verdict and its exact JSON together. It returns the accepted job snapshot or false so the tool reports only its accepted result. Settlement, recovery and account removal lock jobs before attempts; cleanup reuses its transaction instead of nesting one. No second usage source. |
retiredConfigKeys |
Removes the stored transcriptDays value |
No other setting is touched. |
registerService/registerInterval |
Meeting confirmation uses one timer service; intervals only flag discovery or cleanup and wake it. The busy tick awaits the next persisted deadline before arming its timer, retaining wake requests received during that read. Recording MeetingConfirmRecord({callId, result:'callback', at, message}) arms the requested future call; due-call selection and the timer share the same eligibility condition. |
Disabled service does no dialing/delivery; startup reconciles retained work. Active delivery blocks a follow-up until success or exhaustion after three persisted passes. Exhaustion retains failed flags without retrying that result or resetting the phone-attempt budget; a permitted scheduled follow-up can then proceed. No scheduled follow-up means no additional call. Existing call windows, account admission and provider costs still apply. |
registerBootReconcile/registerUserRemoved |
Unknown-attempt recovery and account teardown | No sends during reconciliation; startup cleanup handles downtime. |
ctx.host.conversations().send (mutates:['conversations']) |
After every placed call, { accountUserId, key: meetingId, title, text, display, call: { callId } } opens or continues the meeting's conversation as an agent turn billed to the service account |
Requires core 0.30.7 and operator consent. A refused send, or a completed that rejects (failed or stopped turn, conversation busy with another turn), records unclear at once; a restart never settles completed, so the persisted ten-minute deadline records unclear. The call card is present only while core still holds its record of the call, and only because this plugin placed the call through phoneCalls. |
registerTool MeetingConfirmRecord |
Inside that conversation's turn; reads ctx.currentSessionId() before any await and accepts callId, the validated result fields and message only for a call bound to that conversation |
The service account needs the tool in its allowed tools. Providers receive one root object with optional time fields; the plugin's discriminated Zod contract requires the matching field before recording. The delivery loop stays the only writer; the tool waits at most a minute for the write and otherwise reports it as retried. |
ctx.notify |
Explicit destination:msteams:<encoded email> for exception notices |
Missing Teams delivery leaves notice pending; success depends on explicit non-delivery errors. |
ctx.alerts |
Local administrator alert on typed call refusal | No customer notice for account/provider configuration failures. |
Raynet response shape
The consumer validates this response contract before placing a call:
- The meeting list (
GET /meeting/) has noowner. It is read forid,scheduledFrom,status,completedandtagsonly; a watched meeting is then read in detail. - The detail has
owner{id, fullName}. The owner is a Raynet person; the Teams address is that person'scontactInfo.email. An owner without a valid email fails the meeting loudly and nothing is called. statusis a string; besidesSCHEDULED,COMPLETEDandCANCELLEDRaynet also returnsNEW. OnlySCHEDULEDwith nullcompletedis watched.- The detail has
company{id, name}andbusinessCase{id, code, name}, each possibly null. The consumer reads the contact from the business case note instead of assuming the meeting's person is the customer. - The deal detail (
GET /businessCase/{id}/) supplies the HTMLdescriptionused for contact extraction. The consumer does not infer contact identity from a deal's person reference. - Updates take
tagsas one comma-separated string (Raynet's update schema).
A record that does not match fails validation and cannot cause a call.
Verification
Focused database tests take the repository's shared PostgreSQL fixture, tests/helpers/pluginDb.mjs, which supplies the plugin database host binding and a fresh template clone per scenario; they run with a fake clock and mocked controls/Teams. Run them with vitest run --config tests/vitest.meetingConfirm.config.ts --maxWorkers=2 against the selected integration core inside the repository isolation boundary. Pure schedule and manifest tests remain on Node's test runner. Concurrent transactions cover agent versus fallback acceptance, restart recovery and settlement versus account removal. They cover weekdays and DST, deadlines, exact callbacks, all outcome branches, moved meetings, derived counts, refusal, slow reads, restart priority, atomic rollback, pending delivery retries, lost Raynet acknowledgement, account removal, orphan cleanup, the meeting conversation and its retry turns, the record tool (one write, a foreign callId, nothing to write, a superseded call), the grace deadline and failed turns, pilot restrictions, malformed raw records, a list without owners, owners without email, the deal-note contact with its untrusted-data prompt, missing deals and notes, unusable model answers, a missing model and the cached extraction. Fixtures mirror the live response shape with synthetic values.
The generic settings form still needs real browser verification at mobile and desktop widths in Czech, Slovak and English: save/reload, required-field errors, help, missing control dependencies and console errors. External audio, the Raynet write, Teams delivery and the agent's turn in the conversation still require the owner-only pilot.