mulmoclaude 0.6.2 → 0.6.4
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/README.md +26 -0
- package/bin/mulmoclaude.js +11 -1
- package/client/assets/JsonEditor-D6WBWLoa.js +10 -0
- package/client/assets/JsonEditor-Di5xGeZY.css +1 -0
- package/client/assets/_plugin-vue_export-helper-BOai-rQB.js +1 -0
- package/client/assets/chunk-D8eiyYIV-LcKZGJv5.js +1 -0
- package/client/assets/{html2canvas-CDGcmOD3-Bkf2uOth.js → html2canvas-CDGcmOD3-XVrO-eyz.js} +1 -1
- package/client/assets/index-CyBr8Mkr.css +2 -0
- package/client/assets/index-zZIqEbNX.js +5106 -0
- package/client/assets/{index.es-DqtpmBm8-D9mAh_KQ.js → index.es-DqtpmBm8-DHT6q10o.js} +1 -1
- package/client/assets/material-symbols-outlined-DtIK7AQn.woff2 +0 -0
- package/client/assets/runtime-protocol-vue-D6kcV0wa.js +1 -0
- package/client/assets/{runtime-vue-BVUzgYGA.js → runtime-vue-fFYhnNg3.js} +1 -1
- package/client/assets/{vue-C8UuIO9J.js → vue-D4w8THF_.js} +1 -1
- package/client/assets/vue-i18n-CQbxVmNs.js +3 -0
- package/client/assets/vue.runtime.esm-bundler-BTyIdNAI.js +4 -0
- package/client/index.html +10 -10
- package/package.json +9 -8
- package/server/agent/backend/claude-code.ts +34 -0
- package/server/agent/backend/fake-echo.ts +370 -0
- package/server/agent/backend/index.ts +16 -1
- package/server/agent/config.ts +74 -24
- package/server/agent/index.ts +104 -80
- package/server/agent/mcpFailureMonitor.ts +167 -0
- package/server/agent/mcpPreflight.ts +185 -0
- package/server/agent/prompt.ts +50 -359
- package/server/agent/stdioHttpShim.ts +171 -0
- package/server/agent/stream.ts +12 -1
- package/server/api/routes/encore.ts +55 -0
- package/server/api/routes/files.ts +22 -0
- package/server/api/routes/mulmo-script.ts +19 -1
- package/server/api/routes/schedulerHandlers.ts +52 -4
- package/server/api/routes/sessions.ts +15 -0
- package/server/api/routes/skills.ts +263 -0
- package/server/build/dispatcher.mjs +299 -0
- package/server/encore/INVARIANTS.md +272 -0
- package/server/encore/boot.ts +39 -0
- package/server/encore/closure.ts +36 -0
- package/server/encore/cycle.ts +276 -0
- package/server/encore/dispatch.ts +103 -0
- package/server/encore/handlers/amend.ts +99 -0
- package/server/encore/handlers/appendNote.ts +74 -0
- package/server/encore/handlers/defineEncore.ts +42 -0
- package/server/encore/handlers/listTickets.ts +107 -0
- package/server/encore/handlers/markStepDone.ts +41 -0
- package/server/encore/handlers/markTargetSkipped.ts +33 -0
- package/server/encore/handlers/query.ts +138 -0
- package/server/encore/handlers/recordValues.ts +44 -0
- package/server/encore/handlers/resolveNotification.ts +121 -0
- package/server/encore/handlers/setup.ts +81 -0
- package/server/encore/handlers/shared.ts +137 -0
- package/server/encore/handlers/snooze.ts +87 -0
- package/server/encore/handlers/startObligationChat.ts +64 -0
- package/server/encore/handlers/startSetupChat.ts +50 -0
- package/server/encore/lock.ts +61 -0
- package/server/encore/notifier.ts +123 -0
- package/server/encore/obligation.ts +25 -0
- package/server/encore/paths.ts +78 -0
- package/server/encore/reconcile.ts +661 -0
- package/server/encore/tick.ts +191 -0
- package/server/encore/yaml-fm.ts +63 -0
- package/server/events/notifications.ts +19 -91
- package/server/index.ts +94 -9
- package/server/notifier/engine.ts +102 -1
- package/server/notifier/macosReminderAdapter.ts +30 -0
- package/server/notifier/runtime-api.ts +41 -1
- package/server/notifier/types.ts +15 -2
- package/server/plugins/runtime.ts +11 -2
- package/server/prompts/index.ts +39 -0
- package/server/prompts/system/journal-pointer.md +12 -0
- package/server/prompts/system/memory-management-atomic.md +33 -0
- package/server/prompts/system/memory-management-topic.md +60 -0
- package/server/prompts/system/news-concierge.md +24 -0
- package/server/prompts/system/sandbox-tools.md +10 -0
- package/server/prompts/system/sources-context.md +16 -0
- package/server/prompts/system/system.md +91 -0
- package/server/system/announceOptionalDeps.ts +57 -0
- package/server/system/appVersion.ts +34 -0
- package/server/system/config.ts +17 -1
- package/server/system/docker.ts +14 -6
- package/server/system/env.ts +18 -5
- package/server/system/optionalDeps.ts +129 -0
- package/server/utils/cli-flags.d.mts +14 -0
- package/server/utils/cli-flags.mjs +53 -0
- package/server/utils/files/encore-io.ts +111 -0
- package/server/utils/time.ts +6 -0
- package/server/workspace/helps/business.md +2 -2
- package/server/workspace/helps/encore-dsl.md +482 -0
- package/server/workspace/helps/index.md +15 -13
- package/server/workspace/helps/mulmoscript.md +3 -3
- package/server/workspace/helps/sandbox.md +2 -2
- package/server/workspace/hooks/dispatcher.ts +7 -5
- package/server/workspace/hooks/provision.ts +6 -3
- package/server/workspace/paths.ts +13 -4
- package/server/workspace/skills/catalog.ts +355 -0
- package/server/workspace/skills/external/catalog.ts +283 -0
- package/server/workspace/skills/external/clone.ts +129 -0
- package/server/workspace/skills/external/id.ts +194 -0
- package/server/workspace/skills/external/install.ts +417 -0
- package/server/workspace/skills/external/presets.ts +50 -0
- package/server/workspace/skills-preset.ts +29 -17
- package/server/workspace/workspace.ts +10 -5
- package/src/App.vue +37 -8
- package/src/components/FileContentRenderer.vue +102 -9
- package/src/components/JsonEditor.vue +160 -0
- package/src/components/NotificationBell.vue +35 -3
- package/src/components/PluginLauncher.vue +20 -41
- package/src/components/RightSidebar.vue +19 -0
- package/src/components/SettingsMcpTab.vue +58 -11
- package/src/components/SettingsModal.vue +22 -1
- package/src/components/StackView.vue +10 -1
- package/src/components/TodoExplorer.vue +16 -0
- package/src/components/todo/TodoKanbanView.vue +34 -6
- package/src/composables/useNotifications.ts +21 -1
- package/src/config/apiRoutes.ts +0 -6
- package/src/config/mcpCatalog.ts +12 -7
- package/src/config/mcpTypes.ts +5 -0
- package/src/config/roles.ts +52 -15
- package/src/config/systemFileDescriptors.ts +12 -0
- package/src/lang/de.ts +108 -12
- package/src/lang/en.ts +105 -11
- package/src/lang/es.ts +106 -11
- package/src/lang/fr.ts +106 -11
- package/src/lang/ja.ts +104 -11
- package/src/lang/ko.ts +105 -11
- package/src/lang/pt-BR.ts +106 -11
- package/src/lang/zh.ts +103 -11
- package/src/main.ts +1 -0
- package/src/plugins/_generated/metas.ts +4 -0
- package/src/plugins/_generated/registrations.ts +2 -0
- package/src/plugins/_generated/server-bindings.ts +5 -0
- package/src/plugins/encore/EncoreDashboard.vue +504 -0
- package/src/plugins/encore/EncoreRedirect.vue +116 -0
- package/src/plugins/encore/View.vue +36 -0
- package/src/plugins/encore/defineEncoreDefinition.ts +74 -0
- package/src/plugins/encore/defineEncoreMeta.ts +13 -0
- package/src/plugins/encore/index.ts +93 -0
- package/src/plugins/encore/manageEncoreDefinition.ts +100 -0
- package/src/plugins/encore/manageEncoreMeta.ts +36 -0
- package/src/plugins/manageSkills/View.vue +832 -30
- package/src/plugins/manageSkills/categories.ts +125 -0
- package/src/plugins/manageSkills/meta.ts +30 -0
- package/src/plugins/markdown/definition.ts +3 -3
- package/src/plugins/meta-types.ts +5 -0
- package/src/plugins/presentMulmoScript/Preview.vue +3 -3
- package/src/plugins/presentMulmoScript/View.vue +157 -33
- package/src/plugins/presentMulmoScript/meta.ts +4 -0
- package/src/plugins/scheduler/View.vue +45 -9
- package/src/plugins/scheduler/calendarDefinition.ts +6 -2
- package/src/plugins/scheduler/multiDayHelpers.ts +95 -0
- package/src/plugins/skill/View.vue +1 -5
- package/src/plugins/spreadsheet/View.vue +3 -3
- package/src/plugins/spreadsheet/definition.ts +1 -1
- package/src/plugins/textResponse/Preview.vue +14 -1
- package/src/plugins/textResponse/View.vue +39 -24
- package/src/plugins/wiki/components/WikiPageBody.vue +4 -0
- package/src/router/index.ts +11 -0
- package/src/router/pageRoutes.ts +1 -0
- package/src/types/encore-dsl/at-expression.ts +120 -0
- package/src/types/encore-dsl/at-resolver.ts +32 -0
- package/src/types/encore-dsl/cadence.ts +289 -0
- package/src/types/encore-dsl/schema.ts +288 -0
- package/src/types/notification.ts +2 -1
- package/src/types/session.ts +6 -0
- package/src/types/sse.ts +5 -0
- package/src/types/toolCallHistory.ts +7 -0
- package/src/utils/agent/eventDispatch.ts +26 -5
- package/src/utils/agent/mcpHint.ts +50 -0
- package/src/utils/image/htmlSrcAttrs.ts +117 -13
- package/src/utils/session/sessionEntries.ts +8 -32
- package/client/assets/PluginScopedRoot-YjvQq0Nn.js +0 -3
- package/client/assets/chunk-CernVdwh.js +0 -1
- package/client/assets/chunk-D8eiyYIV-CAXpUwLd.js +0 -1
- package/client/assets/index-BwrlMMHr.js +0 -5005
- package/client/assets/index-CvvNuegU.css +0 -2
- package/client/assets/material-symbols-outlined-BOZVWuR3.woff2 +0 -0
- package/client/assets/runtime-protocol-vue-C1To4M3t.js +0 -1
- package/client/assets/vue.runtime.esm-bundler-DQ8Kjjui.js +0 -4
- package/server/api/routes/notifications.ts +0 -195
- package/server/notifier/legacy-adapters.ts +0 -76
- package/server/workspace/hooks/dispatcher.mjs +0 -300
- package/src/composables/useSelectedResult.ts +0 -49
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// `recordValues` handler — record form values against a target
|
|
2
|
+
// without closing any step, then reconcile.
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
import { applyValues } from "../cycle.js";
|
|
7
|
+
import { log } from "../../system/logger/index.js";
|
|
8
|
+
import { assertKnownTargetAndStep, loadCycle, loadDsl, persistAndReconcile, workspaceRelativePath, type EncoreDispatchResult } from "./shared.js";
|
|
9
|
+
|
|
10
|
+
export const RecordValuesArgs = z.object({
|
|
11
|
+
kind: z.literal("recordValues"),
|
|
12
|
+
obligationId: z.string().trim().min(1),
|
|
13
|
+
cycleId: z.string().trim().min(1),
|
|
14
|
+
targetId: z.string().trim().min(1),
|
|
15
|
+
values: z.record(z.string(), z.unknown()),
|
|
16
|
+
pendingId: z.string().trim().min(1).optional(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export async function handleRecordValues(args: z.infer<typeof RecordValuesArgs>): Promise<EncoreDispatchResult> {
|
|
20
|
+
const dsl = await loadDsl(args.obligationId);
|
|
21
|
+
assertKnownTargetAndStep(dsl, args);
|
|
22
|
+
const { rel, state, body } = await loadCycle(args.obligationId, args.cycleId);
|
|
23
|
+
const nextState = applyValues(state, args.targetId, args.values);
|
|
24
|
+
// recordValues never closes anything, so the reconciler is a no-op
|
|
25
|
+
// for bells — but we still funnel through `persistAndReconcile`
|
|
26
|
+
// for uniformity (no special-case handler shape). The reconciler
|
|
27
|
+
// is cheap when nothing changed: Phase 1 sees the same live targets,
|
|
28
|
+
// Phase 2 sees the same covered keys, no notifier calls.
|
|
29
|
+
await persistAndReconcile(rel, nextState, body, args.obligationId, args.cycleId);
|
|
30
|
+
log.info("encore", "recordValues: values recorded", {
|
|
31
|
+
obligationId: args.obligationId,
|
|
32
|
+
cycleId: args.cycleId,
|
|
33
|
+
targetId: args.targetId,
|
|
34
|
+
keys: Object.keys(args.values),
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
ok: true,
|
|
38
|
+
message: `Encore: recorded ${Object.keys(args.values).length} value(s) on ${args.targetId} in cycle ${args.cycleId}.`,
|
|
39
|
+
obligationId: args.obligationId,
|
|
40
|
+
cycleId: args.cycleId,
|
|
41
|
+
targetId: args.targetId,
|
|
42
|
+
cyclePath: workspaceRelativePath(rel),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// `resolveNotification` handler — handle the bell-click path. Seeds
|
|
2
|
+
// a fresh chat session for an active ticket, or clears an orphan
|
|
3
|
+
// bell entry whose ticket was already swept.
|
|
4
|
+
//
|
|
5
|
+
// DOCUMENTED EXCEPTION to the reconciler-owns-the-bell rule:
|
|
6
|
+
// `handleOrphanResolve` calls `encoreNotifier.clear` directly because
|
|
7
|
+
// there's no ticket to reconcile against. See server/encore/INVARIANTS.md.
|
|
8
|
+
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
|
|
12
|
+
import { ticketPath } from "../paths.js";
|
|
13
|
+
import { readTextOrNull, writeText } from "../../utils/files/encore-io.js";
|
|
14
|
+
import * as encoreNotifier from "../notifier.js";
|
|
15
|
+
import { ENCORE_PLUGIN_PKG } from "../notifier.js";
|
|
16
|
+
import { startChat } from "../../api/routes/agent.js";
|
|
17
|
+
import { PLUGIN_SESSION_ORIGIN_PREFIX } from "../../../src/types/session.js";
|
|
18
|
+
import { ENCORE_SEED_ROLE_ID } from "../../../src/config/roles.js";
|
|
19
|
+
import { log } from "../../system/logger/index.js";
|
|
20
|
+
import type { Ticket } from "../tick.js";
|
|
21
|
+
import { EncoreError, type EncoreDispatchResult } from "./shared.js";
|
|
22
|
+
|
|
23
|
+
export const ResolveNotificationArgs = z.object({
|
|
24
|
+
kind: z.literal("resolveNotification"),
|
|
25
|
+
pendingId: z.string(),
|
|
26
|
+
/** Bell entry id, spliced onto the navigateTarget at click time
|
|
27
|
+
* by the host's NotificationBell.vue. Lets us clear orphan bell
|
|
28
|
+
* entries whose ticket was already swept. */
|
|
29
|
+
notificationId: z.string().optional(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
async function handleOrphanResolve(args: z.infer<typeof ResolveNotificationArgs>): Promise<EncoreDispatchResult> {
|
|
33
|
+
// The ticket was already swept (e.g. the LLM resolved the
|
|
34
|
+
// obligation in another chat before this click). Clear the bell
|
|
35
|
+
// entry so it disappears.
|
|
36
|
+
//
|
|
37
|
+
// DOCUMENTED EXCEPTION to the reconciler-owns-the-bell rule:
|
|
38
|
+
// there's no ticket to reconcile against, so the reconciler can't
|
|
39
|
+
// know the bell entry exists. Direct clear is the only way out.
|
|
40
|
+
let cleared = false;
|
|
41
|
+
if (args.notificationId) {
|
|
42
|
+
try {
|
|
43
|
+
await encoreNotifier.clear(args.notificationId);
|
|
44
|
+
cleared = true;
|
|
45
|
+
} catch (err) {
|
|
46
|
+
log.warn("encore", "resolveNotification: orphan clear failed", {
|
|
47
|
+
notificationId: args.notificationId,
|
|
48
|
+
error: err instanceof Error ? err.message : String(err),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Distinguish three outcomes in the message so the caller can tell
|
|
53
|
+
// whether action is still needed: cleared, no-id-to-clear-against,
|
|
54
|
+
// or clear attempted but failed (warning was logged).
|
|
55
|
+
const message = orphanMessage(cleared, args.notificationId !== undefined);
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
orphan: true,
|
|
59
|
+
message,
|
|
60
|
+
error: "ticket not found",
|
|
61
|
+
cleared,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function orphanMessage(cleared: boolean, hadNotificationId: boolean): string {
|
|
66
|
+
const head = "Encore: this notification has already been resolved (the ticket is gone).";
|
|
67
|
+
if (cleared) return `${head} Bell entry cleared.`;
|
|
68
|
+
if (hadNotificationId) return `${head} Bell entry clear was attempted but failed; the bell may still be visible.`;
|
|
69
|
+
return head;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function seedChatForTicket(ticket: Ticket, ticketRel: string, pendingId: string): Promise<string> {
|
|
73
|
+
const chatSessionId = randomUUID();
|
|
74
|
+
const result = await startChat({
|
|
75
|
+
message: ticket.seedPrompt,
|
|
76
|
+
roleId: ENCORE_SEED_ROLE_ID,
|
|
77
|
+
chatSessionId,
|
|
78
|
+
origin: `${PLUGIN_SESSION_ORIGIN_PREFIX}${ENCORE_PLUGIN_PKG}`,
|
|
79
|
+
});
|
|
80
|
+
if (result.kind === "error") {
|
|
81
|
+
throw new EncoreError(result.status ?? 500, `resolveNotification: startChat failed — ${result.error}`);
|
|
82
|
+
}
|
|
83
|
+
await writeText(ticketRel, JSON.stringify({ ...ticket, chatSessionId }, null, 2));
|
|
84
|
+
log.info("encore", "resolveNotification: chat seeded", {
|
|
85
|
+
pendingId,
|
|
86
|
+
chatSessionId,
|
|
87
|
+
obligationId: ticket.obligationId,
|
|
88
|
+
cycleId: ticket.cycleId,
|
|
89
|
+
});
|
|
90
|
+
return chatSessionId;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function handleResolveNotification(args: z.infer<typeof ResolveNotificationArgs>): Promise<EncoreDispatchResult> {
|
|
94
|
+
const ticketRel = ticketPath(args.pendingId);
|
|
95
|
+
const raw = await readTextOrNull(ticketRel);
|
|
96
|
+
if (raw === null) return handleOrphanResolve(args);
|
|
97
|
+
|
|
98
|
+
let ticket: Ticket;
|
|
99
|
+
try {
|
|
100
|
+
ticket = JSON.parse(raw) as Ticket;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
throw new EncoreError(500, `ticket ${JSON.stringify(args.pendingId)} is unparseable`, {
|
|
103
|
+
error: err instanceof Error ? err.message : String(err),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Idempotency: if this ticket already has a chat session, reuse
|
|
108
|
+
// it rather than spawning a duplicate on double-click.
|
|
109
|
+
const { chatSessionId: existing } = ticket;
|
|
110
|
+
const chatSessionId = existing ?? (await seedChatForTicket(ticket, ticketRel, args.pendingId));
|
|
111
|
+
if (existing) {
|
|
112
|
+
log.info("encore", "resolveNotification: reusing existing chat", { pendingId: args.pendingId, chatSessionId });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
message: `Encore: opened chat ${chatSessionId} for ${ticket.obligationId}/${ticket.cycleId}.`,
|
|
118
|
+
chatId: chatSessionId,
|
|
119
|
+
navigateTo: `/chat/${chatSessionId}`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// `setup` handler — provision a new obligation from a DSL document.
|
|
2
|
+
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
import { EncoreDslInput, type EncoreDsl } from "../../../src/types/encore-dsl/schema.js";
|
|
6
|
+
import { buildCycleState, serializeCycleFile } from "../cycle.js";
|
|
7
|
+
import { currentCycleSlot } from "../../../src/types/encore-dsl/cadence.js";
|
|
8
|
+
import { serializeIndexFile } from "../obligation.js";
|
|
9
|
+
import { cycleFilePath, obligationIndexPath, slugify } from "../paths.js";
|
|
10
|
+
import { exists, writeText } from "../../utils/files/encore-io.js";
|
|
11
|
+
import { reconcileCycleNotifications } from "../reconcile.js";
|
|
12
|
+
import { log } from "../../system/logger/index.js";
|
|
13
|
+
import { EncoreError, coerceDefinitionToObject, formatZodError, workspaceRelativePath, type EncoreDispatchResult } from "./shared.js";
|
|
14
|
+
|
|
15
|
+
export const SetupArgs = z.object({
|
|
16
|
+
kind: z.literal("setup"),
|
|
17
|
+
definition: z.unknown(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** Setup-time id allocation. Reject with 409 if the slugified
|
|
21
|
+
* `displayName` collides with an existing obligation — the LLM
|
|
22
|
+
* almost certainly intended to amend an existing obligation but
|
|
23
|
+
* forgot to pass `obligationId`. The previous behavior (silently
|
|
24
|
+
* auto-number `-2`, `-3`) masked that mistake and produced
|
|
25
|
+
* parallel duplicates. The reject message tells the LLM how to
|
|
26
|
+
* recover (pass `obligationId` to make it an amend, or change
|
|
27
|
+
* `displayName`). See plans/feat-encore-define-tool.md. */
|
|
28
|
+
async function requireUniqueObligationId(displayName: string): Promise<string> {
|
|
29
|
+
const slug = slugify(displayName);
|
|
30
|
+
if (!(await exists(obligationIndexPath(slug)))) return slug;
|
|
31
|
+
throw new EncoreError(
|
|
32
|
+
409,
|
|
33
|
+
`Obligation ${JSON.stringify(slug)} already exists (displayName: ${JSON.stringify(displayName)}). ` +
|
|
34
|
+
`To modify it, call defineEncore with obligationId: ${JSON.stringify(slug)} (this becomes an amend). ` +
|
|
35
|
+
`To create a parallel obligation, change the displayName.`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function handleSetup(args: z.infer<typeof SetupArgs>): Promise<EncoreDispatchResult> {
|
|
40
|
+
const definitionObject = coerceDefinitionToObject(args.definition, "setup");
|
|
41
|
+
let dsl: EncoreDsl;
|
|
42
|
+
try {
|
|
43
|
+
dsl = EncoreDslInput.parse(definitionObject);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (err instanceof z.ZodError) {
|
|
46
|
+
throw new EncoreError(400, formatZodError(err), { issues: err.issues });
|
|
47
|
+
}
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const obligationId = await requireUniqueObligationId(dsl.displayName);
|
|
52
|
+
const fullDsl: EncoreDsl = {
|
|
53
|
+
...dsl,
|
|
54
|
+
id: obligationId,
|
|
55
|
+
createdAt: new Date().toISOString(),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Provision the first cycle synchronously so the obligation has
|
|
59
|
+
// something to fire against on the very next tick.
|
|
60
|
+
const slot = currentCycleSlot(fullDsl.cadence, new Date());
|
|
61
|
+
const cycle = buildCycleState(fullDsl, slot);
|
|
62
|
+
|
|
63
|
+
await writeText(obligationIndexPath(obligationId), serializeIndexFile(fullDsl, ""));
|
|
64
|
+
await writeText(cycleFilePath(obligationId, cycle.cycleId), serializeCycleFile(cycle, ""));
|
|
65
|
+
|
|
66
|
+
// Reconcile so that if the firingPlan's first phase is already due
|
|
67
|
+
// (cycle-start with no offset, for example), the bell surfaces the
|
|
68
|
+
// notification within the same SSE turn.
|
|
69
|
+
await reconcileCycleNotifications({ obligationId, cycleId: cycle.cycleId, now: new Date(), log });
|
|
70
|
+
|
|
71
|
+
log.info("encore", "setup: obligation created", { obligationId, cycleId: cycle.cycleId });
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
ok: true,
|
|
75
|
+
message: `Encore obligation ${JSON.stringify(dsl.displayName)} created (id: ${obligationId}, first cycle: ${cycle.cycleId}, deadline: ${cycle.cycleDeadline}).`,
|
|
76
|
+
obligationId,
|
|
77
|
+
cycleId: cycle.cycleId,
|
|
78
|
+
cyclePath: workspaceRelativePath(cycleFilePath(obligationId, cycle.cycleId)),
|
|
79
|
+
indexPath: workspaceRelativePath(obligationIndexPath(obligationId)),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Shared types and helpers used across encore handler modules.
|
|
2
|
+
//
|
|
3
|
+
// Each per-kind handler in this directory imports from here so that
|
|
4
|
+
// `dispatch.ts` stays a thin router and handlers stay focused on
|
|
5
|
+
// their own kind. EncoreError + the dispatch envelope types are
|
|
6
|
+
// re-exported from `dispatch.ts` for external callers (route adapter,
|
|
7
|
+
// tests).
|
|
8
|
+
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
import type { EncoreDsl } from "../../../src/types/encore-dsl/schema.js";
|
|
13
|
+
import { parseCycleFile, serializeCycleFile, type CycleState } from "../cycle.js";
|
|
14
|
+
import { parseIndexFile } from "../obligation.js";
|
|
15
|
+
import { cycleFilePath, obligationIndexPath } from "../paths.js";
|
|
16
|
+
import { readTextOrNull, writeText } from "../../utils/files/encore-io.js";
|
|
17
|
+
import { WORKSPACE_DIRS } from "../../workspace/paths.js";
|
|
18
|
+
import { reconcileCycleNotifications } from "../reconcile.js";
|
|
19
|
+
import { log } from "../../system/logger/index.js";
|
|
20
|
+
|
|
21
|
+
// ── error types + envelope ────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export interface EncoreDispatchBody {
|
|
24
|
+
kind: string;
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface EncoreDispatchResult {
|
|
29
|
+
ok: boolean;
|
|
30
|
+
message: string;
|
|
31
|
+
[key: string]: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class EncoreError extends Error {
|
|
35
|
+
constructor(
|
|
36
|
+
public readonly status: number,
|
|
37
|
+
message: string,
|
|
38
|
+
public readonly details?: unknown,
|
|
39
|
+
) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "EncoreError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── DSL coercion + error formatting ───────────────────────────────
|
|
46
|
+
|
|
47
|
+
export function formatZodError(err: z.ZodError): string {
|
|
48
|
+
// First issue's path + message — Claude reads this and either
|
|
49
|
+
// self-corrects or asks the user. The full issues list is in
|
|
50
|
+
// `details` for clients that want the structured form.
|
|
51
|
+
const [first] = err.issues;
|
|
52
|
+
const pathStr = first.path.length > 0 ? first.path.map((segment) => String(segment)).join(".") : "(root)";
|
|
53
|
+
return `DSL validation failed at ${pathStr}: ${first.message}. Read config/helps/encore-dsl.md for the full grammar.`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Accept `definition` as either an object literal OR a JSON-encoded
|
|
57
|
+
* string of one. The LLM commonly JSON.stringify's tool-call
|
|
58
|
+
* arguments (especially for nested objects), and rejecting that
|
|
59
|
+
* shape with "expected object, received string" reads as a schema
|
|
60
|
+
* problem rather than a wire-format problem — the LLM tends to
|
|
61
|
+
* retry with the same shape. Silently coercing eliminates the
|
|
62
|
+
* whole class of mistake. The trade-off: a non-JSON string or a
|
|
63
|
+
* JSON string that decodes to a non-object surfaces with a clear
|
|
64
|
+
* 400 instead of being silently dropped. */
|
|
65
|
+
export function coerceDefinitionToObject(value: unknown, kind: string): Record<string, unknown> {
|
|
66
|
+
let coerced = value;
|
|
67
|
+
if (typeof coerced === "string") {
|
|
68
|
+
try {
|
|
69
|
+
coerced = JSON.parse(coerced);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
throw new EncoreError(
|
|
72
|
+
400,
|
|
73
|
+
`${kind}: \`definition\` was provided as a string but is not valid JSON: ${err instanceof Error ? err.message : String(err)}. Pass an object literal, or a JSON-encoded string of one.`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!coerced || typeof coerced !== "object" || Array.isArray(coerced)) {
|
|
78
|
+
const actual = Array.isArray(coerced) ? "array" : coerced === null ? "null" : typeof coerced;
|
|
79
|
+
throw new EncoreError(400, `${kind}: \`definition\` must be an object (or a JSON string of one), got ${actual}.`);
|
|
80
|
+
}
|
|
81
|
+
return coerced as Record<string, unknown>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function workspaceRelativePath(rel: string): string {
|
|
85
|
+
return path.join(WORKSPACE_DIRS.encore, rel);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── DSL / cycle loaders ───────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
export async function loadDsl(obligationId: string): Promise<EncoreDsl | null> {
|
|
91
|
+
const raw = await readTextOrNull(obligationIndexPath(obligationId));
|
|
92
|
+
if (raw === null) return null;
|
|
93
|
+
try {
|
|
94
|
+
return parseIndexFile(raw).dsl;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function loadCycle(obligationId: string, cycleId: string): Promise<{ rel: string; raw: string; state: CycleState; body: string }> {
|
|
101
|
+
const rel = cycleFilePath(obligationId, cycleId);
|
|
102
|
+
const raw = await readTextOrNull(rel);
|
|
103
|
+
if (raw === null) {
|
|
104
|
+
throw new EncoreError(404, `cycle file ${obligationId}/${cycleId}.md not found`);
|
|
105
|
+
}
|
|
106
|
+
const { state, body } = parseCycleFile(raw);
|
|
107
|
+
return { rel, raw, state, body };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The mutating-handler envelope. Write the cycle file, then run
|
|
111
|
+
* the reconciler under the same per-plugin lock that wraps this
|
|
112
|
+
* dispatch. The reconciler re-derives the desired bell state from
|
|
113
|
+
* disk — it's both the trim path (closed/snoozed → out of bundle)
|
|
114
|
+
* and the publish path (un-fired in-bundle pairs → publish). */
|
|
115
|
+
export async function persistAndReconcile(rel: string, state: CycleState, body: string, obligationId: string, cycleId: string): Promise<void> {
|
|
116
|
+
await writeText(rel, serializeCycleFile(state, body));
|
|
117
|
+
await reconcileCycleNotifications({ obligationId, cycleId, now: new Date(), log });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Reject calls referencing target/step ids that don't exist in
|
|
121
|
+
* the DSL. Without this, a typo (`pat` vs `pay`) would succeed
|
|
122
|
+
* silently — writing a record under the bogus id, leaving the
|
|
123
|
+
* real step still un-closed, and surfacing as "I told the LLM
|
|
124
|
+
* I paid but the bell didn't clear". */
|
|
125
|
+
export function assertKnownTargetAndStep(dsl: EncoreDsl | null, args: { obligationId: string; targetId: string; stepId?: string }): void {
|
|
126
|
+
if (!dsl) {
|
|
127
|
+
throw new EncoreError(404, `obligation ${JSON.stringify(args.obligationId)} not found`);
|
|
128
|
+
}
|
|
129
|
+
if (!dsl.targets.some((target) => target.id === args.targetId)) {
|
|
130
|
+
const known = dsl.targets.map((target) => target.id).join(", ");
|
|
131
|
+
throw new EncoreError(400, `unknown targetId ${JSON.stringify(args.targetId)} for obligation ${JSON.stringify(args.obligationId)}. Known: [${known}]`);
|
|
132
|
+
}
|
|
133
|
+
if (args.stepId !== undefined && !dsl.steps.some((step) => step.id === args.stepId)) {
|
|
134
|
+
const known = dsl.steps.map((step) => step.id).join(", ");
|
|
135
|
+
throw new EncoreError(400, `unknown stepId ${JSON.stringify(args.stepId)} for obligation ${JSON.stringify(args.obligationId)}. Known: [${known}]`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// `snooze` / `unsnooze` handlers — inverse pair that mark a step as
|
|
2
|
+
// suppressed (or unsuppressed) for a cycle. The reconciler treats
|
|
3
|
+
// snoozed pairs as out-of-bundle, so trim/republish flows naturally.
|
|
4
|
+
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
import { recordStepSnooze, recordStepUnsnooze } from "../cycle.js";
|
|
8
|
+
import { ONE_HOUR_MS } from "../../utils/time.js";
|
|
9
|
+
import { log } from "../../system/logger/index.js";
|
|
10
|
+
import { assertKnownTargetAndStep, loadCycle, loadDsl, persistAndReconcile, type EncoreDispatchResult } from "./shared.js";
|
|
11
|
+
|
|
12
|
+
export const SnoozeArgs = z.object({
|
|
13
|
+
kind: z.literal("snooze"),
|
|
14
|
+
obligationId: z.string().trim().min(1),
|
|
15
|
+
cycleId: z.string().trim().min(1),
|
|
16
|
+
targetId: z.string().trim().min(1),
|
|
17
|
+
stepId: z.string().trim().min(1),
|
|
18
|
+
pendingId: z.string().trim().min(1).optional(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const UnsnoozeArgs = z.object({
|
|
22
|
+
kind: z.literal("unsnooze"),
|
|
23
|
+
obligationId: z.string().trim().min(1),
|
|
24
|
+
cycleId: z.string().trim().min(1),
|
|
25
|
+
targetId: z.string().trim().min(1),
|
|
26
|
+
stepId: z.string().trim().min(1),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export async function handleSnooze(args: z.infer<typeof SnoozeArgs>): Promise<EncoreDispatchResult> {
|
|
30
|
+
const dsl = await loadDsl(args.obligationId);
|
|
31
|
+
assertKnownTargetAndStep(dsl, args);
|
|
32
|
+
// Snooze under the unified-reconciler model: write
|
|
33
|
+
// `snoozedSteps[stepId]` on the cycle file and let the reconciler
|
|
34
|
+
// do the rest. Because `isPairInBundle` treats snoozed as
|
|
35
|
+
// out-of-bundle, Phase 1 trims this target out of any existing
|
|
36
|
+
// ticket (clearing the bell if the bundle empties); Phase 2 sees
|
|
37
|
+
// the pair as not eligible-to-fire (snooze active), so no
|
|
38
|
+
// republish. The pre-reconciler workaround ("skip the tick after
|
|
39
|
+
// snooze") is no longer needed.
|
|
40
|
+
const snoozeUntilIso = new Date(Date.now() + 24 * ONE_HOUR_MS).toISOString();
|
|
41
|
+
const { rel, state, body } = await loadCycle(args.obligationId, args.cycleId);
|
|
42
|
+
const nextState = recordStepSnooze(state, args.targetId, args.stepId, snoozeUntilIso);
|
|
43
|
+
await persistAndReconcile(rel, nextState, body, args.obligationId, args.cycleId);
|
|
44
|
+
log.info("encore", "snooze: step snoozed", {
|
|
45
|
+
obligationId: args.obligationId,
|
|
46
|
+
cycleId: args.cycleId,
|
|
47
|
+
targetId: args.targetId,
|
|
48
|
+
stepId: args.stepId,
|
|
49
|
+
snoozeUntilIso,
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
ok: true,
|
|
53
|
+
message: `Encore: snoozed ${args.stepId} for ${args.targetId} in cycle ${args.cycleId} of ${args.obligationId}.`,
|
|
54
|
+
obligationId: args.obligationId,
|
|
55
|
+
cycleId: args.cycleId,
|
|
56
|
+
targetId: args.targetId,
|
|
57
|
+
stepId: args.stepId,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function handleUnsnooze(args: z.infer<typeof UnsnoozeArgs>): Promise<EncoreDispatchResult> {
|
|
62
|
+
const dsl = await loadDsl(args.obligationId);
|
|
63
|
+
assertKnownTargetAndStep(dsl, args);
|
|
64
|
+
// Inverse of snooze: delete `snoozedSteps[stepId]`. The reconciler
|
|
65
|
+
// sees the pair eligible to fire again (assuming the step isn't
|
|
66
|
+
// also closed) and Phase 2 publishes a fresh bell — in the same
|
|
67
|
+
// dispatch turn, no tick wait. If the step was already not
|
|
68
|
+
// snoozed, `recordStepUnsnooze` is a no-op and the reconciler
|
|
69
|
+
// sees no state change → no flicker.
|
|
70
|
+
const { rel, state, body } = await loadCycle(args.obligationId, args.cycleId);
|
|
71
|
+
const nextState = recordStepUnsnooze(state, args.targetId, args.stepId);
|
|
72
|
+
await persistAndReconcile(rel, nextState, body, args.obligationId, args.cycleId);
|
|
73
|
+
log.info("encore", "unsnooze: step unsnoozed", {
|
|
74
|
+
obligationId: args.obligationId,
|
|
75
|
+
cycleId: args.cycleId,
|
|
76
|
+
targetId: args.targetId,
|
|
77
|
+
stepId: args.stepId,
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
ok: true,
|
|
81
|
+
message: `Encore: unsnoozed ${args.stepId} for ${args.targetId} in cycle ${args.cycleId} of ${args.obligationId}.`,
|
|
82
|
+
obligationId: args.obligationId,
|
|
83
|
+
cycleId: args.cycleId,
|
|
84
|
+
targetId: args.targetId,
|
|
85
|
+
stepId: args.stepId,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// `startObligationChat` handler — user-initiated chat about a
|
|
2
|
+
// specific obligation. Reached from the dashboard chat button
|
|
3
|
+
// (`/encore` landing); NOT exposed in the LLM-facing tool schema.
|
|
4
|
+
//
|
|
5
|
+
// Compared to `resolveNotification` (the bell-click path):
|
|
6
|
+
// - No ticket. There's no notification to clear and no severity
|
|
7
|
+
// baseline to track — this is purely user-initiated.
|
|
8
|
+
// - No reuse / idempotency. Each click yields a fresh chat. If
|
|
9
|
+
// the user wants to continue a previous conversation, they
|
|
10
|
+
// pick it from the sidebar instead.
|
|
11
|
+
// - Seed prompt is composed here from the DSL (id + displayName)
|
|
12
|
+
// rather than read from a ticket.
|
|
13
|
+
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
|
|
17
|
+
import { startChat } from "../../api/routes/agent.js";
|
|
18
|
+
import { PLUGIN_SESSION_ORIGIN_PREFIX } from "../../../src/types/session.js";
|
|
19
|
+
import { ENCORE_SEED_ROLE_ID } from "../../../src/config/roles.js";
|
|
20
|
+
import { ENCORE_PLUGIN_PKG } from "../notifier.js";
|
|
21
|
+
import { log } from "../../system/logger/index.js";
|
|
22
|
+
import { EncoreError, loadDsl, type EncoreDispatchResult } from "./shared.js";
|
|
23
|
+
|
|
24
|
+
export const StartObligationChatArgs = z.object({
|
|
25
|
+
kind: z.literal("startObligationChat"),
|
|
26
|
+
obligationId: z.string().min(1),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function buildSeedPrompt(obligationId: string, displayName: string): string {
|
|
30
|
+
// Mention the obligationId explicitly so the LLM can call
|
|
31
|
+
// `manageEncore({ kind: "query", obligationId })` to read the
|
|
32
|
+
// current state on its first turn without guessing.
|
|
33
|
+
return `Let's talk about my "${displayName}" obligation (obligationId: ${obligationId}). Please query its current state first, then ask me what I'd like to do.`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function handleStartObligationChat(args: z.infer<typeof StartObligationChatArgs>): Promise<EncoreDispatchResult> {
|
|
37
|
+
const dsl = await loadDsl(args.obligationId);
|
|
38
|
+
if (!dsl) {
|
|
39
|
+
throw new EncoreError(404, `obligation ${JSON.stringify(args.obligationId)} not found`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const chatSessionId = randomUUID();
|
|
43
|
+
const result = await startChat({
|
|
44
|
+
message: buildSeedPrompt(args.obligationId, dsl.displayName),
|
|
45
|
+
roleId: ENCORE_SEED_ROLE_ID,
|
|
46
|
+
chatSessionId,
|
|
47
|
+
origin: `${PLUGIN_SESSION_ORIGIN_PREFIX}${ENCORE_PLUGIN_PKG}`,
|
|
48
|
+
});
|
|
49
|
+
if (result.kind === "error") {
|
|
50
|
+
throw new EncoreError(result.status ?? 500, `startObligationChat: startChat failed — ${result.error}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
log.info("encore", "startObligationChat: chat seeded", {
|
|
54
|
+
obligationId: args.obligationId,
|
|
55
|
+
chatSessionId,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
ok: true,
|
|
60
|
+
message: `Encore: opened chat ${chatSessionId} for ${args.obligationId}.`,
|
|
61
|
+
chatId: chatSessionId,
|
|
62
|
+
navigateTo: `/chat/${chatSessionId}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// `startSetupChat` handler — user-initiated chat for creating a new
|
|
2
|
+
// obligation. Reached from the dashboard "+ Add" button (`/encore`
|
|
3
|
+
// landing); NOT exposed in the LLM-facing tool schema.
|
|
4
|
+
//
|
|
5
|
+
// Parallel to `startObligationChat` (which discusses an existing
|
|
6
|
+
// obligation) — this one has no obligation yet, so the seed prompt
|
|
7
|
+
// only asks the LLM to walk the user through setup. The LLM has
|
|
8
|
+
// the encore-dsl help file + `manageEncore({kind:"setup"})` already
|
|
9
|
+
// in its toolbelt; we don't need to teach it the schema here.
|
|
10
|
+
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
import { startChat } from "../../api/routes/agent.js";
|
|
15
|
+
import { PLUGIN_SESSION_ORIGIN_PREFIX } from "../../../src/types/session.js";
|
|
16
|
+
import { ENCORE_SEED_ROLE_ID } from "../../../src/config/roles.js";
|
|
17
|
+
import { ENCORE_PLUGIN_PKG } from "../notifier.js";
|
|
18
|
+
import { log } from "../../system/logger/index.js";
|
|
19
|
+
import { EncoreError, type EncoreDispatchResult } from "./shared.js";
|
|
20
|
+
|
|
21
|
+
export const StartSetupChatArgs = z.object({
|
|
22
|
+
kind: z.literal("startSetupChat"),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const SEED_PROMPT =
|
|
26
|
+
"I'd like to set up a new recurring obligation in Encore. " +
|
|
27
|
+
"Please walk me through what to track (kind, cadence, targets, fields), " +
|
|
28
|
+
"then compose the DSL and call defineEncore when ready.";
|
|
29
|
+
|
|
30
|
+
export async function handleStartSetupChat(__args: z.infer<typeof StartSetupChatArgs>): Promise<EncoreDispatchResult> {
|
|
31
|
+
const chatSessionId = randomUUID();
|
|
32
|
+
const result = await startChat({
|
|
33
|
+
message: SEED_PROMPT,
|
|
34
|
+
roleId: ENCORE_SEED_ROLE_ID,
|
|
35
|
+
chatSessionId,
|
|
36
|
+
origin: `${PLUGIN_SESSION_ORIGIN_PREFIX}${ENCORE_PLUGIN_PKG}`,
|
|
37
|
+
});
|
|
38
|
+
if (result.kind === "error") {
|
|
39
|
+
throw new EncoreError(result.status ?? 500, `startSetupChat: startChat failed — ${result.error}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
log.info("encore", "startSetupChat: chat seeded", { chatSessionId });
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
ok: true,
|
|
46
|
+
message: `Encore: opened setup chat ${chatSessionId}.`,
|
|
47
|
+
chatId: chatSessionId,
|
|
48
|
+
navigateTo: `/chat/${chatSessionId}`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Per-plugin mutex. All dispatch calls and all ticks serialise
|
|
2
|
+
// through one Promise-chain so:
|
|
3
|
+
//
|
|
4
|
+
// - Two concurrent handler calls can't tmp+rename the same cycle
|
|
5
|
+
// file at once (writeFileAtomic ENOENT race).
|
|
6
|
+
// - A handler that kicks the tick can't race the hourly heartbeat
|
|
7
|
+
// into double-publishing the same notification.
|
|
8
|
+
//
|
|
9
|
+
// Implementation split:
|
|
10
|
+
// - `withLock(fn)`: appends fn to the chain; returns its result.
|
|
11
|
+
// Errors don't poison the chain — the next consumer's wait
|
|
12
|
+
// resolves regardless.
|
|
13
|
+
// - `kickTickLocked(reason)`: acquires the lock, runs the tick,
|
|
14
|
+
// releases. Handlers call this AFTER persisting; the hourly
|
|
15
|
+
// heartbeat also goes through this.
|
|
16
|
+
// - `runDispatchLocked(fn)`: thin alias for withLock — exists for
|
|
17
|
+
// readability at call sites in dispatch.ts.
|
|
18
|
+
//
|
|
19
|
+
// Handlers that need to kick the tick from inside their critical
|
|
20
|
+
// section MUST NOT call kickTickLocked (it would deadlock). They
|
|
21
|
+
// invoke `tickUnlocked` directly. The boot wiring + the hourly
|
|
22
|
+
// heartbeat both call kickTickLocked so external callers don't
|
|
23
|
+
// have to think about which one to pick.
|
|
24
|
+
|
|
25
|
+
import { log } from "../system/logger/index.js";
|
|
26
|
+
import { runTick, type TickDeps } from "./tick.js";
|
|
27
|
+
|
|
28
|
+
let pluginLock: Promise<unknown> = Promise.resolve();
|
|
29
|
+
|
|
30
|
+
/** Append `fn` to the per-plugin chain. Returns `fn`'s result. */
|
|
31
|
+
export function withLock<T>(task: () => Promise<T>): Promise<T> {
|
|
32
|
+
const next = pluginLock.catch(() => undefined).then(task);
|
|
33
|
+
pluginLock = next.catch(() => undefined);
|
|
34
|
+
return next;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Run the tick without acquiring the lock. Callers that already
|
|
38
|
+
* hold the lock (e.g. a handler that wants to kick the tick from
|
|
39
|
+
* inside its critical section) use this; everyone else uses
|
|
40
|
+
* `kickTickLocked`. */
|
|
41
|
+
export async function tickUnlocked(deps: TickDeps, reason: string): Promise<void> {
|
|
42
|
+
try {
|
|
43
|
+
await runTick(deps);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
log.warn("encore", "tick: unhandled error", { reason, error: err instanceof Error ? err.message : String(err) });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Acquire the lock, run the tick, release. The hourly heartbeat
|
|
50
|
+
* and `kickTick`-from-handler calls both come through here so the
|
|
51
|
+
* serialisation guarantee applies. */
|
|
52
|
+
export async function kickTickLocked(deps: TickDeps, reason: string): Promise<void> {
|
|
53
|
+
await withLock(() => tickUnlocked(deps, reason));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Reset the lock to a fresh resolved Promise. Test-only — production
|
|
57
|
+
* code never wants this; the chain is meant to live for the process
|
|
58
|
+
* lifetime. */
|
|
59
|
+
export function _resetLockForTesting(): void {
|
|
60
|
+
pluginLock = Promise.resolve();
|
|
61
|
+
}
|