kimaki 0.26.0 → 0.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/dist/agent-model.e2e.test.js +250 -164
- package/dist/anthropic-account-identity.js +9 -1
- package/dist/anthropic-auth-plugin.js +10 -13
- package/dist/anthropic-auth-plugin.test.js +17 -0
- package/dist/anthropic-auth-state.js +1 -0
- package/dist/bash-tool-schema-plugin.js +32 -0
- package/dist/bash-tool-schema-plugin.test.js +49 -0
- package/dist/bin.js +73 -19
- package/dist/cli-commands/maintenance.js +46 -7
- package/dist/cli-commands/project.js +11 -27
- package/dist/cli-commands/send.js +30 -1
- package/dist/cli-commands/session.js +322 -90
- package/dist/cli-commands/task.js +34 -15
- package/dist/cli-parsing.test.js +83 -4
- package/dist/cli-runner.js +44 -8
- package/dist/cli-runner.test.js +27 -0
- package/dist/cli-send-thread.e2e.test.js +9 -9
- package/dist/cli.js +32 -0
- package/dist/commands/abort.js +5 -1
- package/dist/commands/action-buttons.js +2 -1
- package/dist/commands/add-project.js +4 -7
- package/dist/commands/agent.js +164 -32
- package/dist/commands/ask-question.js +23 -33
- package/dist/commands/ask-question.test.js +43 -1
- package/dist/commands/btw.js +28 -6
- package/dist/commands/file-upload.js +2 -1
- package/dist/commands/fork-subagent.js +2 -4
- package/dist/commands/fork.js +4 -4
- package/dist/commands/last-sessions.js +17 -10
- package/dist/commands/login.js +6 -0
- package/dist/commands/merge-worktree.js +47 -14
- package/dist/commands/model-variant.js +8 -1
- package/dist/commands/model.js +16 -9
- package/dist/commands/multioauth.js +1 -0
- package/dist/commands/new-worktree.js +170 -102
- package/dist/commands/queue.js +3 -2
- package/dist/commands/resume.js +4 -4
- package/dist/commands/session.js +78 -63
- package/dist/commands/user-command.js +6 -8
- package/dist/commands/worktrees.js +5 -13
- package/dist/config.js +16 -1
- package/dist/cpu-profiler.js +211 -0
- package/dist/cpu-profiler.test.js +160 -0
- package/dist/database.js +152 -3
- package/dist/db.js +33 -0
- package/dist/db.test.js +267 -1
- package/dist/default-channel-provisioning.test.js +148 -0
- package/dist/discord-bot.js +517 -488
- package/dist/discord-command-registration.js +25 -10
- package/dist/discord-command-registration.test.js +17 -0
- package/dist/discord-utils.js +97 -5
- package/dist/discord-utils.test.js +151 -1
- package/dist/errors.js +10 -0
- package/dist/exec-async.js +17 -5
- package/dist/external-directory-permissions.e2e.test.js +11 -7
- package/dist/external-directory-project-deny.e2e.test.js +5 -3
- package/dist/external-opencode-sync.js +94 -17
- package/dist/external-opencode-sync.test.js +206 -0
- package/dist/extract-sdk-error-message.test.js +13 -0
- package/dist/file-edit-log.js +197 -0
- package/dist/file-edit-log.test.js +258 -0
- package/dist/format-tables.js +52 -0
- package/dist/format-tables.test.js +93 -0
- package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
- package/dist/gateway-proxy.e2e.test.js +18 -18
- package/dist/git-worktree-core.js +182 -17
- package/dist/interaction-handler.js +416 -398
- package/dist/ipc-tools-plugin.js +68 -3
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
- package/dist/kimaki-opencode-plugin.js +9 -3
- package/dist/kimaki-workspace-adaptor.js +33 -10
- package/dist/logger.js +11 -1
- package/dist/markdown.js +3 -1
- package/dist/markdown.test.js +11 -3
- package/dist/message-formatting.js +181 -51
- package/dist/message-formatting.test.js +529 -22
- package/dist/message-preprocessing.js +105 -1
- package/dist/oauth-rotation-shared.js +10 -0
- package/dist/openai-auth-plugin.js +1 -0
- package/dist/openai-auth-state.js +1 -0
- package/dist/opencode-command.js +28 -0
- package/dist/opencode-command.test.js +109 -1
- package/dist/opencode-interrupt-plugin.js +4 -0
- package/dist/opencode-interrupt-plugin.test.js +24 -0
- package/dist/opencode.js +82 -15
- package/dist/project-add.test.js +62 -0
- package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
- package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +150 -0
- package/dist/question-text-before-dropdown.e2e.test.js +52 -0
- package/dist/queue-advanced-abort.e2e.test.js +8 -10
- package/dist/queue-advanced-action-buttons.e2e.test.js +8 -8
- package/dist/queue-advanced-e2e-setup.js +78 -5
- package/dist/queue-advanced-footer.e2e.test.js +60 -269
- package/dist/queue-advanced-model-switch.e2e.test.js +9 -11
- package/dist/queue-advanced-permissions-typing.e2e.test.js +15 -15
- package/dist/queue-advanced-question.e2e.test.js +32 -27
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +10 -12
- package/dist/queue-advanced-typing.e2e.test.js +7 -9
- package/dist/queue-delete-message.e2e.test.js +3 -3
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +5 -5
- package/dist/queue-interrupt-drain.e2e.test.js +122 -12
- package/dist/queue-question-select-drain.e2e.test.js +34 -132
- package/dist/runtime-lifecycle.e2e.test.js +21 -51
- package/dist/schema.js +35 -1
- package/dist/session-handler/event-stream-state.js +391 -50
- package/dist/session-handler/event-stream-state.test.js +736 -8
- package/dist/session-handler/model-utils.js +172 -5
- package/dist/session-handler/model-utils.test.js +445 -0
- package/dist/session-handler/opencode-session-event-log.js +30 -1
- package/dist/session-handler/thread-ingress.test.js +41 -0
- package/dist/session-handler/thread-runtime-state.js +2 -0
- package/dist/session-handler/thread-session-runtime-summary.test.js +121 -0
- package/dist/session-handler/thread-session-runtime.js +574 -299
- package/dist/session-search.js +123 -4
- package/dist/session-search.test.js +248 -1
- package/dist/session-sleep.test.js +224 -0
- package/dist/session-title-rename.test.js +37 -39
- package/dist/sleep.e2e.test.js +494 -0
- package/dist/startup-time.e2e.test.js +2 -1
- package/dist/store.js +3 -0
- package/dist/system-message.js +102 -16
- package/dist/system-message.test.js +192 -15
- package/dist/task-runner.js +96 -5
- package/dist/task-schedule.js +87 -1
- package/dist/task-schedule.test.js +149 -1
- package/dist/test-utils.js +17 -9
- package/dist/thinking-utils.js +10 -0
- package/dist/thinking-utils.test.js +35 -0
- package/dist/thread-message-queue.e2e.test.js +65 -213
- package/dist/undo-redo.e2e.test.js +17 -9
- package/dist/voice-attachment.js +23 -1
- package/dist/voice-handler.js +21 -4
- package/dist/voice-message.e2e.test.js +144 -30
- package/dist/voice-routing-input.e2e.test.js +195 -0
- package/dist/voice-routing-question.e2e.test.js +125 -0
- package/dist/voice.js +28 -8
- package/dist/voice.test.js +96 -30
- package/dist/worktree-clone-isolation.e2e.test.js +188 -0
- package/dist/worktree-lifecycle.e2e.test.js +55 -36
- package/dist/worktrees.js +226 -443
- package/dist/worktrees.test.js +435 -58
- package/dist/xai-auth-plugin.js +1 -0
- package/dist/xai-auth-state.js +1 -0
- package/package.json +8 -6
- package/skills/bump-holocron-consumers/SKILL.md +137 -0
- package/skills/critique/SKILL.md +4 -4
- package/skills/errore/SKILL.md +60 -218
- package/skills/goke/SKILL.md +150 -10
- package/skills/holocron/SKILL.md +154 -17
- package/skills/npm-package/SKILL.md +55 -35
- package/skills/profano/SKILL.md +17 -2
- package/skills/spiceflow/SKILL.md +39 -3
- package/skills/strada/SKILL.md +39 -0
- package/skills/zele/SKILL.md +19 -0
- package/src/agent-model.e2e.test.ts +287 -186
- package/src/anthropic-account-identity.ts +20 -1
- package/src/anthropic-auth-plugin.test.ts +19 -0
- package/src/anthropic-auth-plugin.ts +12 -16
- package/src/anthropic-auth-state.ts +1 -0
- package/src/bash-tool-schema-plugin.test.ts +55 -0
- package/src/bash-tool-schema-plugin.ts +46 -0
- package/src/bin.ts +78 -26
- package/src/cli-commands/maintenance.ts +59 -13
- package/src/cli-commands/project.ts +14 -35
- package/src/cli-commands/send.ts +31 -1
- package/src/cli-commands/session.ts +374 -114
- package/src/cli-commands/task.ts +34 -15
- package/src/cli-parsing.test.ts +103 -4
- package/src/cli-runner.test.ts +49 -0
- package/src/cli-runner.ts +44 -7
- package/src/cli-send-thread.e2e.test.ts +9 -11
- package/src/cli.ts +53 -0
- package/src/commands/abort.ts +6 -1
- package/src/commands/action-buttons.ts +2 -1
- package/src/commands/add-project.ts +4 -8
- package/src/commands/agent.ts +205 -29
- package/src/commands/ask-question.test.ts +48 -1
- package/src/commands/ask-question.ts +23 -33
- package/src/commands/btw.ts +40 -5
- package/src/commands/file-upload.ts +2 -1
- package/src/commands/fork-subagent.ts +2 -3
- package/src/commands/fork.ts +4 -3
- package/src/commands/last-sessions.ts +10 -4
- package/src/commands/login.ts +6 -0
- package/src/commands/merge-worktree.ts +54 -16
- package/src/commands/model-variant.ts +9 -1
- package/src/commands/model.ts +20 -9
- package/src/commands/multioauth.ts +1 -0
- package/src/commands/new-worktree.ts +138 -55
- package/src/commands/queue.ts +3 -2
- package/src/commands/resume.ts +4 -3
- package/src/commands/session.ts +94 -70
- package/src/commands/user-command.ts +6 -6
- package/src/commands/worktrees.ts +5 -12
- package/src/config.ts +18 -1
- package/src/cpu-profiler.test.ts +183 -0
- package/src/cpu-profiler.ts +293 -0
- package/src/database.ts +235 -3
- package/src/db.test.ts +293 -0
- package/src/db.ts +38 -0
- package/src/default-channel-provisioning.test.ts +155 -0
- package/src/discord-bot.ts +78 -36
- package/src/discord-command-registration.test.ts +20 -0
- package/src/discord-command-registration.ts +42 -12
- package/src/discord-utils.test.ts +176 -0
- package/src/discord-utils.ts +152 -7
- package/src/errors.ts +12 -0
- package/src/exec-async.ts +20 -11
- package/src/external-directory-permissions.e2e.test.ts +11 -7
- package/src/external-directory-project-deny.e2e.test.ts +5 -3
- package/src/external-opencode-sync.test.ts +248 -0
- package/src/external-opencode-sync.ts +122 -19
- package/src/extract-sdk-error-message.test.ts +18 -0
- package/src/file-edit-log.test.ts +287 -0
- package/src/file-edit-log.ts +283 -0
- package/src/format-tables.test.ts +101 -0
- package/src/format-tables.ts +75 -0
- package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
- package/src/gateway-proxy.e2e.test.ts +18 -17
- package/src/git-worktree-core.ts +247 -20
- package/src/interaction-handler.ts +36 -13
- package/src/ipc-tools-plugin.ts +82 -5
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
- package/src/kimaki-opencode-plugin.ts +9 -3
- package/src/kimaki-workspace-adaptor.ts +40 -11
- package/src/logger.ts +14 -4
- package/src/markdown.test.ts +13 -3
- package/src/markdown.ts +4 -1
- package/src/message-formatting.test.ts +672 -25
- package/src/message-formatting.ts +242 -53
- package/src/message-preprocessing.ts +116 -1
- package/src/oauth-rotation-shared.ts +10 -0
- package/src/openai-auth-plugin.ts +1 -0
- package/src/openai-auth-state.ts +1 -0
- package/src/opencode-command.test.ts +140 -0
- package/src/opencode-command.ts +38 -0
- package/src/opencode-interrupt-plugin.test.ts +32 -0
- package/src/opencode-interrupt-plugin.ts +3 -0
- package/src/opencode.ts +115 -15
- package/src/project-add.test.ts +80 -0
- package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
- package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +172 -0
- package/src/question-text-before-dropdown.e2e.test.ts +67 -0
- package/src/queue-advanced-abort.e2e.test.ts +8 -9
- package/src/queue-advanced-action-buttons.e2e.test.ts +8 -8
- package/src/queue-advanced-e2e-setup.ts +83 -4
- package/src/queue-advanced-footer.e2e.test.ts +67 -315
- package/src/queue-advanced-model-switch.e2e.test.ts +9 -10
- package/src/queue-advanced-permissions-typing.e2e.test.ts +15 -15
- package/src/queue-advanced-question.e2e.test.ts +36 -30
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +11 -11
- package/src/queue-advanced-typing.e2e.test.ts +8 -8
- package/src/queue-delete-message.e2e.test.ts +3 -3
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +5 -5
- package/src/queue-interrupt-drain.e2e.test.ts +133 -12
- package/src/queue-question-select-drain.e2e.test.ts +36 -161
- package/src/runtime-lifecycle.e2e.test.ts +21 -54
- package/src/schema.sql +15 -2
- package/src/schema.ts +37 -1
- package/src/session-handler/event-stream-state.test.ts +790 -7
- package/src/session-handler/event-stream-state.ts +568 -53
- package/src/session-handler/model-utils.test.ts +537 -0
- package/src/session-handler/model-utils.ts +281 -5
- package/src/session-handler/opencode-session-event-log.ts +31 -1
- package/src/session-handler/thread-ingress.test.ts +54 -0
- package/src/session-handler/thread-runtime-state.ts +3 -0
- package/src/session-handler/thread-session-runtime-summary.test.ts +169 -0
- package/src/session-handler/thread-session-runtime.ts +712 -358
- package/src/session-search.test.ts +281 -0
- package/src/session-search.ts +241 -7
- package/src/session-sleep.test.ts +294 -0
- package/src/session-title-rename.test.ts +54 -48
- package/src/sleep.e2e.test.ts +565 -0
- package/src/startup-time.e2e.test.ts +5 -1
- package/src/store.ts +22 -0
- package/src/system-message.test.ts +226 -15
- package/src/system-message.ts +130 -16
- package/src/task-runner.ts +138 -3
- package/src/task-schedule.test.ts +170 -0
- package/src/task-schedule.ts +141 -1
- package/src/test-utils.ts +21 -10
- package/src/thinking-utils.test.ts +46 -0
- package/src/thinking-utils.ts +21 -0
- package/src/thread-message-queue.e2e.test.ts +65 -245
- package/src/undo-redo.e2e.test.ts +17 -9
- package/src/voice-attachment.ts +29 -1
- package/src/voice-handler.ts +23 -4
- package/src/voice-message.e2e.test.ts +149 -29
- package/src/voice-routing-input.e2e.test.ts +203 -0
- package/src/voice-routing-question.e2e.test.ts +131 -0
- package/src/voice.test.ts +107 -29
- package/src/voice.ts +34 -6
- package/src/worktree-clone-isolation.e2e.test.ts +213 -0
- package/src/worktree-lifecycle.e2e.test.ts +55 -35
- package/src/worktrees.test.ts +505 -64
- package/src/worktrees.ts +257 -603
- package/src/xai-auth-plugin.ts +1 -0
- package/src/xai-auth-state.ts +1 -0
- package/dist/message-finish-field.e2e.test.js +0 -169
- package/dist/subagent-rate-limit-plugin.js +0 -184
- package/src/message-finish-field.e2e.test.ts +0 -196
- package/src/subagent-rate-limit-plugin.ts +0 -228
package/dist/database.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
// Stores thread-session mappings, bot tokens, channel directories,
|
|
3
3
|
// API keys, and model preferences in <dataDir>/discord-sessions.db.
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import * as errore from 'errore';
|
|
5
8
|
import * as orm from 'drizzle-orm';
|
|
6
9
|
import { getDb, closeDb } from './db.js';
|
|
7
10
|
import { createLogger, LogPrefix } from './logger.js';
|
|
@@ -213,6 +216,104 @@ export async function setSessionStartSource({ sessionId, scheduleKind, scheduled
|
|
|
213
216
|
set: { schedule_kind: scheduleKind, scheduled_task_id: scheduledTaskId ?? null, updated_at: new Date() },
|
|
214
217
|
});
|
|
215
218
|
}
|
|
219
|
+
export async function upsertSessionSleep({ sessionId, wakeAt, reason, }) {
|
|
220
|
+
const db = await getDb();
|
|
221
|
+
// A new occurrence gets a fresh delivery_id, which invalidates any in-flight
|
|
222
|
+
// wake for the previous occurrence: every delivery write is guarded on it.
|
|
223
|
+
const deliveryId = crypto.randomUUID();
|
|
224
|
+
await db.insert(schema.session_sleeps)
|
|
225
|
+
.values({
|
|
226
|
+
session_id: sessionId,
|
|
227
|
+
wake_at: wakeAt,
|
|
228
|
+
reason: reason ?? null,
|
|
229
|
+
status: 'planned',
|
|
230
|
+
delivery_id: deliveryId,
|
|
231
|
+
})
|
|
232
|
+
.onConflictDoUpdate({
|
|
233
|
+
target: schema.session_sleeps.session_id,
|
|
234
|
+
set: {
|
|
235
|
+
wake_at: wakeAt,
|
|
236
|
+
reason: reason ?? null,
|
|
237
|
+
status: 'planned',
|
|
238
|
+
delivery_id: deliveryId,
|
|
239
|
+
attempts: 0,
|
|
240
|
+
last_attempt_at: null,
|
|
241
|
+
created_at: new Date(),
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
return deliveryId;
|
|
245
|
+
}
|
|
246
|
+
export async function getSessionSleep({ sessionId }) {
|
|
247
|
+
const db = await getDb();
|
|
248
|
+
return await db.query.session_sleeps.findFirst({
|
|
249
|
+
where: { session_id: sessionId },
|
|
250
|
+
}) ?? null;
|
|
251
|
+
}
|
|
252
|
+
/** Rows that are due and not attempted too recently. Status stays `planned`. */
|
|
253
|
+
export async function getDueSessionSleeps({ now, retryAfterMs, limit, }) {
|
|
254
|
+
const db = await getDb();
|
|
255
|
+
const retryBefore = new Date(now.getTime() - retryAfterMs);
|
|
256
|
+
return db.select()
|
|
257
|
+
.from(schema.session_sleeps)
|
|
258
|
+
.where(orm.and(orm.eq(schema.session_sleeps.status, 'planned'), orm.lte(schema.session_sleeps.wake_at, now), orm.or(orm.isNull(schema.session_sleeps.last_attempt_at), orm.lte(schema.session_sleeps.last_attempt_at, retryBefore))))
|
|
259
|
+
.orderBy(orm.asc(schema.session_sleeps.wake_at))
|
|
260
|
+
.limit(limit);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Reserve one delivery attempt. The row never leaves `planned` here.
|
|
264
|
+
*
|
|
265
|
+
* Only `last_attempt_at` moves, which keeps concurrent ticks off the same row
|
|
266
|
+
* for retryAfterMs. Because posting does not change the status either, one
|
|
267
|
+
* retry path covers every loss: dying before the post, dying after it, or
|
|
268
|
+
* never receiving the gateway event for the message we posted.
|
|
269
|
+
*/
|
|
270
|
+
export async function claimSessionSleepAttempt({ sessionId, deliveryId, now, retryAfterMs, }) {
|
|
271
|
+
const db = await getDb();
|
|
272
|
+
const retryBefore = new Date(now.getTime() - retryAfterMs);
|
|
273
|
+
const rows = await db.update(schema.session_sleeps)
|
|
274
|
+
.set({ last_attempt_at: now, attempts: orm.sql `${schema.session_sleeps.attempts} + 1` })
|
|
275
|
+
.where(orm.and(orm.eq(schema.session_sleeps.session_id, sessionId), orm.eq(schema.session_sleeps.delivery_id, deliveryId), orm.eq(schema.session_sleeps.status, 'planned'), orm.lte(schema.session_sleeps.wake_at, now), orm.or(orm.isNull(schema.session_sleeps.last_attempt_at), orm.lte(schema.session_sleeps.last_attempt_at, retryBefore))))
|
|
276
|
+
.returning();
|
|
277
|
+
return rows[0] ?? null;
|
|
278
|
+
}
|
|
279
|
+
export async function markSessionSleepFailed({ sessionId, deliveryId, }) {
|
|
280
|
+
const db = await getDb();
|
|
281
|
+
const rows = await db.update(schema.session_sleeps)
|
|
282
|
+
.set({ status: 'failed' })
|
|
283
|
+
.where(orm.and(orm.eq(schema.session_sleeps.session_id, sessionId), orm.eq(schema.session_sleeps.delivery_id, deliveryId), orm.eq(schema.session_sleeps.status, 'planned')))
|
|
284
|
+
.returning({ session_id: schema.session_sleeps.session_id });
|
|
285
|
+
return countRows(rows) > 0;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Ingress commit point. Marks the wake as delivered to the session.
|
|
289
|
+
*
|
|
290
|
+
* Returns false when the row is missing or no longer `planned`, which is how a
|
|
291
|
+
* wake that raced a user cancellation, or a duplicate of one already delivered,
|
|
292
|
+
* gets dropped instead of starting a turn.
|
|
293
|
+
*/
|
|
294
|
+
export async function consumeSessionSleepWake({ deliveryId }) {
|
|
295
|
+
const db = await getDb();
|
|
296
|
+
const rows = await db.update(schema.session_sleeps)
|
|
297
|
+
.set({ status: 'consumed' })
|
|
298
|
+
.where(orm.and(orm.eq(schema.session_sleeps.delivery_id, deliveryId), orm.eq(schema.session_sleeps.status, 'planned')))
|
|
299
|
+
.returning({ session_id: schema.session_sleeps.session_id });
|
|
300
|
+
return countRows(rows) > 0;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Cancel the pending sleep of the session currently bound to a thread.
|
|
304
|
+
*
|
|
305
|
+
* Runs on every ingress, so the binding is resolved with a correlated subquery
|
|
306
|
+
* rather than a second round trip. thread_id is the primary key of
|
|
307
|
+
* thread_sessions, so it yields at most one session.
|
|
308
|
+
*/
|
|
309
|
+
export async function cancelSessionSleepForThread({ threadId }) {
|
|
310
|
+
const db = await getDb();
|
|
311
|
+
const rows = await db.update(schema.session_sleeps)
|
|
312
|
+
.set({ status: 'cancelled' })
|
|
313
|
+
.where(orm.and(orm.eq(schema.session_sleeps.status, 'planned'), orm.eq(schema.session_sleeps.session_id, orm.sql `(SELECT ${schema.thread_sessions.session_id} FROM ${schema.thread_sessions} WHERE ${schema.thread_sessions.thread_id} = ${threadId})`)))
|
|
314
|
+
.returning({ session_id: schema.session_sleeps.session_id });
|
|
315
|
+
return countRows(rows) > 0;
|
|
316
|
+
}
|
|
216
317
|
export async function getSessionStartSourcesBySessionIds(sessionIds) {
|
|
217
318
|
if (sessionIds.length === 0)
|
|
218
319
|
return new Map();
|
|
@@ -226,6 +327,12 @@ export async function getSessionStartSourcesBySessionIds(sessionIds) {
|
|
|
226
327
|
}
|
|
227
328
|
return new Map(rows.map((row) => [row.session_id, row]));
|
|
228
329
|
}
|
|
330
|
+
export async function getSessionStartSource({ sessionId }) {
|
|
331
|
+
const db = await getDb();
|
|
332
|
+
return await db.query.session_start_sources.findFirst({
|
|
333
|
+
where: { session_id: sessionId },
|
|
334
|
+
}) ?? null;
|
|
335
|
+
}
|
|
229
336
|
export async function getChannelModel(channelId) {
|
|
230
337
|
const db = await getDb();
|
|
231
338
|
const row = await db.query.channel_models.findFirst({ where: { channel_id: channelId } });
|
|
@@ -440,9 +547,18 @@ export async function setThreadSession(threadId, sessionId) {
|
|
|
440
547
|
}
|
|
441
548
|
export async function upsertThreadSession({ threadId, sessionId, source }) {
|
|
442
549
|
const db = await getDb();
|
|
550
|
+
// updated_at is written explicitly on BOTH paths, never left to the column's
|
|
551
|
+
// CURRENT_TIMESTAMP default. The default stores "YYYY-MM-DD HH:MM:SS" while
|
|
552
|
+
// Date values store ISO with a "T", and those two text formats do not sort
|
|
553
|
+
// against each other correctly. Writing ISO everywhere keeps the ordering in
|
|
554
|
+
// getThreadIdBySessionId honest and gives it millisecond resolution.
|
|
555
|
+
const boundAt = new Date();
|
|
443
556
|
await db.insert(schema.thread_sessions)
|
|
444
|
-
.values({ thread_id: threadId, session_id: sessionId, source })
|
|
445
|
-
.onConflictDoUpdate({
|
|
557
|
+
.values({ thread_id: threadId, session_id: sessionId, source, updated_at: boundAt })
|
|
558
|
+
.onConflictDoUpdate({
|
|
559
|
+
target: schema.thread_sessions.thread_id,
|
|
560
|
+
set: { session_id: sessionId, source, updated_at: boundAt },
|
|
561
|
+
});
|
|
446
562
|
}
|
|
447
563
|
export async function getThreadParentSessionId(threadId) {
|
|
448
564
|
const db = await getDb();
|
|
@@ -462,9 +578,23 @@ export async function getThreadSessionSource(threadId) {
|
|
|
462
578
|
const db = await getDb();
|
|
463
579
|
return (await db.query.thread_sessions.findFirst({ where: { thread_id: threadId }, columns: { source: true } }))?.source;
|
|
464
580
|
}
|
|
581
|
+
/**
|
|
582
|
+
* Reverse lookup of the Discord thread that currently owns an OpenCode session.
|
|
583
|
+
*
|
|
584
|
+
* `thread_sessions.session_id` is NOT unique: /resume binds an existing session
|
|
585
|
+
* to a brand new thread without clearing the old row. An unordered findFirst
|
|
586
|
+
* therefore returns an arbitrary (often dead) thread, which made plugin tools
|
|
587
|
+
* post into the wrong conversation. Order by the most recent binding instead,
|
|
588
|
+
* falling back to created_at for rows written before updated_at existed.
|
|
589
|
+
*/
|
|
465
590
|
export async function getThreadIdBySessionId(sessionId) {
|
|
466
591
|
const db = await getDb();
|
|
467
|
-
|
|
592
|
+
const rows = await db.select({ thread_id: schema.thread_sessions.thread_id })
|
|
593
|
+
.from(schema.thread_sessions)
|
|
594
|
+
.where(orm.eq(schema.thread_sessions.session_id, sessionId))
|
|
595
|
+
.orderBy(orm.desc(orm.sql `COALESCE(${schema.thread_sessions.updated_at}, ${schema.thread_sessions.created_at})`))
|
|
596
|
+
.limit(1);
|
|
597
|
+
return rows[0]?.thread_id;
|
|
468
598
|
}
|
|
469
599
|
export async function getAllThreadSessionIds() {
|
|
470
600
|
const db = await getDb();
|
|
@@ -671,6 +801,25 @@ export async function findChannelsByDirectory({ directory, channelType }) {
|
|
|
671
801
|
: undefined;
|
|
672
802
|
return db.query.channel_directories.findMany({ where, columns: { channel_id: true, directory: true, channel_type: true, guild_id: true } });
|
|
673
803
|
}
|
|
804
|
+
function canonicalizeDirectory(directory) {
|
|
805
|
+
const resolved = path.resolve(directory);
|
|
806
|
+
const real = errore.try(() => fs.realpathSync(resolved), (e) => new Error('realpath failed', { cause: e }));
|
|
807
|
+
if (real instanceof Error)
|
|
808
|
+
return resolved;
|
|
809
|
+
return real;
|
|
810
|
+
}
|
|
811
|
+
export async function findRegisteredTextChannelForDirectory(directory) {
|
|
812
|
+
const wanted = canonicalizeDirectory(directory);
|
|
813
|
+
const channels = await findChannelsByDirectory({ channelType: 'text' });
|
|
814
|
+
return channels.find((ch) => canonicalizeDirectory(ch.directory) === wanted) ?? null;
|
|
815
|
+
}
|
|
816
|
+
export function formatProjectAlreadyRegisteredError({ channelId, directory, }) {
|
|
817
|
+
return [
|
|
818
|
+
`Channel already exists for this directory: ${directory}`,
|
|
819
|
+
`Channel ID: ${channelId}`,
|
|
820
|
+
`Remove the mapping first: kimaki project remove ${channelId}`,
|
|
821
|
+
].join('\n');
|
|
822
|
+
}
|
|
674
823
|
export async function getAllTextChannelDirectories() {
|
|
675
824
|
const db = await getDb();
|
|
676
825
|
const rows = await db.query.channel_directories.findMany({ where: { channel_type: 'text' }, columns: { directory: true } });
|
package/dist/db.js
CHANGED
|
@@ -89,6 +89,20 @@ async function initializeDb() {
|
|
|
89
89
|
dbInstance = db;
|
|
90
90
|
return db;
|
|
91
91
|
}
|
|
92
|
+
// CREATE TABLE IF NOT EXISTS cannot drop columns. The first two session_sleeps
|
|
93
|
+
// shapes kept thread_id NOT NULL and posted_at. Inserts now omit thread_id, so
|
|
94
|
+
// existing DBs fail with SQLITE_CONSTRAINT_NOTNULL until those columns go away.
|
|
95
|
+
async function dropLegacySessionSleepsColumns(client) {
|
|
96
|
+
const info = await client.execute('PRAGMA table_info(session_sleeps)');
|
|
97
|
+
const columns = new Set(info.rows.map((row) => String(row.name)));
|
|
98
|
+
if (columns.has('thread_id')) {
|
|
99
|
+
await client.execute('ALTER TABLE session_sleeps DROP COLUMN thread_id');
|
|
100
|
+
}
|
|
101
|
+
if (columns.has('posted_at')) {
|
|
102
|
+
await client.execute('ALTER TABLE session_sleeps DROP COLUMN posted_at');
|
|
103
|
+
}
|
|
104
|
+
await client.execute("UPDATE session_sleeps SET status = 'planned' WHERE status = 'posted'");
|
|
105
|
+
}
|
|
92
106
|
async function migrateSchema({ db, client, }) {
|
|
93
107
|
const schemaPath = path.join(__dirname, '../src/schema.sql');
|
|
94
108
|
const sql = fs.readFileSync(schemaPath, 'utf-8');
|
|
@@ -120,7 +134,12 @@ async function migrateSchema({ db, client, }) {
|
|
|
120
134
|
"ALTER TABLE thread_sessions ADD COLUMN source TEXT DEFAULT 'kimaki'",
|
|
121
135
|
'ALTER TABLE thread_sessions ADD COLUMN last_synced_name TEXT',
|
|
122
136
|
'ALTER TABLE thread_sessions ADD COLUMN parent_session_id TEXT',
|
|
137
|
+
'ALTER TABLE thread_sessions ADD COLUMN updated_at DATETIME',
|
|
123
138
|
'ALTER TABLE channel_directories ADD COLUMN guild_id TEXT',
|
|
139
|
+
// First session_sleeps shape had thread_id only. Later ticks query these.
|
|
140
|
+
'ALTER TABLE session_sleeps ADD COLUMN delivery_id TEXT',
|
|
141
|
+
'ALTER TABLE session_sleeps ADD COLUMN attempts INTEGER DEFAULT 0',
|
|
142
|
+
'ALTER TABLE session_sleeps ADD COLUMN last_attempt_at DATETIME',
|
|
124
143
|
];
|
|
125
144
|
for (const stmt of alterStatements) {
|
|
126
145
|
await client.execute(stmt).catch(() => undefined);
|
|
@@ -140,10 +159,24 @@ async function migrateSchema({ db, client, }) {
|
|
|
140
159
|
"UPDATE bot_tokens SET bot_mode = 'self_hosted' WHERE bot_mode = 'self-hosted'",
|
|
141
160
|
"UPDATE bot_tokens SET proxy_url = REPLACE(proxy_url, 'discord-gateway.kimaki.xyz', 'discord-gateway.kimaki.dev') WHERE bot_mode = 'gateway' AND proxy_url LIKE '%discord-gateway.kimaki.xyz%'",
|
|
142
161
|
"UPDATE thread_worktrees SET status = 'pending' WHERE status IS NULL",
|
|
162
|
+
"UPDATE session_sleeps SET delivery_id = lower(hex(randomblob(16))) WHERE delivery_id IS NULL",
|
|
163
|
+
'UPDATE session_sleeps SET attempts = 0 WHERE attempts IS NULL',
|
|
143
164
|
];
|
|
144
165
|
for (const stmt of migrationStatements) {
|
|
145
166
|
await client.execute(stmt).catch(() => undefined);
|
|
146
167
|
}
|
|
168
|
+
// Existing part_messages tables never get a rewritten FK. Drop orphans.
|
|
169
|
+
// NOT EXISTS, not NOT IN: a NULL thread_sessions.thread_id would make
|
|
170
|
+
// NOT IN unknown for every row and skip the whole cleanup.
|
|
171
|
+
await client.execute(`
|
|
172
|
+
DELETE FROM part_messages
|
|
173
|
+
WHERE NOT EXISTS (
|
|
174
|
+
SELECT 1
|
|
175
|
+
FROM thread_sessions
|
|
176
|
+
WHERE thread_sessions.thread_id = part_messages.thread_id
|
|
177
|
+
)
|
|
178
|
+
`);
|
|
179
|
+
await dropLegacySessionSleepsColumns(client);
|
|
147
180
|
// Migrate legacy thread_worktrees rows into thread_workspaces.
|
|
148
181
|
// Rows that already exist in thread_workspaces (by thread_id) are skipped.
|
|
149
182
|
await client.execute(`
|
package/dist/db.test.js
CHANGED
|
@@ -7,7 +7,8 @@ import { afterAll, describe, expect, test } from 'vitest';
|
|
|
7
7
|
import { closeDb, getDb } from './db.js';
|
|
8
8
|
import * as orm from 'drizzle-orm';
|
|
9
9
|
import * as schema from './schema.js';
|
|
10
|
-
import { appendSessionEventsSinceLastTimestamp, createPendingWorkspace, getSessionEventSnapshot, getSessionModel, setSessionModel, } from './database.js';
|
|
10
|
+
import { appendSessionEventsSinceLastTimestamp, createPendingWorkspace, getDueSessionSleeps, getSessionEventSnapshot, getSessionModel, getSessionSleep, setSessionModel, upsertSessionSleep, } from './database.js';
|
|
11
|
+
import { createClient } from '@libsql/client';
|
|
11
12
|
import { startHranaServer, stopHranaServer } from './hrana-server.js';
|
|
12
13
|
import { chooseLockPort } from './test-utils.js';
|
|
13
14
|
import { copyCurrentSessionModel } from './commands/model.js';
|
|
@@ -15,6 +16,271 @@ afterAll(async () => {
|
|
|
15
16
|
await closeDb();
|
|
16
17
|
});
|
|
17
18
|
describe('getDb', () => {
|
|
19
|
+
test('schema.sql creates every drizzle table', () => {
|
|
20
|
+
const schemaTs = fs.readFileSync(path.join(import.meta.dirname, 'schema.ts'), 'utf8');
|
|
21
|
+
const schemaSql = fs.readFileSync(path.join(import.meta.dirname, 'schema.sql'), 'utf8');
|
|
22
|
+
const tablesFromTs = [
|
|
23
|
+
...schemaTs.matchAll(/sqliteTable\('([^']+)'/g),
|
|
24
|
+
].map((match) => match[1]);
|
|
25
|
+
const tablesFromSql = [
|
|
26
|
+
...schemaSql.matchAll(/CREATE TABLE IF NOT EXISTS `([^`]+)`/g),
|
|
27
|
+
].map((match) => match[1]);
|
|
28
|
+
expect(new Set(tablesFromSql)).toEqual(new Set(tablesFromTs));
|
|
29
|
+
expect(tablesFromSql).toContain('session_sleeps');
|
|
30
|
+
});
|
|
31
|
+
test('removes part_messages rows whose thread_sessions parent is gone', async () => {
|
|
32
|
+
await closeDb();
|
|
33
|
+
const previousDbUrl = process.env['KIMAKI_DB_URL'];
|
|
34
|
+
const dbPath = path.join(process.cwd(), `tmp/test-db-orphan-parts-${crypto.randomUUID().slice(0, 8)}.db`);
|
|
35
|
+
try {
|
|
36
|
+
const client = createClient({ url: `file:${dbPath}` });
|
|
37
|
+
await client.execute(`
|
|
38
|
+
CREATE TABLE thread_sessions (
|
|
39
|
+
thread_id text PRIMARY KEY,
|
|
40
|
+
session_id text NOT NULL
|
|
41
|
+
)
|
|
42
|
+
`);
|
|
43
|
+
await client.execute(`
|
|
44
|
+
CREATE TABLE part_messages (
|
|
45
|
+
part_id text PRIMARY KEY,
|
|
46
|
+
message_id text NOT NULL,
|
|
47
|
+
thread_id text NOT NULL,
|
|
48
|
+
created_at datetime DEFAULT CURRENT_TIMESTAMP,
|
|
49
|
+
CONSTRAINT fk_part_messages_thread_id_thread_sessions_thread_id_fk
|
|
50
|
+
FOREIGN KEY (thread_id) REFERENCES thread_sessions(thread_id) ON UPDATE CASCADE
|
|
51
|
+
)
|
|
52
|
+
`);
|
|
53
|
+
await client.execute(`
|
|
54
|
+
INSERT INTO thread_sessions (thread_id, session_id)
|
|
55
|
+
VALUES ('thr-keep', 'ses-keep'), ('thr-gone', 'ses-gone')
|
|
56
|
+
`);
|
|
57
|
+
await client.execute(`
|
|
58
|
+
INSERT INTO part_messages (part_id, message_id, thread_id)
|
|
59
|
+
VALUES
|
|
60
|
+
('part-keep', 'msg-keep', 'thr-keep'),
|
|
61
|
+
('part-orphan', 'msg-orphan', 'thr-gone')
|
|
62
|
+
`);
|
|
63
|
+
// sqlite3 CLI leaves foreign_keys OFF, which is how real installs
|
|
64
|
+
// accumulate these orphans. libsql defaults to ON.
|
|
65
|
+
await client.execute('PRAGMA foreign_keys = OFF');
|
|
66
|
+
await client.execute(`DELETE FROM thread_sessions WHERE thread_id = 'thr-gone'`);
|
|
67
|
+
await client.execute(`
|
|
68
|
+
INSERT INTO thread_sessions (thread_id, session_id)
|
|
69
|
+
VALUES (NULL, 'ses-null')
|
|
70
|
+
`);
|
|
71
|
+
await client.execute('PRAGMA foreign_keys = ON');
|
|
72
|
+
const before = await client.execute(`
|
|
73
|
+
SELECT COUNT(*) AS n FROM part_messages
|
|
74
|
+
WHERE NOT EXISTS (
|
|
75
|
+
SELECT 1 FROM thread_sessions
|
|
76
|
+
WHERE thread_sessions.thread_id = part_messages.thread_id
|
|
77
|
+
)
|
|
78
|
+
`);
|
|
79
|
+
expect(Number(before.rows[0]?.n)).toBe(1);
|
|
80
|
+
client.close();
|
|
81
|
+
process.env['KIMAKI_DB_URL'] = `file:${dbPath}`;
|
|
82
|
+
const db = await getDb();
|
|
83
|
+
const remaining = await db.query.part_messages.findMany({
|
|
84
|
+
columns: { part_id: true },
|
|
85
|
+
orderBy: { part_id: 'asc' },
|
|
86
|
+
});
|
|
87
|
+
expect(remaining).toMatchInlineSnapshot(`
|
|
88
|
+
[
|
|
89
|
+
{
|
|
90
|
+
"part_id": "part-keep",
|
|
91
|
+
},
|
|
92
|
+
]
|
|
93
|
+
`);
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
await closeDb();
|
|
97
|
+
if (previousDbUrl === undefined) {
|
|
98
|
+
delete process.env['KIMAKI_DB_URL'];
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
process.env['KIMAKI_DB_URL'] = previousDbUrl;
|
|
102
|
+
}
|
|
103
|
+
for (const file of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
|
|
104
|
+
try {
|
|
105
|
+
fs.unlinkSync(file);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Test cleanup best effort.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
test('deleting a thread_sessions row also deletes its part_messages', async () => {
|
|
114
|
+
const db = await getDb();
|
|
115
|
+
const threadId = `test-part-cascade-${crypto.randomUUID()}`;
|
|
116
|
+
await db.insert(schema.thread_sessions).values({
|
|
117
|
+
thread_id: threadId,
|
|
118
|
+
session_id: 'ses-part-cascade',
|
|
119
|
+
});
|
|
120
|
+
await db.insert(schema.part_messages).values({
|
|
121
|
+
part_id: `${threadId}-part`,
|
|
122
|
+
message_id: 'msg-part-cascade',
|
|
123
|
+
thread_id: threadId,
|
|
124
|
+
});
|
|
125
|
+
await db.delete(schema.thread_sessions).where(orm.eq(schema.thread_sessions.thread_id, threadId));
|
|
126
|
+
const leftover = await db.query.part_messages.findFirst({
|
|
127
|
+
where: { thread_id: threadId },
|
|
128
|
+
});
|
|
129
|
+
expect(leftover).toBeUndefined();
|
|
130
|
+
});
|
|
131
|
+
test('adds session_sleeps delivery columns on databases created before that schema', async () => {
|
|
132
|
+
await closeDb();
|
|
133
|
+
const previousDbUrl = process.env['KIMAKI_DB_URL'];
|
|
134
|
+
const dbPath = path.join(process.cwd(), `tmp/test-db-legacy-sleeps-${crypto.randomUUID().slice(0, 8)}.db`);
|
|
135
|
+
try {
|
|
136
|
+
const client = createClient({ url: `file:${dbPath}` });
|
|
137
|
+
await client.execute(`
|
|
138
|
+
CREATE TABLE thread_sessions (
|
|
139
|
+
thread_id text PRIMARY KEY,
|
|
140
|
+
session_id text NOT NULL
|
|
141
|
+
)
|
|
142
|
+
`);
|
|
143
|
+
await client.execute(`
|
|
144
|
+
CREATE TABLE session_sleeps (
|
|
145
|
+
session_id text PRIMARY KEY,
|
|
146
|
+
thread_id text NOT NULL,
|
|
147
|
+
wake_at datetime NOT NULL,
|
|
148
|
+
reason text,
|
|
149
|
+
status text DEFAULT 'planned' NOT NULL,
|
|
150
|
+
created_at datetime DEFAULT CURRENT_TIMESTAMP
|
|
151
|
+
)
|
|
152
|
+
`);
|
|
153
|
+
await client.execute(`
|
|
154
|
+
INSERT INTO thread_sessions (thread_id, session_id)
|
|
155
|
+
VALUES ('thr-legacy', 'ses-legacy')
|
|
156
|
+
`);
|
|
157
|
+
await client.execute(`
|
|
158
|
+
INSERT INTO session_sleeps (session_id, thread_id, wake_at, status)
|
|
159
|
+
VALUES ('ses-legacy', 'thr-legacy', '2020-01-01T00:00:00.000Z', 'planned')
|
|
160
|
+
`);
|
|
161
|
+
client.close();
|
|
162
|
+
process.env['KIMAKI_DB_URL'] = `file:${dbPath}`;
|
|
163
|
+
await getDb();
|
|
164
|
+
const due = await getDueSessionSleeps({
|
|
165
|
+
now: new Date('2026-08-21T00:00:00Z'),
|
|
166
|
+
retryAfterMs: 30_000,
|
|
167
|
+
limit: 10,
|
|
168
|
+
});
|
|
169
|
+
const legacy = due.find((row) => row.session_id === 'ses-legacy');
|
|
170
|
+
expect(legacy?.delivery_id).toBeTruthy();
|
|
171
|
+
expect(legacy?.attempts).toBe(0);
|
|
172
|
+
// The first table required thread_id. After the column was dropped from
|
|
173
|
+
// the schema, inserts that omit it must still work on existing databases.
|
|
174
|
+
await upsertSessionSleep({
|
|
175
|
+
sessionId: 'ses-legacy-new',
|
|
176
|
+
wakeAt: new Date('2026-08-26T00:00:00Z'),
|
|
177
|
+
reason: 'check the reply',
|
|
178
|
+
});
|
|
179
|
+
const created = await getSessionSleep({ sessionId: 'ses-legacy-new' });
|
|
180
|
+
expect(created?.status).toBe('planned');
|
|
181
|
+
expect(created?.reason).toBe('check the reply');
|
|
182
|
+
expect(created?.delivery_id).toBeTruthy();
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
await closeDb();
|
|
186
|
+
if (previousDbUrl === undefined) {
|
|
187
|
+
delete process.env['KIMAKI_DB_URL'];
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
process.env['KIMAKI_DB_URL'] = previousDbUrl;
|
|
191
|
+
}
|
|
192
|
+
for (const file of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
|
|
193
|
+
try {
|
|
194
|
+
fs.unlinkSync(file);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// Test cleanup best effort.
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
test('rebuilds session_sleeps that still have posted_at from the intermediate schema', async () => {
|
|
203
|
+
await closeDb();
|
|
204
|
+
const previousDbUrl = process.env['KIMAKI_DB_URL'];
|
|
205
|
+
const dbPath = path.join(process.cwd(), `tmp/test-db-posted-sleeps-${crypto.randomUUID().slice(0, 8)}.db`);
|
|
206
|
+
try {
|
|
207
|
+
const client = createClient({ url: `file:${dbPath}` });
|
|
208
|
+
await client.execute(`
|
|
209
|
+
CREATE TABLE thread_sessions (
|
|
210
|
+
thread_id text PRIMARY KEY,
|
|
211
|
+
session_id text NOT NULL
|
|
212
|
+
)
|
|
213
|
+
`);
|
|
214
|
+
await client.execute(`
|
|
215
|
+
CREATE TABLE session_sleeps (
|
|
216
|
+
session_id text PRIMARY KEY,
|
|
217
|
+
thread_id text NOT NULL,
|
|
218
|
+
wake_at datetime NOT NULL,
|
|
219
|
+
reason text,
|
|
220
|
+
status text DEFAULT 'planned' NOT NULL,
|
|
221
|
+
delivery_id text NOT NULL,
|
|
222
|
+
attempts integer DEFAULT 0 NOT NULL,
|
|
223
|
+
last_attempt_at datetime,
|
|
224
|
+
posted_at datetime,
|
|
225
|
+
created_at datetime DEFAULT CURRENT_TIMESTAMP
|
|
226
|
+
)
|
|
227
|
+
`);
|
|
228
|
+
await client.execute(`
|
|
229
|
+
INSERT INTO thread_sessions (thread_id, session_id)
|
|
230
|
+
VALUES ('thr-posted', 'ses-posted')
|
|
231
|
+
`);
|
|
232
|
+
await client.execute(`
|
|
233
|
+
INSERT INTO session_sleeps (
|
|
234
|
+
session_id, thread_id, wake_at, status, delivery_id, posted_at
|
|
235
|
+
)
|
|
236
|
+
VALUES (
|
|
237
|
+
'ses-posted',
|
|
238
|
+
'thr-posted',
|
|
239
|
+
'2020-01-01T00:00:00.000Z',
|
|
240
|
+
'posted',
|
|
241
|
+
'del-posted',
|
|
242
|
+
'2020-01-01T00:00:01.000Z'
|
|
243
|
+
)
|
|
244
|
+
`);
|
|
245
|
+
client.close();
|
|
246
|
+
process.env['KIMAKI_DB_URL'] = `file:${dbPath}`;
|
|
247
|
+
await getDb();
|
|
248
|
+
const due = await getDueSessionSleeps({
|
|
249
|
+
now: new Date('2026-08-21T00:00:00Z'),
|
|
250
|
+
retryAfterMs: 30_000,
|
|
251
|
+
limit: 10,
|
|
252
|
+
});
|
|
253
|
+
const posted = due.find((row) => row.session_id === 'ses-posted');
|
|
254
|
+
expect(posted?.status).toBe('planned');
|
|
255
|
+
expect(posted?.delivery_id).toBe('del-posted');
|
|
256
|
+
await upsertSessionSleep({
|
|
257
|
+
sessionId: 'ses-posted',
|
|
258
|
+
wakeAt: new Date('2026-08-26T00:00:00Z'),
|
|
259
|
+
reason: 'retry after schema change',
|
|
260
|
+
});
|
|
261
|
+
const updated = await getSessionSleep({ sessionId: 'ses-posted' });
|
|
262
|
+
expect(updated?.status).toBe('planned');
|
|
263
|
+
expect(updated?.reason).toBe('retry after schema change');
|
|
264
|
+
expect(updated?.delivery_id).not.toBe('del-posted');
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
await closeDb();
|
|
268
|
+
if (previousDbUrl === undefined) {
|
|
269
|
+
delete process.env['KIMAKI_DB_URL'];
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
process.env['KIMAKI_DB_URL'] = previousDbUrl;
|
|
273
|
+
}
|
|
274
|
+
for (const file of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
|
|
275
|
+
try {
|
|
276
|
+
fs.unlinkSync(file);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
// Test cleanup best effort.
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
});
|
|
18
284
|
test('creates sqlite file and migrates schema automatically', async () => {
|
|
19
285
|
const db = await getDb();
|
|
20
286
|
const [session] = await db.insert(schema.thread_sessions)
|