dot-studio 0.0.1 → 0.0.2

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.
Files changed (148) hide show
  1. package/README.md +20 -200
  2. package/client/assets/ActFrame-BYOBkLYW.js +1 -0
  3. package/client/assets/ActFrame-C_WEt6bv.css +1 -0
  4. package/client/assets/ActInspectorPanel-C3VlS7tB.js +1 -0
  5. package/client/assets/ActInspectorPanel-CE6s6GYv.css +1 -0
  6. package/client/assets/AssistantChat-BOyW0K79.js +1 -0
  7. package/client/assets/AssistantChat-DoVmHvMJ.css +1 -0
  8. package/client/assets/CanvasTerminalFrame-BC-79q9U.css +1 -0
  9. package/client/assets/CanvasTerminalFrame-DxKbexK6.js +4 -0
  10. package/client/assets/CanvasTrackingFrame-DumxhNwg.js +1 -0
  11. package/client/assets/CanvasTrackingFrame-G4rRrfne.css +1 -0
  12. package/client/assets/CanvasWindowFrame-ziJeVfHG.js +1 -0
  13. package/client/assets/DanceBundleEditorFrame-CH8VDUMK.js +1 -0
  14. package/client/assets/DanceBundleEditorFrame-DaLqMflT.css +1 -0
  15. package/client/assets/MarkdownEditorFrame-DVecIZpZ.css +1 -0
  16. package/client/assets/MarkdownEditorFrame-Dwpgs2GX.js +2 -0
  17. package/client/assets/MarkdownRenderer-Cz8A4AgP.js +1 -0
  18. package/client/assets/PublishModal-DUlHz0fT.js +1 -0
  19. package/client/assets/TodoDock-DcVf7zQG.js +1 -0
  20. package/client/assets/WorkspaceToolbar-CXYi_sMD.js +2 -0
  21. package/client/assets/WorkspaceToolbar-CiQvVocC.css +1 -0
  22. package/client/assets/chat-message-visibility-YwJ-AQno.js +11 -0
  23. package/client/assets/dnd-vendor-CIAZE2P2.js +5 -0
  24. package/client/assets/flow-vendor-BZV40eAE.css +1 -0
  25. package/client/assets/flow-vendor-C868rU-6.js +23 -0
  26. package/client/assets/icon-vendor-I2JVIi1s.js +501 -0
  27. package/client/assets/index-BMY4hrBP.js +3 -0
  28. package/client/assets/index-C-vnj9y3.js +1 -0
  29. package/client/assets/index-C9HTqfZw.css +1 -0
  30. package/client/assets/index-CWrv6O3o.js +64 -0
  31. package/client/assets/index-DMS12-Q2.js +8 -0
  32. package/client/assets/index-Dn7t_Y7G.js +1 -0
  33. package/client/assets/index-p-wk7iGH.css +1 -0
  34. package/client/assets/markdown-vendor-BSTcku12.css +10 -0
  35. package/client/assets/markdown-vendor-DnTJ9hmR.js +35 -0
  36. package/client/assets/participant-labels-Cf3qP3GB.js +1 -0
  37. package/client/assets/queries-Dm1jEHfc.js +1 -0
  38. package/client/assets/query-vendor-_taqgrbn.js +1 -0
  39. package/client/assets/react-vendor-DzpMUNDT.js +49 -0
  40. package/client/assets/settings-utils-l7KCS3Ev.js +1 -0
  41. package/client/assets/terminal-vendor-6GBZ9nXN.css +32 -0
  42. package/client/assets/terminal-vendor-D0xRnmbI.js +112 -0
  43. package/client/index.html +13 -3
  44. package/dist/cli.js +25 -3
  45. package/dist/server/app.js +72 -0
  46. package/dist/server/index.js +2 -62
  47. package/dist/server/lib/act-session-policy.js +31 -0
  48. package/dist/server/lib/chat-session.js +101 -0
  49. package/dist/server/lib/config.js +18 -4
  50. package/dist/server/lib/dot-authoring.js +171 -102
  51. package/dist/server/lib/dot-loader.js +9 -8
  52. package/dist/server/lib/dot-login.js +8 -190
  53. package/dist/server/lib/dot-source.js +11 -0
  54. package/dist/server/lib/model-catalog.js +74 -15
  55. package/dist/server/lib/opencode-auth.js +4 -1
  56. package/dist/server/lib/opencode-errors.js +70 -38
  57. package/dist/server/lib/opencode-sidecar.js +5 -2
  58. package/dist/server/lib/project-config.js +8 -0
  59. package/dist/server/lib/runtime-tools.js +46 -8
  60. package/dist/server/lib/safe-mode.js +410 -0
  61. package/dist/server/lib/session-execution.js +81 -0
  62. package/dist/server/lib/sse.js +22 -0
  63. package/dist/server/routes/act-runtime-threads.js +156 -0
  64. package/dist/server/routes/act-runtime-tools.js +157 -0
  65. package/dist/server/routes/act-runtime.js +7 -0
  66. package/dist/server/routes/adapter.js +32 -0
  67. package/dist/server/routes/assets-collection.js +16 -0
  68. package/dist/server/routes/assets-detail.js +38 -0
  69. package/dist/server/routes/assets.js +4 -158
  70. package/dist/server/routes/chat-messages.js +104 -0
  71. package/dist/server/routes/chat-sessions.js +104 -0
  72. package/dist/server/routes/chat-stream.js +15 -0
  73. package/dist/server/routes/chat.js +6 -353
  74. package/dist/server/routes/compile.js +5 -91
  75. package/dist/server/routes/dot-assets.js +77 -0
  76. package/dist/server/routes/dot-core.js +62 -0
  77. package/dist/server/routes/dot-performer.js +80 -0
  78. package/dist/server/routes/dot.js +6 -267
  79. package/dist/server/routes/drafts-collection.js +40 -0
  80. package/dist/server/routes/drafts-dance-bundle.js +113 -0
  81. package/dist/server/routes/drafts-item.js +86 -0
  82. package/dist/server/routes/drafts.js +9 -0
  83. package/dist/server/routes/health.js +18 -33
  84. package/dist/server/routes/opencode-core.js +120 -0
  85. package/dist/server/routes/opencode-file.js +67 -0
  86. package/dist/server/routes/opencode-mcp.js +74 -0
  87. package/dist/server/routes/opencode-provider.js +41 -0
  88. package/dist/server/routes/opencode.js +8 -418
  89. package/dist/server/routes/route-errors.js +10 -0
  90. package/dist/server/routes/safe-actions.js +60 -0
  91. package/dist/server/routes/safe-summary.js +20 -0
  92. package/dist/server/routes/safe.js +7 -0
  93. package/dist/server/routes/workspaces.js +47 -0
  94. package/dist/server/services/act-runtime/act-context-builder.js +81 -0
  95. package/dist/server/services/act-runtime/act-runtime-service.js +313 -0
  96. package/dist/server/services/act-runtime/act-runtime-utils.js +10 -0
  97. package/dist/server/services/act-runtime/act-tool-projection.js +26 -0
  98. package/dist/server/services/act-runtime/act-tools.js +151 -0
  99. package/dist/server/services/act-runtime/board-persistence.js +38 -0
  100. package/dist/server/services/act-runtime/event-logger.js +73 -0
  101. package/dist/server/services/act-runtime/event-router.js +102 -0
  102. package/dist/server/services/act-runtime/mailbox.js +149 -0
  103. package/dist/server/services/act-runtime/safety-guard.js +162 -0
  104. package/dist/server/services/act-runtime/session-queue.js +114 -0
  105. package/dist/server/services/act-runtime/thread-manager.js +351 -0
  106. package/dist/server/services/act-runtime/wake-cascade.js +306 -0
  107. package/dist/server/services/act-runtime/wake-evaluator.js +43 -0
  108. package/dist/server/services/act-runtime/wake-performer-resolver.js +68 -0
  109. package/dist/server/services/act-runtime/wake-prompt-builder.js +77 -0
  110. package/dist/server/services/adapter-view-service.js +6 -0
  111. package/dist/server/services/asset-service.js +366 -0
  112. package/dist/server/services/chat-event-stream-service.js +157 -0
  113. package/dist/server/services/chat-service.js +207 -0
  114. package/dist/server/services/chat-session-service.js +203 -0
  115. package/dist/server/services/compile-service.js +4 -0
  116. package/dist/server/services/dance-bundle-service.js +222 -0
  117. package/dist/server/services/dot-add-service.js +59 -0
  118. package/dist/server/services/dot-service.js +178 -0
  119. package/dist/server/services/draft-service.js +367 -0
  120. package/dist/server/services/opencode-projection/dance-compiler.js +164 -0
  121. package/dist/server/services/opencode-projection/performer-compiler.js +195 -0
  122. package/dist/server/services/opencode-projection/preview-service.js +31 -0
  123. package/dist/server/services/opencode-projection/projection-manifest.js +98 -0
  124. package/dist/server/services/opencode-projection/stage-projection-service.js +188 -0
  125. package/dist/server/services/opencode-service.js +338 -0
  126. package/dist/server/services/safe-service.js +33 -0
  127. package/dist/server/services/studio-assistant/assistant-service.js +172 -0
  128. package/dist/server/services/studio-service.js +69 -0
  129. package/dist/server/services/workspace-service.js +224 -0
  130. package/dist/server/terminal.js +57 -11
  131. package/dist/shared/act-types.js +4 -0
  132. package/dist/shared/adapter-view.js +1 -0
  133. package/dist/shared/asset-contracts.js +1 -0
  134. package/dist/shared/assistant-actions.js +1 -0
  135. package/dist/shared/chat-contracts.js +1 -0
  136. package/dist/shared/dot-contracts.js +1 -0
  137. package/dist/shared/dot-types.js +4 -0
  138. package/dist/shared/draft-contracts.js +2 -0
  139. package/dist/shared/model-types.js +2 -0
  140. package/dist/shared/performer-mcp-portability.js +10 -0
  141. package/dist/shared/safe-mode.js +1 -0
  142. package/dist/shared/session-metadata.js +4 -3
  143. package/package.json +6 -4
  144. package/client/assets/index-C2eIILoa.css +0 -41
  145. package/client/assets/index-DUPZ_Lw5.js +0 -616
  146. package/dist/server/lib/act-runtime.js +0 -1282
  147. package/dist/server/lib/prompt.js +0 -222
  148. package/dist/server/routes/stages.js +0 -137
