soja-cli 1.4.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/LICENSE +18 -0
- package/README.md +117 -0
- package/dist/application/chat.js +1 -0
- package/dist/application/filters.js +27 -0
- package/dist/application/import.js +1 -0
- package/dist/application/ports.js +1 -0
- package/dist/application/repository-root.js +18 -0
- package/dist/application/services/backup-service.js +93 -0
- package/dist/application/services/folder-service.js +116 -0
- package/dist/application/services/git-workflow-service.js +593 -0
- package/dist/application/services/index.js +24 -0
- package/dist/application/services/project-service.js +129 -0
- package/dist/application/services/session-service.js +52 -0
- package/dist/application/services/task-service.js +350 -0
- package/dist/application/services/update-service.js +60 -0
- package/dist/application/services/workspace-service.js +81 -0
- package/dist/application/timeline.js +83 -0
- package/dist/application/types.js +1 -0
- package/dist/application/validation.js +30 -0
- package/dist/bootstrap.js +91 -0
- package/dist/cli/commands/access.js +60 -0
- package/dist/cli/commands/account.js +203 -0
- package/dist/cli/commands/args.js +27 -0
- package/dist/cli/commands/backup.js +70 -0
- package/dist/cli/commands/chat.js +133 -0
- package/dist/cli/commands/dev.js +59 -0
- package/dist/cli/commands/folders.js +59 -0
- package/dist/cli/commands/git-ops.js +200 -0
- package/dist/cli/commands/import.js +48 -0
- package/dist/cli/commands/info.js +82 -0
- package/dist/cli/commands/project.js +108 -0
- package/dist/cli/commands/start.js +34 -0
- package/dist/cli/commands/sync.js +43 -0
- package/dist/cli/commands/task.js +309 -0
- package/dist/cli/commands/tui.js +43 -0
- package/dist/cli/commands/update.js +20 -0
- package/dist/cli/commands/workspace.js +55 -0
- package/dist/cli/index.js +103 -0
- package/dist/cli/output.js +56 -0
- package/dist/cli/prompt.js +28 -0
- package/dist/cli/runtime.js +43 -0
- package/dist/config/config.js +103 -0
- package/dist/config/credentials.js +68 -0
- package/dist/config/paths.js +18 -0
- package/dist/config/server.js +21 -0
- package/dist/config/version.js +7 -0
- package/dist/data/local/activity.js +33 -0
- package/dist/data/local/clock.js +1 -0
- package/dist/data/local/comments.js +27 -0
- package/dist/data/local/index.js +48 -0
- package/dist/data/local/projects.js +53 -0
- package/dist/data/local/tasks.js +104 -0
- package/dist/data/local/users.js +38 -0
- package/dist/data/local/workspaces.js +55 -0
- package/dist/data/remote/api-client.js +103 -0
- package/dist/data/remote/live.js +93 -0
- package/dist/data/repositories.js +1 -0
- package/dist/data/sync/chat.js +242 -0
- package/dist/data/sync/engine.js +459 -0
- package/dist/data/sync/importer.js +156 -0
- package/dist/data/sync/index.js +109 -0
- package/dist/data/sync/services.js +263 -0
- package/dist/data/sync/store.js +200 -0
- package/dist/database/client.js +76 -0
- package/dist/database/migrate.js +27 -0
- package/dist/database/migrations/0000_init.sql +100 -0
- package/dist/database/migrations/0001_git-operations.sql +19 -0
- package/dist/database/migrations/0002_merge-detection.sql +19 -0
- package/dist/database/migrations/0003_sync-replica.sql +40 -0
- package/dist/database/migrations/0004_chat-replica.sql +52 -0
- package/dist/database/migrations/0005_pr-events.sql +18 -0
- package/dist/database/migrations/0006_archive.sql +20 -0
- package/dist/database/migrations/meta/0000_snapshot.json +710 -0
- package/dist/database/migrations/meta/0001_snapshot.json +717 -0
- package/dist/database/migrations/meta/0002_snapshot.json +724 -0
- package/dist/database/migrations/meta/0003_snapshot.json +972 -0
- package/dist/database/migrations/meta/0004_snapshot.json +1168 -0
- package/dist/database/migrations/meta/0005_snapshot.json +1168 -0
- package/dist/database/migrations/meta/0006_snapshot.json +1183 -0
- package/dist/database/migrations/meta/_journal.json +55 -0
- package/dist/database/schema.js +170 -0
- package/dist/dev/seed.js +84 -0
- package/dist/domain/activity.js +22 -0
- package/dist/domain/chat.js +21 -0
- package/dist/domain/entities.js +1 -0
- package/dist/domain/errors.js +23 -0
- package/dist/domain/naming.js +33 -0
- package/dist/domain/task.js +140 -0
- package/dist/domain/workflow.js +23 -0
- package/dist/git/cli-git.js +363 -0
- package/dist/git/console.js +37 -0
- package/dist/git/diagnose.js +145 -0
- package/dist/git/merge-evidence.js +23 -0
- package/dist/git/releases.js +95 -0
- package/dist/git/types.js +16 -0
- package/dist/ui/App.js +71 -0
- package/dist/ui/Shell.js +211 -0
- package/dist/ui/app-state.js +213 -0
- package/dist/ui/branding/CompactLogo.js +8 -0
- package/dist/ui/branding/Logo.js +22 -0
- package/dist/ui/branding/Splash.js +21 -0
- package/dist/ui/branding/brand.js +23 -0
- package/dist/ui/chrome/Sidebar.js +50 -0
- package/dist/ui/chrome/StatusBar.js +26 -0
- package/dist/ui/chrome/Toast.js +11 -0
- package/dist/ui/chrome/TopBar.js +10 -0
- package/dist/ui/chrome/context.js +33 -0
- package/dist/ui/components/ConsoleLines.js +16 -0
- package/dist/ui/components/EmptyState.js +8 -0
- package/dist/ui/components/Labels.js +25 -0
- package/dist/ui/components/PullRequestLabel.js +27 -0
- package/dist/ui/components/TaskTable.js +41 -0
- package/dist/ui/components/TextField.js +11 -0
- package/dist/ui/components/TextInput.js +29 -0
- package/dist/ui/components/task-columns.js +27 -0
- package/dist/ui/copy.js +23 -0
- package/dist/ui/hooks/use-commands.js +58 -0
- package/dist/ui/hooks/use-flows.js +254 -0
- package/dist/ui/hooks/use-layout.js +36 -0
- package/dist/ui/hooks/use-list.js +28 -0
- package/dist/ui/hooks/use-measure.js +22 -0
- package/dist/ui/hooks/use-query.js +29 -0
- package/dist/ui/hooks/use-task-actions.js +450 -0
- package/dist/ui/hooks/use-terminal-size.js +19 -0
- package/dist/ui/input/KeyProvider.js +101 -0
- package/dist/ui/input/dispatcher.js +26 -0
- package/dist/ui/input/list-navigation.js +38 -0
- package/dist/ui/input/mouse.js +78 -0
- package/dist/ui/input/text-editing.js +62 -0
- package/dist/ui/kit/Badge.js +8 -0
- package/dist/ui/kit/Button.js +13 -0
- package/dist/ui/kit/Clickable.js +14 -0
- package/dist/ui/kit/Gauge.js +9 -0
- package/dist/ui/kit/Keycap.js +7 -0
- package/dist/ui/kit/Panel.js +30 -0
- package/dist/ui/kit/ScrollBar.js +14 -0
- package/dist/ui/kit/Spinner.js +9 -0
- package/dist/ui/kit/Tabs.js +12 -0
- package/dist/ui/mascot/AgentScene.js +30 -0
- package/dist/ui/mascot/Mascot.js +26 -0
- package/dist/ui/mascot/ansi.js +25 -0
- package/dist/ui/mascot/engine.js +146 -0
- package/dist/ui/mascot/index.js +6 -0
- package/dist/ui/mascot/palette.js +18 -0
- package/dist/ui/mascot/scenes/bugHunt.js +115 -0
- package/dist/ui/mascot/scenes/celebrate.js +76 -0
- package/dist/ui/mascot/scenes/cleaning.js +156 -0
- package/dist/ui/mascot/scenes/clockIn.js +105 -0
- package/dist/ui/mascot/scenes/deadline.js +125 -0
- package/dist/ui/mascot/scenes/empty.js +149 -0
- package/dist/ui/mascot/scenes/fire.js +138 -0
- package/dist/ui/mascot/scenes/index.js +25 -0
- package/dist/ui/mascot/scenes/planning.js +73 -0
- package/dist/ui/mascot/scenes/resting.js +90 -0
- package/dist/ui/mascot/scenes/syncing.js +116 -0
- package/dist/ui/mascot/scenes/working.js +193 -0
- package/dist/ui/mascot/selection.js +32 -0
- package/dist/ui/mascot/states.js +25 -0
- package/dist/ui/navigation/routes.js +17 -0
- package/dist/ui/overlays/CommitOverlay.js +93 -0
- package/dist/ui/overlays/ConfirmOverlay.js +42 -0
- package/dist/ui/overlays/GitLogOverlay.js +22 -0
- package/dist/ui/overlays/GitRunOverlay.js +94 -0
- package/dist/ui/overlays/NewTaskOverlay.js +117 -0
- package/dist/ui/overlays/OverlayFrame.js +35 -0
- package/dist/ui/overlays/PickerOverlay.js +72 -0
- package/dist/ui/overlays/PromptOverlay.js +51 -0
- package/dist/ui/overlays/SearchOverlay.js +49 -0
- package/dist/ui/overlays/options.js +29 -0
- package/dist/ui/overlays/types.js +1 -0
- package/dist/ui/screens/AccessScreen.js +91 -0
- package/dist/ui/screens/ChatScreen.js +450 -0
- package/dist/ui/screens/HelpScreen.js +99 -0
- package/dist/ui/screens/ProjectsScreen.js +66 -0
- package/dist/ui/screens/ScreenFrame.js +14 -0
- package/dist/ui/screens/SetupScreen.js +79 -0
- package/dist/ui/screens/TaskListScreen.js +152 -0
- package/dist/ui/screens/TaskScreen.js +193 -0
- package/dist/ui/screens/WelcomeScreen.js +254 -0
- package/dist/ui/screens/WorkspacesScreen.js +64 -0
- package/dist/ui/theme/detect.js +59 -0
- package/dist/ui/theme/theme.js +135 -0
- package/dist/utils/editor.js +37 -0
- package/dist/utils/errors.js +23 -0
- package/dist/utils/text.js +60 -0
- package/dist/utils/time.js +25 -0
- package/docs/NPM_PUBLISH.md +35 -0
- package/docs/PRIVACY.md +38 -0
- package/package.json +74 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { formatTaskRef, isProvisional, TASK_REF_PREFIX } from '../../domain/task.js';
|
|
3
|
+
import { OfflineError } from '../remote/api-client.js';
|
|
4
|
+
import { isChatOp } from './store.js';
|
|
5
|
+
const BATCH = 100;
|
|
6
|
+
const TASK_FIELDS = new Set(['title', 'description', 'projectId', 'type', 'priority', 'status', 'assigneeId', 'requester', 'branch', 'baseBranch', 'branchStart']);
|
|
7
|
+
/** Server task view → stored task row (presentation fields are dropped by the store). */
|
|
8
|
+
const toTask = (view) => view;
|
|
9
|
+
const emptyReport = () => ({
|
|
10
|
+
online: true,
|
|
11
|
+
pushed: 0,
|
|
12
|
+
conflicts: 0,
|
|
13
|
+
rejected: 0,
|
|
14
|
+
chatRejected: 0,
|
|
15
|
+
deferred: 0,
|
|
16
|
+
pulledTasks: 0,
|
|
17
|
+
pulledMessages: 0,
|
|
18
|
+
changes: { taskRefs: [], projects: false, members: false, chatChannelIds: [], chatMessageChannelIds: [], chatReads: false },
|
|
19
|
+
});
|
|
20
|
+
function include(values, value) {
|
|
21
|
+
if (!values.includes(value))
|
|
22
|
+
values.push(value);
|
|
23
|
+
}
|
|
24
|
+
function taskIdOf(op) {
|
|
25
|
+
if (!op.taskId)
|
|
26
|
+
throw new Error(`Operation ${op.type} has no task.`);
|
|
27
|
+
return op.taskId;
|
|
28
|
+
}
|
|
29
|
+
/** `SOJA-?3` as written in text, not followed by more digits. */
|
|
30
|
+
const provisionalRefPattern = (number) => new RegExp(`\\b${TASK_REF_PREFIX}-\\?${-number}(?!\\d)`, 'gi');
|
|
31
|
+
/**
|
|
32
|
+
* A message that names a task created offline (`SOJA-?1`) waits until that
|
|
33
|
+
* task has its real number, so it reaches the server as `SOJA-7`. The batch
|
|
34
|
+
* stops before such a message when the task is created earlier in the batch.
|
|
35
|
+
*/
|
|
36
|
+
function sendable(batch) {
|
|
37
|
+
const provisional = new RegExp(`\\b${TASK_REF_PREFIX}-\\?\\d`, 'i');
|
|
38
|
+
let creates = false;
|
|
39
|
+
for (const [index, op] of batch.entries()) {
|
|
40
|
+
if (op.type === 'task.create')
|
|
41
|
+
creates = true;
|
|
42
|
+
else if (creates && isChatOp(op.type) && provisional.test(String(op.payload['body'] ?? '')))
|
|
43
|
+
return batch.slice(0, index);
|
|
44
|
+
}
|
|
45
|
+
return [...batch];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Keeps a workspace replica in step with the SOJA server (docs/SYNC.md in
|
|
49
|
+
* soja-backend): push the outbox, apply results, pull changes, re-apply
|
|
50
|
+
* what is still pending. One cycle at a time; failures leave the outbox
|
|
51
|
+
* intact for the next attempt.
|
|
52
|
+
*/
|
|
53
|
+
export class SyncEngine {
|
|
54
|
+
api;
|
|
55
|
+
store;
|
|
56
|
+
running = null;
|
|
57
|
+
queued = null;
|
|
58
|
+
online = null;
|
|
59
|
+
/** Called with null when a cycle starts and with its report when it ends. */
|
|
60
|
+
listeners = new Set();
|
|
61
|
+
constructor(api, store) {
|
|
62
|
+
this.api = api;
|
|
63
|
+
this.store = store;
|
|
64
|
+
}
|
|
65
|
+
subscribe(listener) {
|
|
66
|
+
this.listeners.add(listener);
|
|
67
|
+
return () => this.listeners.delete(listener);
|
|
68
|
+
}
|
|
69
|
+
async status(workspaceId) {
|
|
70
|
+
const [pending, state] = await Promise.all([this.store.pending(workspaceId), this.store.state(workspaceId)]);
|
|
71
|
+
return { online: this.online, syncing: this.running !== null, pending: pending.length, lastSyncAt: state.lastSyncAt, lastError: state.lastError };
|
|
72
|
+
}
|
|
73
|
+
/** Downloads who you are and your workspaces. Needed before the first sync of a workspace. */
|
|
74
|
+
async refreshAccount() {
|
|
75
|
+
const me = await this.api.get('/v1/me');
|
|
76
|
+
await this.store.upsertUser(me.user);
|
|
77
|
+
for (const workspace of me.workspaces) {
|
|
78
|
+
await this.store.upsertWorkspace(workspace);
|
|
79
|
+
await this.store.upsertMember(workspace.id, { ...me.user, role: workspace.role });
|
|
80
|
+
}
|
|
81
|
+
this.online = true;
|
|
82
|
+
return me;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Runs one sync cycle. While one is running, a single follow-up cycle is
|
|
86
|
+
* queued (whatever was written meanwhile may have missed its push); every
|
|
87
|
+
* caller in between shares it. Never throws.
|
|
88
|
+
*/
|
|
89
|
+
sync(workspaceId) {
|
|
90
|
+
if (this.running) {
|
|
91
|
+
this.queued ??= this.running.then(() => {
|
|
92
|
+
this.queued = null;
|
|
93
|
+
return this.sync(workspaceId);
|
|
94
|
+
});
|
|
95
|
+
return this.queued;
|
|
96
|
+
}
|
|
97
|
+
this.running = this.cycle(workspaceId).then((report) => {
|
|
98
|
+
this.running = null;
|
|
99
|
+
this.emit(report);
|
|
100
|
+
return report;
|
|
101
|
+
});
|
|
102
|
+
this.emit(null);
|
|
103
|
+
return this.running;
|
|
104
|
+
}
|
|
105
|
+
async cycle(workspaceId) {
|
|
106
|
+
const report = emptyReport();
|
|
107
|
+
let pushError = null;
|
|
108
|
+
try {
|
|
109
|
+
try {
|
|
110
|
+
await this.push(workspaceId, report);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
// Offline: nothing else will work either. A server error: still pull others' changes.
|
|
114
|
+
if (error instanceof OfflineError)
|
|
115
|
+
throw error;
|
|
116
|
+
pushError = error;
|
|
117
|
+
await this.store.markAttempt(workspaceId, error instanceof Error ? error.message : String(error));
|
|
118
|
+
}
|
|
119
|
+
await this.pull(workspaceId, report);
|
|
120
|
+
await this.reapplyPending(workspaceId);
|
|
121
|
+
if (pushError)
|
|
122
|
+
throw pushError;
|
|
123
|
+
await this.store.saveState(workspaceId, { lastSyncAt: new Date(), lastError: null });
|
|
124
|
+
this.online = true;
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
128
|
+
this.online = !(error instanceof OfflineError);
|
|
129
|
+
report.online = this.online;
|
|
130
|
+
report.error = message;
|
|
131
|
+
// Best effort: the replica may already be closed when a late cycle fails.
|
|
132
|
+
if (error !== pushError)
|
|
133
|
+
await this.store.markAttempt(workspaceId, message).catch(() => undefined);
|
|
134
|
+
await this.store.saveState(workspaceId, { lastError: message }).catch(() => undefined);
|
|
135
|
+
// Pending changes stay visible even when the pull could not run.
|
|
136
|
+
await this.reapplyPending(workspaceId).catch(() => undefined);
|
|
137
|
+
}
|
|
138
|
+
return report;
|
|
139
|
+
}
|
|
140
|
+
async push(workspaceId, report) {
|
|
141
|
+
for (;;) {
|
|
142
|
+
const batch = sendable((await this.store.pending(workspaceId)).slice(0, BATCH));
|
|
143
|
+
if (batch.length === 0)
|
|
144
|
+
return;
|
|
145
|
+
const { results } = await this.api.post(`/v1/workspaces/${workspaceId}/ops`, {
|
|
146
|
+
ops: batch.map((op) => ({
|
|
147
|
+
opId: op.opId,
|
|
148
|
+
type: op.type,
|
|
149
|
+
...(op.taskId ? { taskId: op.taskId } : {}),
|
|
150
|
+
occurredAt: op.occurredAt.toISOString(),
|
|
151
|
+
payload: op.payload,
|
|
152
|
+
...(op.base ? { base: op.base } : {}),
|
|
153
|
+
})),
|
|
154
|
+
});
|
|
155
|
+
const acknowledged = [];
|
|
156
|
+
const refresh = new Set();
|
|
157
|
+
let unanswered = 0;
|
|
158
|
+
for (const op of batch) {
|
|
159
|
+
const result = results.find((candidate) => candidate.opId === op.opId);
|
|
160
|
+
if (!result) {
|
|
161
|
+
unanswered += 1;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
acknowledged.push(op.opId);
|
|
165
|
+
if (isChatOp(op.type)) {
|
|
166
|
+
await this.chatResult(workspaceId, op, result, report);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (op.type === 'task.delete') {
|
|
170
|
+
await this.deleteResult(workspaceId, op, result, report);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (result.status === 'applied' && result.task) {
|
|
174
|
+
report.pushed += 1;
|
|
175
|
+
include(report.changes.taskRefs, result.task.ref);
|
|
176
|
+
const before = op.type === 'task.create' ? await this.store.findTask(taskIdOf(op)) : null;
|
|
177
|
+
if (before)
|
|
178
|
+
include(report.changes.taskRefs, formatTaskRef(before.number));
|
|
179
|
+
await this.store.upsertTask(toTask(result.task));
|
|
180
|
+
if (before && isProvisional(before.number) && !isProvisional(result.task.number)) {
|
|
181
|
+
await this.renumberInChat(workspaceId, before.number, result.task.number, report);
|
|
182
|
+
}
|
|
183
|
+
for (const conflict of result.conflicts ?? []) {
|
|
184
|
+
report.conflicts += 1;
|
|
185
|
+
await this.store.addNotice(workspaceId, {
|
|
186
|
+
id: randomUUID(),
|
|
187
|
+
taskId: op.taskId,
|
|
188
|
+
taskRef: result.task.ref,
|
|
189
|
+
kind: 'conflict',
|
|
190
|
+
field: conflict.field,
|
|
191
|
+
overwritten: conflict.overwritten,
|
|
192
|
+
message: `Your ${conflict.field} change on ${result.task.ref} replaced a newer value: ${describe(conflict.overwritten)}`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
report.rejected += 1;
|
|
198
|
+
const task = await this.store.findTask(taskIdOf(op));
|
|
199
|
+
await this.rejected(workspaceId, op, result, refresh);
|
|
200
|
+
if (task)
|
|
201
|
+
include(report.changes.taskRefs, formatTaskRef(task.number));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
await this.store.acknowledge(acknowledged);
|
|
205
|
+
// Best effort: the rejection notice is already recorded; the next pull corrects the rest.
|
|
206
|
+
for (const taskId of refresh)
|
|
207
|
+
await this.refreshTask(workspaceId, taskId).catch(() => undefined);
|
|
208
|
+
// The server is pacing us (message rate limit): the rest waits for a later cycle.
|
|
209
|
+
if (unanswered > 0) {
|
|
210
|
+
report.deferred += unanswered;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async chatResult(workspaceId, op, result, report) {
|
|
216
|
+
if (result.status === 'applied') {
|
|
217
|
+
if (result.message) {
|
|
218
|
+
await this.store.upsertMessage(result.message);
|
|
219
|
+
include(report.changes.chatMessageChannelIds, result.message.channelId);
|
|
220
|
+
}
|
|
221
|
+
if (result.read) {
|
|
222
|
+
await this.store.markRead(result.read.channelId, result.read.lastReadSeq);
|
|
223
|
+
report.changes.chatReads = true;
|
|
224
|
+
include(report.changes.chatChannelIds, result.read.channelId);
|
|
225
|
+
}
|
|
226
|
+
if (op.type !== 'channel.read')
|
|
227
|
+
report.pushed += 1;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
// A refused read mark changes nothing you can see.
|
|
231
|
+
if (op.type === 'channel.read')
|
|
232
|
+
return;
|
|
233
|
+
report.chatRejected += 1;
|
|
234
|
+
const messageId = String(op.payload['messageId'] ?? '');
|
|
235
|
+
const message = await this.store.findMessage(messageId);
|
|
236
|
+
const channel = message ? await this.store.findChannel(message.channelId) : null;
|
|
237
|
+
if (message)
|
|
238
|
+
include(report.changes.chatMessageChannelIds, message.channelId);
|
|
239
|
+
const where = channel ? `#${channel.name}` : 'the chat';
|
|
240
|
+
const reason = result.error?.message ?? 'rejected by the server';
|
|
241
|
+
const body = typeof op.payload['body'] === 'string' ? op.payload['body'] : null;
|
|
242
|
+
await this.store.addNotice(workspaceId, {
|
|
243
|
+
id: randomUUID(),
|
|
244
|
+
taskId: null,
|
|
245
|
+
taskRef: where,
|
|
246
|
+
kind: 'rejected',
|
|
247
|
+
field: 'chat',
|
|
248
|
+
// Your text, so the chat can give it back to you.
|
|
249
|
+
overwritten: body,
|
|
250
|
+
message: `${op.type === 'message.send' ? `Message to ${where} not sent` : `Change to a message in ${where} not applied`}: ${reason}`,
|
|
251
|
+
});
|
|
252
|
+
if (op.type === 'message.send')
|
|
253
|
+
await this.store.deleteMessage(messageId);
|
|
254
|
+
else if (message)
|
|
255
|
+
await this.refreshMessage(workspaceId, message).catch(() => undefined);
|
|
256
|
+
}
|
|
257
|
+
/** A refused delete (e.g. you are no longer an owner) brings the task back as the server has it. */
|
|
258
|
+
async deleteResult(workspaceId, op, result, report) {
|
|
259
|
+
const number = Number(op.payload['number']);
|
|
260
|
+
const ref = formatTaskRef(number);
|
|
261
|
+
if (result.status === 'applied') {
|
|
262
|
+
report.pushed += 1;
|
|
263
|
+
include(report.changes.taskRefs, ref);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
report.rejected += 1;
|
|
267
|
+
await this.store.addNotice(workspaceId, {
|
|
268
|
+
id: randomUUID(),
|
|
269
|
+
taskId: op.taskId,
|
|
270
|
+
taskRef: ref,
|
|
271
|
+
kind: 'rejected',
|
|
272
|
+
field: null,
|
|
273
|
+
overwritten: null,
|
|
274
|
+
message: `Could not delete ${ref}: ${result.error?.message ?? 'rejected by the server'}`,
|
|
275
|
+
});
|
|
276
|
+
if (!isProvisional(number)) {
|
|
277
|
+
await this.refreshTaskByRef(workspaceId, ref).catch(() => undefined);
|
|
278
|
+
include(report.changes.taskRefs, ref);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/** Reloads one message as the server has it (undoing a refused edit or delete). */
|
|
282
|
+
async refreshMessage(workspaceId, message) {
|
|
283
|
+
if (message.seq === null)
|
|
284
|
+
return;
|
|
285
|
+
const [server] = await this.api.get(`/v1/workspaces/${workspaceId}/channels/${message.channelId}/messages?before=${message.seq + 1}&limit=1`);
|
|
286
|
+
if (server?.id === message.id)
|
|
287
|
+
await this.store.upsertMessage(server);
|
|
288
|
+
}
|
|
289
|
+
/** Queued and unsent messages that named `SOJA-?n` now name the real task. */
|
|
290
|
+
async renumberInChat(workspaceId, provisional, number, report) {
|
|
291
|
+
const pattern = provisionalRefPattern(provisional);
|
|
292
|
+
const ref = formatTaskRef(number);
|
|
293
|
+
for (const op of await this.store.pending(workspaceId)) {
|
|
294
|
+
const body = op.payload['body'];
|
|
295
|
+
if (!isChatOp(op.type) || typeof body !== 'string' || !pattern.test(body))
|
|
296
|
+
continue;
|
|
297
|
+
pattern.lastIndex = 0;
|
|
298
|
+
await this.store.updateQueuedPayload(op.opId, { ...op.payload, body: body.replace(pattern, ref) });
|
|
299
|
+
const messageId = String(op.payload['messageId'] ?? '');
|
|
300
|
+
const message = await this.store.findMessage(messageId);
|
|
301
|
+
if (message && message.seq === null) {
|
|
302
|
+
await this.store.patchMessage(messageId, { body: body.replace(pattern, ref) });
|
|
303
|
+
include(report.changes.chatMessageChannelIds, message.channelId);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
async rejected(workspaceId, op, result, refresh) {
|
|
308
|
+
const taskId = taskIdOf(op);
|
|
309
|
+
const task = await this.store.findTask(taskId);
|
|
310
|
+
const ref = task ? formatTaskRef(task.number) : 'a task';
|
|
311
|
+
await this.store.addNotice(workspaceId, {
|
|
312
|
+
id: randomUUID(),
|
|
313
|
+
taskId: op.type === 'task.create' ? null : taskId,
|
|
314
|
+
taskRef: ref,
|
|
315
|
+
kind: 'rejected',
|
|
316
|
+
field: null,
|
|
317
|
+
overwritten: null,
|
|
318
|
+
message: `${op.type === 'task.create' ? `Could not create ${ref}` : `A change to ${ref} was not applied`}: ${result.error?.message ?? 'rejected by the server'}`,
|
|
319
|
+
});
|
|
320
|
+
// A task the server never accepted must not linger; other rejections are undone by reloading the task.
|
|
321
|
+
if (op.type === 'task.create')
|
|
322
|
+
await this.store.deleteTask(taskId);
|
|
323
|
+
else
|
|
324
|
+
refresh.add(taskId);
|
|
325
|
+
}
|
|
326
|
+
async refreshTask(workspaceId, taskId) {
|
|
327
|
+
const task = await this.store.findTask(taskId);
|
|
328
|
+
if (!task || isProvisional(task.number))
|
|
329
|
+
return;
|
|
330
|
+
await this.refreshTaskByRef(workspaceId, formatTaskRef(task.number));
|
|
331
|
+
}
|
|
332
|
+
async refreshTaskByRef(workspaceId, ref) {
|
|
333
|
+
const details = await this.api.get(`/v1/workspaces/${workspaceId}/tasks/${ref}`);
|
|
334
|
+
await this.store.upsertTask(toTask(details.task));
|
|
335
|
+
for (const comment of details.comments)
|
|
336
|
+
await this.store.upsertComment(comment);
|
|
337
|
+
for (const activity of details.activity)
|
|
338
|
+
await this.store.upsertActivity(activity);
|
|
339
|
+
}
|
|
340
|
+
async pull(workspaceId, report) {
|
|
341
|
+
let { cursor } = await this.store.state(workspaceId);
|
|
342
|
+
for (;;) {
|
|
343
|
+
const batch = await this.api.get(`/v1/workspaces/${workspaceId}/changes?after=${cursor}&limit=500`);
|
|
344
|
+
for (const member of batch.members)
|
|
345
|
+
await this.store.upsertMember(workspaceId, member);
|
|
346
|
+
if (batch.members.length)
|
|
347
|
+
report.changes.members = true;
|
|
348
|
+
for (const project of batch.projects)
|
|
349
|
+
await this.store.upsertProject(project);
|
|
350
|
+
if (batch.projects.length)
|
|
351
|
+
report.changes.projects = true;
|
|
352
|
+
for (const task of batch.tasks) {
|
|
353
|
+
await this.store.upsertTask(toTask(task));
|
|
354
|
+
include(report.changes.taskRefs, task.ref);
|
|
355
|
+
}
|
|
356
|
+
for (const comment of batch.comments) {
|
|
357
|
+
await this.store.upsertComment(comment);
|
|
358
|
+
const task = await this.store.findTask(comment.taskId);
|
|
359
|
+
if (task)
|
|
360
|
+
include(report.changes.taskRefs, formatTaskRef(task.number));
|
|
361
|
+
}
|
|
362
|
+
for (const activity of batch.activity) {
|
|
363
|
+
await this.store.upsertActivity(activity);
|
|
364
|
+
const task = await this.store.findTask(activity.taskId);
|
|
365
|
+
if (task)
|
|
366
|
+
include(report.changes.taskRefs, formatTaskRef(task.number));
|
|
367
|
+
}
|
|
368
|
+
for (const channel of batch.channels ?? []) {
|
|
369
|
+
await this.store.upsertChannel(channel);
|
|
370
|
+
include(report.changes.chatChannelIds, channel.id);
|
|
371
|
+
}
|
|
372
|
+
for (const message of batch.messages ?? []) {
|
|
373
|
+
await this.store.upsertMessage(message);
|
|
374
|
+
include(report.changes.chatMessageChannelIds, message.channelId);
|
|
375
|
+
}
|
|
376
|
+
for (const read of batch.reads ?? []) {
|
|
377
|
+
await this.store.markRead(read.channelId, read.lastReadSeq);
|
|
378
|
+
report.changes.chatReads = true;
|
|
379
|
+
include(report.changes.chatChannelIds, read.channelId);
|
|
380
|
+
}
|
|
381
|
+
for (const taskId of batch.deletedTasks ?? []) {
|
|
382
|
+
const task = await this.store.findTask(taskId);
|
|
383
|
+
if (task)
|
|
384
|
+
include(report.changes.taskRefs, formatTaskRef(task.number));
|
|
385
|
+
await this.store.deleteTask(taskId);
|
|
386
|
+
}
|
|
387
|
+
report.pulledTasks += batch.tasks.length;
|
|
388
|
+
report.pulledMessages += batch.messages?.length ?? 0;
|
|
389
|
+
cursor = batch.cursor;
|
|
390
|
+
await this.store.saveState(workspaceId, { cursor });
|
|
391
|
+
if (!batch.hasMore)
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/** Server rows may predate queued edits; put the queued field values back on top. */
|
|
396
|
+
async reapplyPending(workspaceId) {
|
|
397
|
+
for (const op of await this.store.pending(workspaceId)) {
|
|
398
|
+
const messageId = String(op.payload['messageId'] ?? '');
|
|
399
|
+
if (op.type === 'task.change') {
|
|
400
|
+
const fields = Object.fromEntries(Object.entries(op.payload).filter(([field]) => TASK_FIELDS.has(field)));
|
|
401
|
+
if (typeof op.payload['archived'] === 'boolean')
|
|
402
|
+
fields.archivedAt = op.payload['archived'] ? op.occurredAt : null;
|
|
403
|
+
await this.store.patchTask(taskIdOf(op), fields);
|
|
404
|
+
}
|
|
405
|
+
else if (op.type === 'message.edit') {
|
|
406
|
+
await this.store.patchMessage(messageId, { body: String(op.payload['body'] ?? ''), editedAt: op.occurredAt });
|
|
407
|
+
}
|
|
408
|
+
else if (op.type === 'message.delete') {
|
|
409
|
+
await this.store.patchMessage(messageId, { body: '', deletedAt: op.occurredAt });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Applies a chat event from the live connection right away. Returns true
|
|
415
|
+
* when the caller should run a sync instead (the event only says "changed").
|
|
416
|
+
*/
|
|
417
|
+
async receive(workspaceId, event) {
|
|
418
|
+
switch (event.type) {
|
|
419
|
+
case 'changes.available':
|
|
420
|
+
return true;
|
|
421
|
+
case 'message.created':
|
|
422
|
+
case 'message.updated':
|
|
423
|
+
if (event.message.workspaceId !== workspaceId)
|
|
424
|
+
return false;
|
|
425
|
+
await this.store.upsertMessage(event.message);
|
|
426
|
+
break;
|
|
427
|
+
case 'channel.created':
|
|
428
|
+
case 'channel.updated':
|
|
429
|
+
if (event.channel.workspaceId !== workspaceId)
|
|
430
|
+
return false;
|
|
431
|
+
await this.store.upsertChannel(event.channel);
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
await this.reapplyPending(workspaceId);
|
|
435
|
+
this.online = true;
|
|
436
|
+
const report = { ...emptyReport(), live: true, pulledMessages: event.type.startsWith('message.') ? 1 : 0 };
|
|
437
|
+
switch (event.type) {
|
|
438
|
+
case 'message.created':
|
|
439
|
+
case 'message.updated':
|
|
440
|
+
include(report.changes.chatMessageChannelIds, event.message.channelId);
|
|
441
|
+
break;
|
|
442
|
+
case 'channel.created':
|
|
443
|
+
case 'channel.updated':
|
|
444
|
+
include(report.changes.chatChannelIds, event.channel.id);
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
this.emit(report);
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
emit(report) {
|
|
451
|
+
for (const listener of this.listeners)
|
|
452
|
+
listener(report);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
function describe(value) {
|
|
456
|
+
if (value === null || value === undefined || value === '')
|
|
457
|
+
return '(empty)';
|
|
458
|
+
return typeof value === 'string' ? `“${value}”` : JSON.stringify(value);
|
|
459
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { openDatabase } from '../../database/client.js';
|
|
4
|
+
import { runMigrations } from '../../database/migrate.js';
|
|
5
|
+
import { NotFoundError, SojaError } from '../../domain/errors.js';
|
|
6
|
+
import { OfflineError } from '../remote/api-client.js';
|
|
7
|
+
import { createLocalRepositories } from '../local/index.js';
|
|
8
|
+
/**
|
|
9
|
+
* Reads the local-mode database (`soja.db`) and sends its history to the
|
|
10
|
+
* SOJA server in one request (`POST /import`, soja-backend ≥ 1.0). The local
|
|
11
|
+
* database is only read, never changed.
|
|
12
|
+
*/
|
|
13
|
+
export class LocalImporter {
|
|
14
|
+
localFile;
|
|
15
|
+
config;
|
|
16
|
+
api;
|
|
17
|
+
store;
|
|
18
|
+
engine;
|
|
19
|
+
constructor(localFile, config, api, store, engine) {
|
|
20
|
+
this.localFile = localFile;
|
|
21
|
+
this.config = config;
|
|
22
|
+
this.api = api;
|
|
23
|
+
this.store = store;
|
|
24
|
+
this.engine = engine;
|
|
25
|
+
}
|
|
26
|
+
async preview(session, options = {}) {
|
|
27
|
+
return this.withLocal(async (repos) => {
|
|
28
|
+
const data = await this.collect(repos, options.from);
|
|
29
|
+
return {
|
|
30
|
+
from: { name: data.workspace.name, slug: data.workspace.slug },
|
|
31
|
+
projects: data.projects.length,
|
|
32
|
+
tasks: data.tasks.length,
|
|
33
|
+
comments: data.comments.length,
|
|
34
|
+
activity: data.activity.length,
|
|
35
|
+
developers: data.users.map((user) => user.username),
|
|
36
|
+
alreadyImported: await this.alreadyImported(session, data.workspace),
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
async run(session, options = {}) {
|
|
41
|
+
return this.withLocal(async (repos) => {
|
|
42
|
+
const data = await this.collect(repos, options.from);
|
|
43
|
+
let result;
|
|
44
|
+
try {
|
|
45
|
+
result = await this.api.post(`/v1/workspaces/${session.workspace.id}/import`, {
|
|
46
|
+
importId: importId(data.workspace.id, session.workspace.id),
|
|
47
|
+
users: data.users.map((user) => ({ localId: user.id, username: user.username })),
|
|
48
|
+
projects: data.projects.map(({ id, name, key, description, repositoryUrl, createdAt, updatedAt }) => ({
|
|
49
|
+
localId: id, name, key, description, repositoryUrl, createdAt, updatedAt,
|
|
50
|
+
})),
|
|
51
|
+
tasks: data.tasks.map((task) => ({
|
|
52
|
+
localId: task.id, number: task.number, projectLocalId: task.projectId, title: task.title, description: task.description,
|
|
53
|
+
type: task.type, priority: task.priority, status: task.status, assigneeLocalId: task.assigneeId, creatorLocalId: task.creatorId,
|
|
54
|
+
requester: task.requester, branch: task.branch, baseBranch: task.baseBranch, branchStart: task.branchStart,
|
|
55
|
+
createdAt: task.createdAt, updatedAt: task.updatedAt, startedAt: task.startedAt, completedAt: task.completedAt, archivedAt: task.archivedAt,
|
|
56
|
+
})),
|
|
57
|
+
comments: data.comments.map((comment) => ({
|
|
58
|
+
taskLocalId: comment.taskId, userLocalId: comment.userId, body: comment.body, createdAt: comment.createdAt, updatedAt: comment.updatedAt,
|
|
59
|
+
})),
|
|
60
|
+
activity: data.activity.map((item) => ({
|
|
61
|
+
taskLocalId: item.taskId, userLocalId: item.userId, type: item.type, metadata: item.metadata, createdAt: item.createdAt,
|
|
62
|
+
})),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (error instanceof OfflineError) {
|
|
67
|
+
throw new SojaError('Importing needs a connection to the SOJA server.', { hint: 'Nothing was sent; try again when you are online.', cause: error });
|
|
68
|
+
}
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
const id = importId(data.workspace.id, session.workspace.id);
|
|
72
|
+
const current = this.config.load();
|
|
73
|
+
if (current?.remote) {
|
|
74
|
+
const imports = [...new Set([...(current.remote.imports ?? []), id])];
|
|
75
|
+
this.config.save({ ...current, remote: { ...current.remote, imports } });
|
|
76
|
+
}
|
|
77
|
+
await this.engine.sync(session.workspace.id);
|
|
78
|
+
// Folder links are per machine: carry this machine's over to the imported projects.
|
|
79
|
+
let linkedFolders = 0;
|
|
80
|
+
for (const project of data.projects) {
|
|
81
|
+
const serverId = result.projectIds[project.id];
|
|
82
|
+
if (!serverId || !project.repositoryPath)
|
|
83
|
+
continue;
|
|
84
|
+
await this.store.setRepositoryPath(serverId, project.repositoryPath);
|
|
85
|
+
linkedFolders += 1;
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
projects: result.projects,
|
|
89
|
+
tasks: result.tasks,
|
|
90
|
+
comments: result.comments,
|
|
91
|
+
activity: result.activity,
|
|
92
|
+
renumbered: result.renumbered,
|
|
93
|
+
unmatchedUsers: result.unmatchedUsers,
|
|
94
|
+
linkedFolders,
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
async collect(repos, from) {
|
|
99
|
+
const workspace = await this.localWorkspace(repos, from);
|
|
100
|
+
const [members, projects, tasks] = await Promise.all([
|
|
101
|
+
repos.workspaces.listMembers(workspace.id),
|
|
102
|
+
repos.projects.listByWorkspace(workspace.id),
|
|
103
|
+
repos.tasks.list({ workspaceId: workspace.id, archived: 'include' }),
|
|
104
|
+
]);
|
|
105
|
+
const comments = (await Promise.all(tasks.map((task) => repos.comments.listByTask(task.id)))).flat();
|
|
106
|
+
const activity = (await Promise.all(tasks.map((task) => repos.activity.listByTask(task.id)))).flat();
|
|
107
|
+
// Creators or commenters who left the workspace still need a username to be matched.
|
|
108
|
+
const known = new Set(members.map((member) => member.id));
|
|
109
|
+
const others = new Set();
|
|
110
|
+
for (const task of tasks)
|
|
111
|
+
for (const id of [task.creatorId, task.assigneeId])
|
|
112
|
+
if (id && !known.has(id))
|
|
113
|
+
others.add(id);
|
|
114
|
+
for (const comment of comments)
|
|
115
|
+
if (!known.has(comment.userId))
|
|
116
|
+
others.add(comment.userId);
|
|
117
|
+
const users = [...members, ...(await repos.users.findByIds([...others]))];
|
|
118
|
+
return { workspace, users, projects, tasks: tasks.filter((task) => task.number > 0), comments, activity };
|
|
119
|
+
}
|
|
120
|
+
async localWorkspace(repos, from) {
|
|
121
|
+
const config = this.config.load();
|
|
122
|
+
const workspace = from
|
|
123
|
+
? ((await repos.workspaces.findBySlug(from)) ?? (await repos.workspaces.findById(from)))
|
|
124
|
+
: config?.workspaceId
|
|
125
|
+
? await repos.workspaces.findById(config.workspaceId)
|
|
126
|
+
: null;
|
|
127
|
+
if (!workspace) {
|
|
128
|
+
throw new NotFoundError(from ? `There is no local workspace “${from}”.` : 'No local workspace to import.', {
|
|
129
|
+
hint: 'Name it with --from <slug>; `soja mode local` then `soja workspace list` shows them.',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return workspace;
|
|
133
|
+
}
|
|
134
|
+
async alreadyImported(session, workspace) {
|
|
135
|
+
// The server would answer a repeat with the first result anyway; this is only to warn before.
|
|
136
|
+
return (this.config.load()?.remote?.imports ?? []).includes(importId(workspace.id, session.workspace.id));
|
|
137
|
+
}
|
|
138
|
+
async withLocal(work) {
|
|
139
|
+
if (!existsSync(this.localFile)) {
|
|
140
|
+
throw new NotFoundError('There is no local SOJA data on this machine.', { hint: 'Nothing to import: local mode was never used here.' });
|
|
141
|
+
}
|
|
142
|
+
const handle = openDatabase(this.localFile);
|
|
143
|
+
try {
|
|
144
|
+
await runMigrations(handle);
|
|
145
|
+
return await work(createLocalRepositories(handle));
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
handle.close();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** The same local workspace imported into the same server workspace always has the same id, so it never duplicates. */
|
|
153
|
+
function importId(localWorkspaceId, serverWorkspaceId) {
|
|
154
|
+
const hex = createHash('sha256').update(`soja-import:${localWorkspaceId}:${serverWorkspaceId}`).digest('hex');
|
|
155
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
156
|
+
}
|