@@ -0,0 +1,102 @@
1
+ /**
2
+ * event-router.ts — Subscription + relation-based event routing
3
+ *
4
+ * PRD §15.2: Routes events to participants based on:
5
+ * 1. Subscription + relation permission match
6
+ * 2. WakeCondition satisfaction
7
+ */
8
+ import { Mailbox } from './mailbox.js';
9
+ import { evaluateWakeCondition } from './wake-evaluator.js';
10
+ import { payloadString } from './act-runtime-utils.js';
11
+ // ── Subscription matching ───────────────────────────────
12
+ function matchSubscription(participantKey, subscriptions, event) {
13
+ if (!subscriptions)
14
+ return false;
15
+ const payload = event.payload;
16
+ switch (event.type) {
17
+ case 'message.sent':
18
+ case 'message.delivered': {
19
+ const to = payloadString(payload, 'to');
20
+ if (to !== participantKey) {
21
+ return false;
22
+ }
23
+ const from = payloadString(payload, 'from');
24
+ const tag = payloadString(payload, 'tag');
25
+ const fromMatch = subscriptions.messagesFrom?.includes(from || '') ?? false;
26
+ const tagMatch = subscriptions.messageTags?.includes(tag || '') ?? false;
27
+ return fromMatch || tagMatch;
28
+ }
29
+ case 'board.posted':
30
+ case 'board.updated': {
31
+ const key = payloadString(payload, 'key');
32
+ if (!key)
33
+ return false;
34
+ return subscriptions.callboardKeys?.some((pattern) => {
35
+ if (pattern.endsWith('*')) {
36
+ return key.startsWith(pattern.slice(0, -1));
37
+ }
38
+ return key === pattern;
39
+ }) ?? false;
40
+ }
41
+ case 'runtime.idle':
42
+ return subscriptions.eventTypes?.includes('runtime.idle') ?? false;
43
+ default:
44
+ return false;
45
+ }
46
+ }
47
+ // ── Relation permission check ───────────────────────────
48
+ function hasRelationPermission(participantKey, event, relations) {
49
+ const source = event.source;
50
+ if (!source || source === participantKey)
51
+ return false;
52
+ // Check if there's a relation between source and this participant
53
+ return relations.some((rel) => {
54
+ const [a, b] = rel.between;
55
+ const pairMatch = (a === source && b === participantKey) || (a === participantKey && b === source);
56
+ if (!pairMatch)
57
+ return false;
58
+ // For one-way relations, only the second (target) can be woken by the first (source)
59
+ if (rel.direction === 'one-way') {
60
+ return a === source && b === participantKey;
61
+ }
62
+ return true;
63
+ });
64
+ }
65
+ // ── Main routing function ───────────────────────────────
66
+ export function routeEvent(event, actDefinition, mailbox, recentEvents) {
67
+ const targets = [];
68
+ const seen = new Set();
69
+ // 1. Subscription + relation based wake-up
70
+ for (const [key, binding] of Object.entries(actDefinition.participants)) {
71
+ if (key === event.source)
72
+ continue; // Don't wake the source
73
+ const subMatch = matchSubscription(key, binding.subscriptions, event);
74
+ const relMatch = hasRelationPermission(key, event, actDefinition.relations);
75
+ // Direct message: always wake the recipient if relation allows it.
76
+ // 1:1 messages don't need explicit subscription — the `to` field is the routing key.
77
+ const isDirectMessageTarget = (event.type === 'message.sent' || event.type === 'message.delivered') &&
78
+ payloadString(event.payload, 'to') === key;
79
+ if ((subMatch && relMatch) || (isDirectMessageTarget && relMatch)) {
80
+ targets.push({
81
+ participantKey: key,
82
+ triggerEvent: event,
83
+ reason: 'subscription',
84
+ });
85
+ seen.add(key);
86
+ }
87
+ }
88
+ // 2. WakeCondition based wake-up
89
+ const triggeredConditions = mailbox.evaluateConditions(event, (cond, board, events) => evaluateWakeCondition(cond, board, events), recentEvents);
90
+ for (const cond of triggeredConditions) {
91
+ if (!seen.has(cond.createdBy)) {
92
+ targets.push({
93
+ participantKey: cond.createdBy,
94
+ triggerEvent: event,
95
+ wakeCondition: cond,
96
+ reason: 'wake-condition',
97
+ });
98
+ seen.add(cond.createdBy);
99
+ }
100
+ }
101
+ return targets;
102
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * mailbox.ts — Mailbox state management for Act Thread runtime
3
+ *
4
+ * PRD §6: Mailbox is the internal SoT for all Act collaboration state.
5
+ * - pending messages (in-memory, transient)
6
+ * - board (file-backed + memory cache, durable)
7
+ * - wake conditions (in-memory, transient)
8
+ */
9
+ import { nanoid } from 'nanoid';
10
+ // ── Mailbox ─────────────────────────────────────────────
11
+ export class Mailbox {
12
+ // pending messages (in-memory, transient)
13
+ pendingMessages = [];
14
+ // board (file-backed + memory cache, durable)
15
+ board = new Map();
16
+ // wake conditions (in-memory, transient)
17
+ wakeConditions = [];
18
+ // ── Messages ────────────────────────────────────
19
+ addMessage(msg) {
20
+ const message = {
21
+ ...msg,
22
+ id: nanoid(),
23
+ timestamp: Date.now(),
24
+ status: 'pending',
25
+ };
26
+ this.pendingMessages.push(message);
27
+ return message;
28
+ }
29
+ getMessagesFor(participantKey) {
30
+ return this.pendingMessages.filter((m) => m.to === participantKey && m.status === 'pending');
31
+ }
32
+ getAllPendingMessages() {
33
+ return [...this.pendingMessages];
34
+ }
35
+ markDelivered(messageId) {
36
+ const idx = this.pendingMessages.findIndex((m) => m.id === messageId);
37
+ if (idx !== -1) {
38
+ // After delivery, message lives in performer's session — remove from mailbox
39
+ this.pendingMessages.splice(idx, 1);
40
+ }
41
+ }
42
+ // ── Board ───────────────────────────────────────
43
+ postToBoard(entry) {
44
+ const existing = this.board.get(entry.key);
45
+ // writePolicy enforcement
46
+ if (existing) {
47
+ const wp = existing.writePolicy || 'any';
48
+ if (wp === 'author-only' && existing.author !== entry.author) {
49
+ throw new Error(`Board key "${entry.key}" is author-only and owned by "${existing.author}".`);
50
+ }
51
+ // relation-peers could be checked here with relation context (future)
52
+ }
53
+ const boardEntry = {
54
+ ...entry,
55
+ id: existing?.id || nanoid(),
56
+ version: existing ? existing.version + 1 : 1,
57
+ timestamp: Date.now(),
58
+ };
59
+ if (existing && entry.updateMode === 'append') {
60
+ boardEntry.content = existing.content + '\n' + entry.content;
61
+ }
62
+ this.board.set(entry.key, boardEntry);
63
+ return boardEntry;
64
+ }
65
+ readBoard(key) {
66
+ return this.board.get(key);
67
+ }
68
+ getBoardSnapshot() {
69
+ return Array.from(this.board.values());
70
+ }
71
+ getBoardMap() {
72
+ return new Map(this.board);
73
+ }
74
+ // ── WakeCondition ───────────────────────────────
75
+ addWakeCondition(condition) {
76
+ const wc = {
77
+ ...condition,
78
+ id: nanoid(),
79
+ status: 'waiting',
80
+ };
81
+ this.wakeConditions.push(wc);
82
+ return wc;
83
+ }
84
+ getWakeConditions() {
85
+ return this.wakeConditions.filter((c) => c.status === 'waiting');
86
+ }
87
+ /**
88
+ * Evaluate all waiting conditions against an event.
89
+ * Returns conditions that were triggered.
90
+ */
91
+ evaluateConditions(_event, evaluator, recentEvents) {
92
+ const triggered = [];
93
+ for (const cond of this.wakeConditions) {
94
+ if (cond.status !== 'waiting')
95
+ continue;
96
+ if (evaluator(cond, this.board, recentEvents)) {
97
+ cond.status = 'triggered';
98
+ triggered.push(cond);
99
+ }
100
+ }
101
+ return triggered;
102
+ }
103
+ removeCondition(conditionId) {
104
+ this.wakeConditions = this.wakeConditions.filter((c) => c.id !== conditionId);
105
+ }
106
+ // ── Lifecycle ───────────────────────────────────
107
+ /**
108
+ * Export the current state as a serializable object.
109
+ */
110
+ getState() {
111
+ return {
112
+ pendingMessages: [...this.pendingMessages],
113
+ board: Object.fromEntries(this.board),
114
+ wakeConditions: [...this.wakeConditions],
115
+ };
116
+ }
117
+ /**
118
+ * Restore state from a serialized object (e.g. after thread recovery).
119
+ * Only board is restored — pending messages and wake conditions are transient.
120
+ */
121
+ restoreBoard(entries) {
122
+ this.board.clear();
123
+ for (const entry of entries) {
124
+ this.board.set(entry.key, entry);
125
+ }
126
+ }
127
+ /**
128
+ * Restore full mailbox state from a serialized MailboxState snapshot (WS5).
129
+ */
130
+ restoreFromState(state) {
131
+ this.pendingMessages = [...(state.pendingMessages || [])];
132
+ this.board.clear();
133
+ if (state.board) {
134
+ for (const [key, entry] of Object.entries(state.board)) {
135
+ this.board.set(key, entry);
136
+ }
137
+ }
138
+ this.wakeConditions = [...(state.wakeConditions || [])];
139
+ }
140
+ /**
141
+ * Shutdown: discard ephemeral state, return durable state.
142
+ */
143
+ shutdown() {
144
+ const durableBoard = this.getBoardSnapshot();
145
+ this.pendingMessages = [];
146
+ this.wakeConditions = [];
147
+ return { board: durableBoard };
148
+ }
149
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * safety-guard.ts — Safety & Guard for Act runtime
3
+ *
4
+ * PRD §16: Event budget, loop detection, timeout, idle detection, permission checks.
5
+ */
6
+ export const DEFAULT_SAFETY_CONFIG = {
7
+ maxEventsPerAct: 500,
8
+ maxMessagesPerPair: 50,
9
+ maxBoardUpdatesPerKey: 100,
10
+ quietWindowMs: 60_000, // 1 minute
11
+ loopDetectionThreshold: 5,
12
+ threadTimeoutMs: 30 * 60_000, // 30 minutes, per user turn
13
+ };
14
+ // ── Safety Guard ────────────────────────────────────────
15
+ export class SafetyGuard {
16
+ eventCounts = new Map();
17
+ pairMessageCounts = new Map();
18
+ boardUpdateCounts = new Map();
19
+ recentPairs = []; // for loop detection
20
+ turnStartedAt;
21
+ config;
22
+ constructor(config) {
23
+ this.config = { ...DEFAULT_SAFETY_CONFIG, ...config };
24
+ }
25
+ /**
26
+ * Create a SafetyGuard from an ActSafetyConfig (from ActDefinition.safety).
27
+ * Maps field names from the shared type to the internal SafetyConfig.
28
+ */
29
+ static fromActSafety(actSafety) {
30
+ if (!actSafety)
31
+ return new SafetyGuard();
32
+ return new SafetyGuard({
33
+ maxEventsPerAct: actSafety.maxEvents,
34
+ maxMessagesPerPair: actSafety.maxMessagesPerPair,
35
+ maxBoardUpdatesPerKey: actSafety.maxBoardUpdatesPerKey,
36
+ quietWindowMs: actSafety.quietWindowMs,
37
+ loopDetectionThreshold: actSafety.loopDetectionThreshold,
38
+ ...(typeof actSafety.threadTimeoutMs === 'number'
39
+ ? { threadTimeoutMs: actSafety.threadTimeoutMs }
40
+ : {}),
41
+ });
42
+ }
43
+ /**
44
+ * Check total event budget for the Act thread.
45
+ */
46
+ checkEventBudget(event) {
47
+ const threadKey = typeof event.payload.threadId === 'string' ? event.payload.threadId : 'default';
48
+ const current = (this.eventCounts.get(threadKey) || 0) + 1;
49
+ this.eventCounts.set(threadKey, current);
50
+ if (current > this.config.maxEventsPerAct) {
51
+ return { ok: false, reason: `Event budget exceeded (${this.config.maxEventsPerAct} max). Thread should be completed.` };
52
+ }
53
+ return { ok: true };
54
+ }
55
+ /**
56
+ * Check messages between a pair of performers.
57
+ */
58
+ checkPairBudget(from, to) {
59
+ const key = [from, to].sort().join(':');
60
+ const current = (this.pairMessageCounts.get(key) || 0) + 1;
61
+ this.pairMessageCounts.set(key, current);
62
+ if (current > this.config.maxMessagesPerPair) {
63
+ return { ok: false, reason: `Message limit between ${from} and ${to} exceeded (${this.config.maxMessagesPerPair} max).` };
64
+ }
65
+ return { ok: true };
66
+ }
67
+ /**
68
+ * Check board update count for a key.
69
+ */
70
+ checkBoardUpdateBudget(key) {
71
+ const current = (this.boardUpdateCounts.get(key) || 0) + 1;
72
+ this.boardUpdateCounts.set(key, current);
73
+ if (current > this.config.maxBoardUpdatesPerKey) {
74
+ return { ok: false, reason: `Board key "${key}" update limit exceeded (${this.config.maxBoardUpdatesPerKey} max).` };
75
+ }
76
+ return { ok: true };
77
+ }
78
+ /**
79
+ * Check for loop patterns (A→B→A→B repeated).
80
+ */
81
+ checkLoopDetection(from, to) {
82
+ const pair = `${from}→${to}`;
83
+ this.recentPairs.push(pair);
84
+ // Keep only recent entries
85
+ if (this.recentPairs.length > this.config.loopDetectionThreshold * 4) {
86
+ this.recentPairs = this.recentPairs.slice(-this.config.loopDetectionThreshold * 4);
87
+ }
88
+ // Detect A→B→A→B pattern
89
+ const reversePair = `${to}→${from}`;
90
+ let alternations = 0;
91
+ for (let i = this.recentPairs.length - 1; i >= 1; i--) {
92
+ const current = this.recentPairs[i];
93
+ const prev = this.recentPairs[i - 1];
94
+ if ((current === pair && prev === reversePair) || (current === reversePair && prev === pair)) {
95
+ alternations++;
96
+ }
97
+ }
98
+ if (alternations >= this.config.loopDetectionThreshold) {
99
+ return { ok: false, reason: `Loop detected between ${from} and ${to} (${alternations} alternations). Breaking loop.` };
100
+ }
101
+ return { ok: true };
102
+ }
103
+ /**
104
+ * Check thread timeout.
105
+ */
106
+ checkTimeout(thread) {
107
+ if (typeof this.config.threadTimeoutMs !== 'number' || this.config.threadTimeoutMs <= 0) {
108
+ return { ok: true };
109
+ }
110
+ const startedAt = this.turnStartedAt ?? thread.createdAt;
111
+ const elapsed = Date.now() - startedAt;
112
+ if (elapsed > this.config.threadTimeoutMs) {
113
+ return { ok: false, reason: `Thread timeout exceeded (${Math.round(this.config.threadTimeoutMs / 60_000)} minutes max).` };
114
+ }
115
+ return { ok: true };
116
+ }
117
+ /**
118
+ * Check idle condition: no pending work in mailbox or queue.
119
+ */
120
+ checkIdleCondition(mailbox, queue) {
121
+ const hasPending = mailbox.getAllPendingMessages().length > 0;
122
+ const hasQueued = queue.hasAnyPending();
123
+ const hasWakeConditions = mailbox.getWakeConditions().length > 0;
124
+ return !hasPending && !hasQueued && !hasWakeConditions;
125
+ }
126
+ /**
127
+ * Check permission: does a relation exist between from and to?
128
+ */
129
+ checkPermission(from, to, relations) {
130
+ const hasRelation = relations.some((rel) => {
131
+ const [a, b] = rel.between;
132
+ if (rel.direction === 'one-way') {
133
+ return a === from && b === to;
134
+ }
135
+ return (a === from && b === to) || (a === to && b === from);
136
+ });
137
+ if (!hasRelation) {
138
+ return { ok: false, reason: `No relation exists between "${from}" and "${to}". Cannot send message.` };
139
+ }
140
+ return { ok: true };
141
+ }
142
+ /**
143
+ * Check board write policy.
144
+ */
145
+ checkBoardWritePolicy(entry, author) {
146
+ const wp = entry.writePolicy || 'any';
147
+ if (wp === 'author-only' && entry.author !== author) {
148
+ return { ok: false, reason: `Board key "${entry.key}" is author-only and owned by "${entry.author}".` };
149
+ }
150
+ return { ok: true };
151
+ }
152
+ /**
153
+ * Reset all counters (for new thread).
154
+ */
155
+ reset(turnStartedAt) {
156
+ this.eventCounts.clear();
157
+ this.pairMessageCounts.clear();
158
+ this.boardUpdateCounts.clear();
159
+ this.recentPairs = [];
160
+ this.turnStartedAt = turnStartedAt;
161
+ }
162
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * session-queue.ts — Same Participant Policy queue
3
+ *
4
+ * PRD §15.4: If the same participant is already executing, queue new wake-ups.
5
+ * Supports coalescing rules for board key updates and sender messages.
6
+ */
7
+ import { payloadString } from './act-runtime-utils.js';
8
+ export class SessionQueue {
9
+ queues = new Map();
10
+ running = new Set();
11
+ /**
12
+ * Mark a participant as currently running.
13
+ */
14
+ markRunning(participantKey) {
15
+ this.running.add(participantKey);
16
+ }
17
+ /**
18
+ * Clear running state without draining queued work.
19
+ */
20
+ clearRunning(participantKey) {
21
+ this.running.delete(participantKey);
22
+ }
23
+ /**
24
+ * Mark a participant as no longer running. Returns the next queued wake-up if any.
25
+ */
26
+ markIdle(participantKey) {
27
+ this.running.delete(participantKey);
28
+ return this.dequeue(participantKey);
29
+ }
30
+ /**
31
+ * Check if a participant is currently running.
32
+ */
33
+ isRunning(participantKey) {
34
+ return this.running.has(participantKey);
35
+ }
36
+ /**
37
+ * Enqueue a wake-up for a participant. Applies coalescing rules.
38
+ */
39
+ enqueue(participantKey, wakeUp) {
40
+ let queue = this.queues.get(participantKey);
41
+ if (!queue) {
42
+ queue = [];
43
+ this.queues.set(participantKey, queue);
44
+ }
45
+ // ── Coalescing rules ────────────────────────
46
+ const payload = wakeUp.triggerEvent.payload;
47
+ // Rule 1: Same board key update → replace (latest-only)
48
+ if (wakeUp.triggerEvent.type === 'board.posted' || wakeUp.triggerEvent.type === 'board.updated') {
49
+ const key = payloadString(payload, 'key');
50
+ const existingIdx = queue.findIndex((entry) => {
51
+ const ep = entry.target.triggerEvent.payload;
52
+ return (entry.target.triggerEvent.type === 'board.posted' || entry.target.triggerEvent.type === 'board.updated')
53
+ && payloadString(ep, 'key') === key;
54
+ });
55
+ if (existingIdx !== -1) {
56
+ queue[existingIdx] = { target: wakeUp, enqueuedAt: Date.now() };
57
+ return;
58
+ }
59
+ }
60
+ // Rule 2: Same sender consecutive messages → batch (replace with latest)
61
+ if (wakeUp.triggerEvent.type === 'message.sent') {
62
+ const from = payloadString(payload, 'from');
63
+ const tag = payloadString(payload, 'tag');
64
+ const existingIdx = queue.findIndex((entry) => {
65
+ const ep = entry.target.triggerEvent.payload;
66
+ return entry.target.triggerEvent.type === 'message.sent'
67
+ && payloadString(ep, 'from') === from
68
+ && payloadString(ep, 'tag') === tag; // Different tags are not merged
69
+ });
70
+ if (existingIdx !== -1) {
71
+ queue[existingIdx] = { target: wakeUp, enqueuedAt: Date.now() };
72
+ return;
73
+ }
74
+ }
75
+ // No coalescing — just append
76
+ queue.push({ target: wakeUp, enqueuedAt: Date.now() });
77
+ }
78
+ /**
79
+ * Dequeue the next wake-up for a participant.
80
+ */
81
+ dequeue(participantKey) {
82
+ const queue = this.queues.get(participantKey);
83
+ if (!queue || queue.length === 0)
84
+ return null;
85
+ const entry = queue.shift();
86
+ if (queue.length === 0) {
87
+ this.queues.delete(participantKey);
88
+ }
89
+ return entry.target;
90
+ }
91
+ /**
92
+ * Get the queue depth for a participant.
93
+ */
94
+ getQueueDepth(participantKey) {
95
+ return this.queues.get(participantKey)?.length ?? 0;
96
+ }
97
+ /**
98
+ * Check if any queues have pending items.
99
+ */
100
+ hasAnyPending() {
101
+ for (const queue of this.queues.values()) {
102
+ if (queue.length > 0)
103
+ return true;
104
+ }
105
+ return false;
106
+ }
107
+ /**
108
+ * Clear all queues and running state.
109
+ */
110
+ clear() {
111
+ this.queues.clear();
112
+ this.running.clear();
113
+ }
114
+ }