@taskforcehq/taskforce 0.3.301 → 0.3.303
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Taskforce.module.css +37 -2
- package/dist/TaskforceCore.filter-persistence.test.js +1 -1
- package/dist/TaskforceCore.js +72 -33
- package/dist/TaskforceCore.test.js +848 -130
- package/dist/components/features/TaskSettings.js +14 -82
- package/dist/components/features/TaskSettings.test.js +33 -70
- package/dist/components/features/settings/TaxonomyLibraryManager.js +8 -2
- package/dist/components/features/settings/TaxonomyLibraryManager.test.js +1 -1
- package/dist/components/task/TaskActionHeader.js +6 -3
- package/dist/components/task/TaskCard.js +6 -3
- package/dist/components/task/TaskForm.js +2 -2
- package/dist/components/task/TaskForm.test.js +41 -2
- package/dist/components/task/TaskKanban.test.js +17 -0
- package/dist/components/ui/ReferenceBadgeButton.js +1 -1
- package/dist/components/views/PlansPage.d.ts +3 -1
- package/dist/components/views/PlansPage.js +109 -49
- package/dist/components/views/PlansPage.test.d.ts +1 -0
- package/dist/components/views/PlansPage.test.js +100 -0
- package/dist/components/views/StandaloneLayout.d.ts +1 -0
- package/dist/components/views/StandaloneLayout.js +473 -256
- package/dist/components/views/panels/PlanningDrawer.js +18 -1
- package/dist/components/views/standalone/modals/AccountHubModal.d.ts +48 -0
- package/dist/components/views/standalone/modals/AccountHubModal.js +36 -0
- package/dist/components/views/standalone/modals/AccountHubModal.test.d.ts +1 -0
- package/dist/components/views/standalone/modals/AccountHubModal.test.js +26 -0
- package/dist/components/views/standalone/modals/CreateWorkspaceConfirmModal.d.ts +9 -0
- package/dist/components/views/standalone/modals/CreateWorkspaceConfirmModal.js +8 -0
- package/dist/components/views/standalone/modals/EditProfileModal.d.ts +26 -0
- package/dist/components/views/standalone/modals/EditProfileModal.js +10 -0
- package/dist/components/views/standalone/modals/HelpModal.d.ts +9 -0
- package/dist/components/views/standalone/modals/HelpModal.js +8 -0
- package/dist/components/views/standalone/modals/ScheduleWarningModal.d.ts +13 -0
- package/dist/components/views/standalone/modals/ScheduleWarningModal.js +6 -0
- package/dist/components/views/standalone/modals/SyncStatusModal.d.ts +61 -0
- package/dist/components/views/standalone/modals/SyncStatusModal.js +38 -0
- package/dist/components/views/standalone/modals/TeamManagementModal.d.ts +64 -0
- package/dist/components/views/standalone/modals/TeamManagementModal.js +10 -0
- package/dist/components/views/standalone/modals/types.d.ts +2 -0
- package/dist/components/views/standalone/modals/types.js +1 -0
- package/dist/components/views/teamManagementAccess.d.ts +1 -1
- package/dist/components/views/teamManagementAccess.js +2 -2
- package/dist/components/views/teamManagementAccess.test.js +3 -3
- package/dist/config/deployment.js +2 -2
- package/dist/config/deployment.test.js +5 -3
- package/dist/core/AiProfileService.js +7 -2
- package/dist/core/AiProfiles.test.js +19 -0
- package/dist/core/EntitlementsPolicy.test.js +2 -2
- package/dist/core/GlobalSettingsService.js +78 -18
- package/dist/core/PlanEntitlementService.d.ts +29 -5
- package/dist/core/PlanEntitlementService.js +297 -219
- package/dist/core/SignupMonetizationSettings.test.js +173 -49
- package/dist/core/SyncReconciliationService.d.ts +2 -0
- package/dist/core/SyncReconciliationService.js +3 -0
- package/dist/core/SystemAdmin.test.js +186 -96
- package/dist/core/TaskAuditTimeline.test.js +40 -0
- package/dist/core/Taskforce.d.ts +100 -10
- package/dist/core/Taskforce.ids.test.js +133 -0
- package/dist/core/Taskforce.js +459 -127
- package/dist/core/Taskforce.mcpAuth.test.js +0 -2
- package/dist/core/UserProfileAvatarDrafts.test.d.ts +1 -0
- package/dist/core/UserProfileAvatarDrafts.test.js +111 -0
- package/dist/core/taskReferencePolicy.d.ts +28 -0
- package/dist/core/taskReferencePolicy.js +189 -0
- package/dist/core/types.d.ts +13 -12
- package/dist/hooks/tasks/useTaskFormActions.js +3 -0
- package/dist/hooks/useSyncOrchestrator.js +17 -3
- package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +97 -5
- package/dist/hooks/useTaskData.js +3 -2
- package/dist/hooks/useTaskData.test.js +66 -0
- package/dist/hooks/useTaskforce.d.ts +8 -0
- package/dist/hooks/useTaskforce.js +72 -19
- package/dist/hooks/useTaskforce.sync-behavior.test.js +185 -6
- package/dist/mcp/aiProfileBootstrap.d.ts +29 -0
- package/dist/mcp/aiProfileBootstrap.js +80 -0
- package/dist/mcp/aiProfileBootstrap.test.d.ts +1 -0
- package/dist/mcp/aiProfileBootstrap.test.js +68 -0
- package/dist/mcp/clientIntegrations.d.ts +2 -8
- package/dist/mcp/clientIntegrations.js +21 -40
- package/dist/mcp/clientIntegrations.test.js +28 -2
- package/dist/mcp/clientProfileDefaults.d.ts +12 -0
- package/dist/mcp/clientProfileDefaults.js +37 -0
- package/dist/mcp/runtime.d.ts +10 -7
- package/dist/mcp/runtime.js +131 -48
- package/dist/mcp/runtime.test.js +321 -83
- package/dist/migrations/billingSchemaParity.test.js +3 -0
- package/dist/migrations/taskSchemaMigrations.js +68 -1
- package/dist/qaOpen.test.d.ts +1 -0
- package/dist/qaOpen.test.js +56 -0
- package/dist/server/index.cookieProxy.test.js +2 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +17 -1
- package/dist/server/index.test.js +15 -1
- package/dist/server/mockStripe.d.ts +1 -0
- package/dist/server/mockStripe.js +2 -0
- package/dist/server/routes/admin.js +105 -45
- package/dist/server/routes/auth.d.ts +6 -0
- package/dist/server/routes/auth.js +505 -30
- package/dist/server/routes/billing.js +517 -24
- package/dist/server/routes/billing.test.js +967 -45
- package/dist/server/routes/documents.js +6 -1
- package/dist/server/routes/shared.d.ts +1 -1
- package/dist/server/routes/shared.js +16 -6
- package/dist/server/routes/sync.integration.test.js +290 -0
- package/dist/server/routes/sync.js +234 -1922
- package/dist/server/routes/tasks.js +18 -4
- package/dist/server/routes.js +29 -22
- package/dist/server/routes.test.js +639 -82
- package/dist/shared/runtimeContract.d.ts +1 -1
- package/dist/sync/cloudSyncApi.d.ts +1 -0
- package/dist/sync/collaborationSyncPayload.d.ts +17 -0
- package/dist/sync/collaborationSyncPayload.js +131 -0
- package/dist/sync/collaborationSyncPayload.test.d.ts +1 -0
- package/dist/sync/collaborationSyncPayload.test.js +104 -0
- package/dist/sync/collaborationSyncState.d.ts +15 -0
- package/dist/sync/collaborationSyncState.js +60 -0
- package/dist/sync/contentSyncPayload.d.ts +45 -0
- package/dist/sync/contentSyncPayload.js +104 -0
- package/dist/sync/contentSyncPayload.test.d.ts +1 -0
- package/dist/sync/contentSyncPayload.test.js +104 -0
- package/dist/sync/contentSyncState.d.ts +98 -0
- package/dist/sync/contentSyncState.js +1004 -0
- package/dist/sync/planningSyncPayload.d.ts +9 -0
- package/dist/sync/planningSyncPayload.js +82 -0
- package/dist/sync/planningSyncPayload.test.d.ts +1 -0
- package/dist/sync/planningSyncPayload.test.js +68 -0
- package/dist/sync/syncApplyHandlers.d.ts +83 -0
- package/dist/sync/syncApplyHandlers.js +445 -0
- package/dist/sync/syncService.d.ts +1 -0
- package/dist/sync/syncService.js +21 -8
- package/dist/sync/taskSyncPayload.d.ts +33 -0
- package/dist/sync/taskSyncPayload.js +126 -0
- package/dist/sync/taskSyncPayload.test.d.ts +1 -0
- package/dist/sync/taskSyncPayload.test.js +54 -0
- package/dist/sync/workspacePullFeed.d.ts +38 -0
- package/dist/sync/workspacePullFeed.js +349 -0
- package/dist/sync/workspacePullFeed.test.d.ts +1 -0
- package/dist/sync/workspacePullFeed.test.js +278 -0
- package/dist/sync/workspaceSyncModel.d.ts +4 -1
- package/dist/sync/workspaceSyncModel.js +9 -2
- package/dist/sync/workspaceSyncModel.test.js +29 -0
- package/dist/test/setup.js +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/ui/assets/{AgentsModule-Bdq3T1Ts.js → AgentsModule-CpsTmrIW.js} +1 -1
- package/dist/ui/assets/{AnnotatedAttachmentWorkspace-jKUVo-PJ.js → AnnotatedAttachmentWorkspace-C_TmXZwA.js} +1 -1
- package/dist/ui/assets/{DocumentWorkspace-DuQa-uIC.js → DocumentWorkspace-C3GyzrWm.js} +2 -2
- package/dist/ui/assets/{InitiativesModule-C8Pi2VTk.js → InitiativesModule-4-Rh2K2n.js} +1 -1
- package/dist/ui/assets/PlansPage-BP7AYOqr.js +1 -0
- package/dist/ui/assets/TaskSettings-BOC5F6Ag.js +8 -0
- package/dist/ui/assets/{TaskSettings-BwB9NtmJ.css → TaskSettings-CnIBL_Eb.css} +1 -1
- package/dist/ui/assets/{WorkflowsModule-b6TLuHwJ.js → WorkflowsModule-CGk9s3NJ.js} +1 -1
- package/dist/ui/assets/index-CLIMgR6g.js +6 -0
- package/dist/ui/assets/index-DneETxcu.css +1 -0
- package/dist/ui/assets/{vendor-icons-QZyhEwgT.js → vendor-icons-pWocEipT.js} +1 -1
- package/dist/ui/index.html +3 -3
- package/dist/utils/accountProfileSummaryCache.d.ts +31 -0
- package/dist/utils/accountProfileSummaryCache.js +97 -0
- package/dist/utils/accountProfileSummaryCache.test.d.ts +1 -0
- package/dist/utils/accountProfileSummaryCache.test.js +63 -0
- package/dist/utils/taskActivity.js +17 -4
- package/dist/utils/taskActivity.test.js +27 -0
- package/dist/utils/taskNormalization.js +10 -2
- package/dist/utils/taskReferences.d.ts +11 -1
- package/dist/utils/taskReferences.js +33 -1
- package/package.json +4 -3
- package/scripts/qa-open-lib.mjs +175 -0
- package/scripts/qa-open-lib.mjs.d.ts +49 -0
- package/scripts/qa-open.mjs +352 -0
- package/dist/ui/assets/PlansPage-B6BDECSi.js +0 -1
- package/dist/ui/assets/TaskSettings-DUJgRTPU.js +0 -8
- package/dist/ui/assets/index-BiiF1cLQ.css +0 -1
- package/dist/ui/assets/index-xBWLPOEb.js +0 -6
|
@@ -27,7 +27,6 @@ function setupWorkspace(core, userId = 'owner-1', workspaceId = 'workspace-1') {
|
|
|
27
27
|
hasEntitlement: true,
|
|
28
28
|
canAccessApp: true,
|
|
29
29
|
canAccessPlans: true,
|
|
30
|
-
signupMonetizationStrategy: 'none',
|
|
31
30
|
planSelectionRequired: false,
|
|
32
31
|
message: null,
|
|
33
32
|
entitlement: null
|
|
@@ -328,7 +327,6 @@ describe('TaskforceCore MCP auth invalidation', () => {
|
|
|
328
327
|
hasEntitlement: false,
|
|
329
328
|
canAccessApp: false,
|
|
330
329
|
canAccessPlans: true,
|
|
331
|
-
signupMonetizationStrategy: 'none',
|
|
332
330
|
planSelectionRequired: false,
|
|
333
331
|
message: 'Suspended for test',
|
|
334
332
|
entitlement: null
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { TaskforceCore } from './Taskforce';
|
|
6
|
+
function makeProjectRoot() {
|
|
7
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-user-avatar-'));
|
|
8
|
+
}
|
|
9
|
+
function seedPlans(core) {
|
|
10
|
+
core.upsertPlanCatalog({ planId: 'starter', displayName: 'Starter', status: 'active' });
|
|
11
|
+
core.updatePlanVersion({
|
|
12
|
+
planId: 'starter',
|
|
13
|
+
versionNumber: 1,
|
|
14
|
+
seatLimit: 1
|
|
15
|
+
});
|
|
16
|
+
const db = core.db;
|
|
17
|
+
const now = new Date().toISOString();
|
|
18
|
+
db.prepare(`
|
|
19
|
+
INSERT OR IGNORE INTO billing_price_mappings (
|
|
20
|
+
tenant_id, plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at
|
|
21
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
22
|
+
`).run(core.getTenantId(), 'starter-v1', 'month', 'free:starter-v1:month', 'free', 1, 0, null, now, now);
|
|
23
|
+
core.updateGlobalSettings({
|
|
24
|
+
billing: {
|
|
25
|
+
onboardingPolicy: {
|
|
26
|
+
mode: 'auto_grant_free',
|
|
27
|
+
planVersionId: 'starter-v1'
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function openCore(projectRoot) {
|
|
33
|
+
const core = new TaskforceCore({
|
|
34
|
+
projectRoot,
|
|
35
|
+
storagePath: path.join(projectRoot, '.taskforce')
|
|
36
|
+
});
|
|
37
|
+
seedPlans(core);
|
|
38
|
+
return core;
|
|
39
|
+
}
|
|
40
|
+
describe('TaskforceCore user profile avatar drafts', () => {
|
|
41
|
+
it('persists avatarUrl on profile updates and exposes it in resolved identity', () => {
|
|
42
|
+
const projectRoot = makeProjectRoot();
|
|
43
|
+
const core = openCore(projectRoot);
|
|
44
|
+
try {
|
|
45
|
+
const created = core.registerPasswordUser({
|
|
46
|
+
email: 'avatar-user@example.com',
|
|
47
|
+
password: 'password123'
|
|
48
|
+
});
|
|
49
|
+
const updated = core.updateUserProfile(created.userId, {
|
|
50
|
+
displayName: 'Avatar User',
|
|
51
|
+
avatarUrl: `/api/taskforce/context/${encodeURIComponent(`users/${created.userId}/profile/avatar/headshot.png`)}`
|
|
52
|
+
});
|
|
53
|
+
expect(updated).toMatchObject({
|
|
54
|
+
userId: created.userId,
|
|
55
|
+
displayName: 'Avatar User',
|
|
56
|
+
avatarUrl: `/api/taskforce/context/${encodeURIComponent(`users/${created.userId}/profile/avatar/headshot.png`)}`
|
|
57
|
+
});
|
|
58
|
+
expect(core.resolveUserIdentity(created.userId)?.avatarUrl).toBe(`/api/taskforce/context/${encodeURIComponent(`users/${created.userId}/profile/avatar/headshot.png`)}`);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
core.close();
|
|
62
|
+
fs.rmSync(projectRoot, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
it('tracks, finalizes, discards, and cleans up stale avatar upload drafts', () => {
|
|
66
|
+
const projectRoot = makeProjectRoot();
|
|
67
|
+
const core = openCore(projectRoot);
|
|
68
|
+
try {
|
|
69
|
+
const created = core.registerPasswordUser({
|
|
70
|
+
email: 'draft-user@example.com',
|
|
71
|
+
password: 'password123'
|
|
72
|
+
});
|
|
73
|
+
const draft = core.createUserAvatarUploadDraft({
|
|
74
|
+
userId: created.userId,
|
|
75
|
+
draftId: 'avatar-draft-1',
|
|
76
|
+
storageKey: `users/${created.userId}/profile/avatar/avatar-draft-1-headshot.png`,
|
|
77
|
+
mimeType: 'image/png',
|
|
78
|
+
originalFilename: 'headshot.png',
|
|
79
|
+
sizeBytes: 1024,
|
|
80
|
+
storageProvider: 'r2'
|
|
81
|
+
});
|
|
82
|
+
expect(draft.finalizedAt).toBeNull();
|
|
83
|
+
expect(core.markUserAvatarUploadDraftFinalized(created.userId, draft.draftId)?.finalizedAt).toEqual(expect.any(String));
|
|
84
|
+
expect(core.markUserAvatarUploadDraftConsumed(created.userId, draft.draftId)?.consumedAt).toEqual(expect.any(String));
|
|
85
|
+
expect(core.discardUserAvatarUploadDraft(created.userId, draft.draftId)?.discardedAt).toEqual(expect.any(String));
|
|
86
|
+
core.createUserAvatarUploadDraft({
|
|
87
|
+
userId: created.userId,
|
|
88
|
+
draftId: 'avatar-draft-stale',
|
|
89
|
+
storageKey: `users/${created.userId}/profile/avatar/avatar-draft-stale-headshot.png`,
|
|
90
|
+
mimeType: 'image/png',
|
|
91
|
+
originalFilename: 'stale.png',
|
|
92
|
+
sizeBytes: 512,
|
|
93
|
+
storageProvider: 'r2'
|
|
94
|
+
});
|
|
95
|
+
const db = core.db;
|
|
96
|
+
db.prepare(`
|
|
97
|
+
UPDATE user_avatar_upload_drafts
|
|
98
|
+
SET created_at = ?, updated_at = ?
|
|
99
|
+
WHERE tenant_id = ? AND user_id = ? AND draft_id = ?
|
|
100
|
+
`).run('2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', core.getTenantId(), created.userId, 'avatar-draft-stale');
|
|
101
|
+
const stale = core.cleanupStaleUserAvatarUploadDrafts(created.userId, '2026-02-01T00:00:00.000Z');
|
|
102
|
+
expect(stale).toHaveLength(1);
|
|
103
|
+
expect(stale[0]?.draftId).toBe('avatar-draft-stale');
|
|
104
|
+
expect(core.getUserAvatarUploadDraft(created.userId, 'avatar-draft-stale')?.discardedAt).toEqual(expect.any(String));
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
core.close();
|
|
108
|
+
fs.rmSync(projectRoot, { recursive: true, force: true });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { DatabaseAdapter } from '../storage/databaseAdapter.js';
|
|
2
|
+
import type { TaskItem } from './Taskforce.js';
|
|
3
|
+
interface TaskReferencePolicyDeps<TTask> {
|
|
4
|
+
db: DatabaseAdapter;
|
|
5
|
+
tenantId: string;
|
|
6
|
+
normalizeWorkspaceId: (workspaceId?: string) => string;
|
|
7
|
+
sanitizeTaskReferenceNumber: (value: unknown) => number | null;
|
|
8
|
+
rowToTask: (row: any) => TTask;
|
|
9
|
+
getTask: (id: string, workspaceId?: string) => TTask | null;
|
|
10
|
+
}
|
|
11
|
+
export declare function getTaskReferenceSequenceValue<TTask>(deps: TaskReferencePolicyDeps<TTask>, workspaceId: string): number;
|
|
12
|
+
export declare function getLocalTaskReferenceSequenceValue<TTask>(deps: TaskReferencePolicyDeps<TTask>, workspaceId: string): number;
|
|
13
|
+
export declare function getMaxTaskReferenceNumber<TTask>(deps: TaskReferencePolicyDeps<TTask>, workspaceId: string, excludeTaskId?: string | null): number;
|
|
14
|
+
export declare function getMaxLocalTaskReferenceNumber<TTask>(deps: TaskReferencePolicyDeps<TTask>, workspaceId: string, excludeTaskId?: string | null): number;
|
|
15
|
+
export declare function persistTaskReferenceSequence<TTask>(deps: TaskReferencePolicyDeps<TTask>, workspaceId: string, referenceNumber: number): void;
|
|
16
|
+
export declare function persistLocalTaskReferenceSequence<TTask>(deps: TaskReferencePolicyDeps<TTask>, workspaceId: string, referenceNumber: number): void;
|
|
17
|
+
export declare function allocateNextTaskReferenceNumber<TTask>(deps: TaskReferencePolicyDeps<TTask>, workspaceId: string, excludeTaskId?: string | null): number;
|
|
18
|
+
export declare function allocateNextLocalTaskReferenceNumber<TTask>(deps: TaskReferencePolicyDeps<TTask>, workspaceId: string, excludeTaskId?: string | null): number;
|
|
19
|
+
export declare function relocateTaskReferenceConflict<TTask>(deps: TaskReferencePolicyDeps<TTask>, workspaceId: string, referenceNumber: number, claimantTaskId?: string | null, updatedAt?: string | null): {
|
|
20
|
+
taskId: string;
|
|
21
|
+
referenceNumber: number;
|
|
22
|
+
} | null;
|
|
23
|
+
export declare function reserveTaskReferenceNumber<TTask>(deps: TaskReferencePolicyDeps<TTask>, workspaceId: string, preferredReferenceNumber?: number | null, currentTaskId?: string | null): number;
|
|
24
|
+
export declare function getTaskByReferenceNumber<TTask>(deps: TaskReferencePolicyDeps<TTask>, referenceNumber: number, workspaceId?: string): TTask | null;
|
|
25
|
+
export declare function getTaskByLocalReferenceNumber<TTask>(deps: TaskReferencePolicyDeps<TTask>, referenceNumber: number, workspaceId?: string): TTask | null;
|
|
26
|
+
export declare function resolveTaskIdentifier(deps: TaskReferencePolicyDeps<TaskItem>, value: string, workspaceId?: string): TaskItem | null;
|
|
27
|
+
export declare function deriveTaskReferenceLabel(task: Pick<TaskItem, 'referenceNumber' | 'localReferenceNumber'>): string;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { formatLocalTaskReference, parseLocalTaskReference, parseTaskReference, } from '../utils/taskReferences.js';
|
|
2
|
+
export function getTaskReferenceSequenceValue(deps, workspaceId) {
|
|
3
|
+
const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
|
|
4
|
+
const row = deps.db.prepare(`
|
|
5
|
+
SELECT next_reference_number
|
|
6
|
+
FROM workspace_task_reference_sequences
|
|
7
|
+
WHERE tenant_id = ? AND workspace_id = ?
|
|
8
|
+
LIMIT 1
|
|
9
|
+
`).get(deps.tenantId, normalizedWorkspaceId);
|
|
10
|
+
return deps.sanitizeTaskReferenceNumber(row?.next_reference_number) || 0;
|
|
11
|
+
}
|
|
12
|
+
export function getLocalTaskReferenceSequenceValue(deps, workspaceId) {
|
|
13
|
+
const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
|
|
14
|
+
const row = deps.db.prepare(`
|
|
15
|
+
SELECT next_reference_number
|
|
16
|
+
FROM workspace_local_task_reference_sequences
|
|
17
|
+
WHERE tenant_id = ? AND workspace_id = ?
|
|
18
|
+
LIMIT 1
|
|
19
|
+
`).get(deps.tenantId, normalizedWorkspaceId);
|
|
20
|
+
return deps.sanitizeTaskReferenceNumber(row?.next_reference_number) || 0;
|
|
21
|
+
}
|
|
22
|
+
export function getMaxTaskReferenceNumber(deps, workspaceId, excludeTaskId) {
|
|
23
|
+
const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
|
|
24
|
+
const normalizedExcludeTaskId = String(excludeTaskId || '').trim();
|
|
25
|
+
const row = normalizedExcludeTaskId
|
|
26
|
+
? deps.db.prepare(`
|
|
27
|
+
SELECT MAX(reference_number) AS max_reference_number
|
|
28
|
+
FROM tasks
|
|
29
|
+
WHERE tenant_id = ? AND workspace_id = ? AND id != ?
|
|
30
|
+
`).get(deps.tenantId, normalizedWorkspaceId, normalizedExcludeTaskId)
|
|
31
|
+
: deps.db.prepare(`
|
|
32
|
+
SELECT MAX(reference_number) AS max_reference_number
|
|
33
|
+
FROM tasks
|
|
34
|
+
WHERE tenant_id = ? AND workspace_id = ?
|
|
35
|
+
`).get(deps.tenantId, normalizedWorkspaceId);
|
|
36
|
+
return deps.sanitizeTaskReferenceNumber(row?.max_reference_number) || 0;
|
|
37
|
+
}
|
|
38
|
+
export function getMaxLocalTaskReferenceNumber(deps, workspaceId, excludeTaskId) {
|
|
39
|
+
const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
|
|
40
|
+
const normalizedExcludeTaskId = String(excludeTaskId || '').trim();
|
|
41
|
+
const row = normalizedExcludeTaskId
|
|
42
|
+
? deps.db.prepare(`
|
|
43
|
+
SELECT MAX(local_reference_number) AS max_reference_number
|
|
44
|
+
FROM tasks
|
|
45
|
+
WHERE tenant_id = ? AND workspace_id = ? AND id != ?
|
|
46
|
+
`).get(deps.tenantId, normalizedWorkspaceId, normalizedExcludeTaskId)
|
|
47
|
+
: deps.db.prepare(`
|
|
48
|
+
SELECT MAX(local_reference_number) AS max_reference_number
|
|
49
|
+
FROM tasks
|
|
50
|
+
WHERE tenant_id = ? AND workspace_id = ?
|
|
51
|
+
`).get(deps.tenantId, normalizedWorkspaceId);
|
|
52
|
+
return deps.sanitizeTaskReferenceNumber(row?.max_reference_number) || 0;
|
|
53
|
+
}
|
|
54
|
+
export function persistTaskReferenceSequence(deps, workspaceId, referenceNumber) {
|
|
55
|
+
const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
|
|
56
|
+
const normalizedReferenceNumber = deps.sanitizeTaskReferenceNumber(referenceNumber);
|
|
57
|
+
if (!normalizedReferenceNumber)
|
|
58
|
+
return;
|
|
59
|
+
deps.db.prepare(`
|
|
60
|
+
INSERT INTO workspace_task_reference_sequences (tenant_id, workspace_id, next_reference_number)
|
|
61
|
+
VALUES (?, ?, ?)
|
|
62
|
+
ON CONFLICT (tenant_id, workspace_id) DO UPDATE SET
|
|
63
|
+
next_reference_number = CASE
|
|
64
|
+
WHEN workspace_task_reference_sequences.next_reference_number < EXCLUDED.next_reference_number
|
|
65
|
+
THEN EXCLUDED.next_reference_number
|
|
66
|
+
ELSE workspace_task_reference_sequences.next_reference_number
|
|
67
|
+
END
|
|
68
|
+
`).run(deps.tenantId, normalizedWorkspaceId, normalizedReferenceNumber);
|
|
69
|
+
}
|
|
70
|
+
export function persistLocalTaskReferenceSequence(deps, workspaceId, referenceNumber) {
|
|
71
|
+
const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
|
|
72
|
+
const normalizedReferenceNumber = deps.sanitizeTaskReferenceNumber(referenceNumber);
|
|
73
|
+
if (!normalizedReferenceNumber)
|
|
74
|
+
return;
|
|
75
|
+
deps.db.prepare(`
|
|
76
|
+
INSERT INTO workspace_local_task_reference_sequences (tenant_id, workspace_id, next_reference_number)
|
|
77
|
+
VALUES (?, ?, ?)
|
|
78
|
+
ON CONFLICT (tenant_id, workspace_id) DO UPDATE SET
|
|
79
|
+
next_reference_number = CASE
|
|
80
|
+
WHEN workspace_local_task_reference_sequences.next_reference_number < EXCLUDED.next_reference_number
|
|
81
|
+
THEN EXCLUDED.next_reference_number
|
|
82
|
+
ELSE workspace_local_task_reference_sequences.next_reference_number
|
|
83
|
+
END
|
|
84
|
+
`).run(deps.tenantId, normalizedWorkspaceId, normalizedReferenceNumber);
|
|
85
|
+
}
|
|
86
|
+
export function allocateNextTaskReferenceNumber(deps, workspaceId, excludeTaskId) {
|
|
87
|
+
const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
|
|
88
|
+
const nextReferenceNumber = Math.max(getTaskReferenceSequenceValue(deps, normalizedWorkspaceId), getMaxTaskReferenceNumber(deps, normalizedWorkspaceId, excludeTaskId)) + 1;
|
|
89
|
+
persistTaskReferenceSequence(deps, normalizedWorkspaceId, nextReferenceNumber);
|
|
90
|
+
return nextReferenceNumber;
|
|
91
|
+
}
|
|
92
|
+
export function allocateNextLocalTaskReferenceNumber(deps, workspaceId, excludeTaskId) {
|
|
93
|
+
const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
|
|
94
|
+
const nextReferenceNumber = Math.max(getLocalTaskReferenceSequenceValue(deps, normalizedWorkspaceId), getMaxLocalTaskReferenceNumber(deps, normalizedWorkspaceId, excludeTaskId)) + 1;
|
|
95
|
+
persistLocalTaskReferenceSequence(deps, normalizedWorkspaceId, nextReferenceNumber);
|
|
96
|
+
return nextReferenceNumber;
|
|
97
|
+
}
|
|
98
|
+
export function relocateTaskReferenceConflict(deps, workspaceId, referenceNumber, claimantTaskId, updatedAt) {
|
|
99
|
+
const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
|
|
100
|
+
const normalizedReferenceNumber = deps.sanitizeTaskReferenceNumber(referenceNumber);
|
|
101
|
+
if (!normalizedReferenceNumber)
|
|
102
|
+
return null;
|
|
103
|
+
const normalizedClaimantTaskId = String(claimantTaskId || '').trim();
|
|
104
|
+
const incumbent = deps.db.prepare(`
|
|
105
|
+
SELECT id
|
|
106
|
+
FROM tasks
|
|
107
|
+
WHERE tenant_id = ? AND workspace_id = ? AND reference_number = ? AND id != ?
|
|
108
|
+
LIMIT 1
|
|
109
|
+
`).get(deps.tenantId, normalizedWorkspaceId, normalizedReferenceNumber, normalizedClaimantTaskId);
|
|
110
|
+
const incumbentTaskId = String(incumbent?.id || '').trim();
|
|
111
|
+
if (!incumbentTaskId)
|
|
112
|
+
return null;
|
|
113
|
+
const nextReferenceNumber = allocateNextTaskReferenceNumber(deps, normalizedWorkspaceId, incumbentTaskId);
|
|
114
|
+
deps.db.prepare(`
|
|
115
|
+
UPDATE tasks
|
|
116
|
+
SET reference_number = ?, updated_at = ?
|
|
117
|
+
WHERE tenant_id = ? AND workspace_id = ? AND id = ?
|
|
118
|
+
`).run(nextReferenceNumber, String(updatedAt || new Date().toISOString()), deps.tenantId, normalizedWorkspaceId, incumbentTaskId);
|
|
119
|
+
return { taskId: incumbentTaskId, referenceNumber: nextReferenceNumber };
|
|
120
|
+
}
|
|
121
|
+
export function reserveTaskReferenceNumber(deps, workspaceId, preferredReferenceNumber, currentTaskId) {
|
|
122
|
+
const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
|
|
123
|
+
const preferred = deps.sanitizeTaskReferenceNumber(preferredReferenceNumber);
|
|
124
|
+
const normalizedCurrentTaskId = String(currentTaskId || '').trim();
|
|
125
|
+
if (preferred) {
|
|
126
|
+
const existing = deps.db.prepare(`
|
|
127
|
+
SELECT id
|
|
128
|
+
FROM tasks
|
|
129
|
+
WHERE tenant_id = ? AND workspace_id = ? AND reference_number = ?
|
|
130
|
+
LIMIT 1
|
|
131
|
+
`).get(deps.tenantId, normalizedWorkspaceId, preferred);
|
|
132
|
+
if (!existing?.id || existing.id === normalizedCurrentTaskId) {
|
|
133
|
+
persistTaskReferenceSequence(deps, normalizedWorkspaceId, preferred);
|
|
134
|
+
return preferred;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return allocateNextTaskReferenceNumber(deps, normalizedWorkspaceId, normalizedCurrentTaskId || undefined);
|
|
138
|
+
}
|
|
139
|
+
export function getTaskByReferenceNumber(deps, referenceNumber, workspaceId) {
|
|
140
|
+
const normalizedReferenceNumber = deps.sanitizeTaskReferenceNumber(referenceNumber);
|
|
141
|
+
if (!normalizedReferenceNumber)
|
|
142
|
+
return null;
|
|
143
|
+
const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
|
|
144
|
+
const row = deps.db.prepare(`
|
|
145
|
+
SELECT * FROM tasks
|
|
146
|
+
WHERE tenant_id = ? AND workspace_id = ? AND reference_number = ?
|
|
147
|
+
LIMIT 1
|
|
148
|
+
`).get(deps.tenantId, normalizedWorkspaceId, normalizedReferenceNumber);
|
|
149
|
+
if (!row)
|
|
150
|
+
return null;
|
|
151
|
+
return deps.rowToTask(row);
|
|
152
|
+
}
|
|
153
|
+
export function getTaskByLocalReferenceNumber(deps, referenceNumber, workspaceId) {
|
|
154
|
+
const normalizedReferenceNumber = deps.sanitizeTaskReferenceNumber(referenceNumber);
|
|
155
|
+
if (!normalizedReferenceNumber)
|
|
156
|
+
return null;
|
|
157
|
+
const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
|
|
158
|
+
const row = deps.db.prepare(`
|
|
159
|
+
SELECT * FROM tasks
|
|
160
|
+
WHERE tenant_id = ? AND workspace_id = ? AND local_reference_number = ?
|
|
161
|
+
LIMIT 1
|
|
162
|
+
`).get(deps.tenantId, normalizedWorkspaceId, normalizedReferenceNumber);
|
|
163
|
+
if (!row)
|
|
164
|
+
return null;
|
|
165
|
+
return deps.rowToTask(row);
|
|
166
|
+
}
|
|
167
|
+
export function resolveTaskIdentifier(deps, value, workspaceId) {
|
|
168
|
+
const normalizedValue = String(value || '').trim();
|
|
169
|
+
if (!normalizedValue)
|
|
170
|
+
return null;
|
|
171
|
+
const referenceNumber = parseTaskReference(normalizedValue);
|
|
172
|
+
if (referenceNumber) {
|
|
173
|
+
const exactReferenceMatch = getTaskByReferenceNumber(deps, referenceNumber, workspaceId);
|
|
174
|
+
if (exactReferenceMatch)
|
|
175
|
+
return exactReferenceMatch;
|
|
176
|
+
}
|
|
177
|
+
const localReferenceNumber = parseLocalTaskReference(normalizedValue);
|
|
178
|
+
if (localReferenceNumber) {
|
|
179
|
+
const exactLocalReferenceMatch = getTaskByLocalReferenceNumber(deps, localReferenceNumber, workspaceId);
|
|
180
|
+
if (exactLocalReferenceMatch)
|
|
181
|
+
return exactLocalReferenceMatch;
|
|
182
|
+
}
|
|
183
|
+
return deps.getTask(normalizedValue, workspaceId);
|
|
184
|
+
}
|
|
185
|
+
export function deriveTaskReferenceLabel(task) {
|
|
186
|
+
return task.referenceNumber
|
|
187
|
+
? `T-${task.referenceNumber}`
|
|
188
|
+
: formatLocalTaskReference(task.localReferenceNumber);
|
|
189
|
+
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ export type TaskAssignee = string;
|
|
|
27
27
|
export interface TaskItem {
|
|
28
28
|
id: string;
|
|
29
29
|
referenceNumber?: number | null;
|
|
30
|
+
localReferenceNumber?: number | null;
|
|
30
31
|
referenceLabel?: string;
|
|
31
32
|
title: string;
|
|
32
33
|
description: string | null;
|
|
@@ -267,8 +268,9 @@ export interface GlobalTaskforceSettings {
|
|
|
267
268
|
authRateLimitEnabled?: boolean;
|
|
268
269
|
authRateLimitMaxRequests?: number;
|
|
269
270
|
authRateLimitWindowMs?: number;
|
|
270
|
-
|
|
271
|
-
|
|
271
|
+
};
|
|
272
|
+
billing?: {
|
|
273
|
+
onboardingPolicy?: OnboardingPolicy;
|
|
272
274
|
};
|
|
273
275
|
site?: {
|
|
274
276
|
showRunTaskforceLocally?: boolean;
|
|
@@ -298,14 +300,14 @@ export interface UserGlobalSyncSettings {
|
|
|
298
300
|
weekStartsOn?: 'sunday' | 'monday';
|
|
299
301
|
};
|
|
300
302
|
}
|
|
301
|
-
export type
|
|
302
|
-
export interface
|
|
303
|
-
|
|
304
|
-
|
|
303
|
+
export type OnboardingPolicyMode = 'require_plan_selection' | 'auto_grant_free' | 'auto_start_trial';
|
|
304
|
+
export interface OnboardingPolicy {
|
|
305
|
+
mode: OnboardingPolicyMode;
|
|
306
|
+
planVersionId: string | null;
|
|
305
307
|
}
|
|
306
|
-
export interface
|
|
308
|
+
export interface OnboardingPolicyValidationResult {
|
|
307
309
|
valid: boolean;
|
|
308
|
-
code?: '
|
|
310
|
+
code?: 'ONBOARDING_PLAN_VERSION_REQUIRED' | 'ONBOARDING_PLAN_VERSION_NOT_FOUND' | 'ONBOARDING_PLAN_NOT_ENABLED' | 'ONBOARDING_PLAN_MUST_BE_FREE' | 'ONBOARDING_PLAN_TRIAL_REQUIRED';
|
|
309
311
|
message?: string;
|
|
310
312
|
details?: Record<string, unknown>;
|
|
311
313
|
}
|
|
@@ -330,6 +332,7 @@ export interface UserIdentityRecord {
|
|
|
330
332
|
userId: string;
|
|
331
333
|
email: string;
|
|
332
334
|
displayName?: string | null;
|
|
335
|
+
avatarUrl?: string | null;
|
|
333
336
|
systemRole?: SystemRole;
|
|
334
337
|
betaAccess?: boolean;
|
|
335
338
|
}
|
|
@@ -439,7 +442,7 @@ export interface McpAuditLogRecord {
|
|
|
439
442
|
reasonCode: string | null;
|
|
440
443
|
details: Record<string, unknown>;
|
|
441
444
|
}
|
|
442
|
-
export type EntitlementState = 'active' | 'trialing' | 'grace' | 'suspended' | 'canceled';
|
|
445
|
+
export type EntitlementState = 'pending_plan_selection' | 'checkout_pending' | 'active' | 'trialing' | 'grace' | 'suspended' | 'canceled';
|
|
443
446
|
export type PlanFeatureAccess = 'enabled' | 'disabled' | 'limited';
|
|
444
447
|
export interface PlanFeatureSnapshot {
|
|
445
448
|
featureKey: string;
|
|
@@ -497,17 +500,15 @@ export interface AccountAccessStatus_Ok {
|
|
|
497
500
|
hasEntitlement: true;
|
|
498
501
|
canAccessApp: true;
|
|
499
502
|
canAccessPlans: true;
|
|
500
|
-
signupMonetizationStrategy: SignupMonetizationStrategy;
|
|
501
503
|
planSelectionRequired: false;
|
|
502
504
|
message: null;
|
|
503
505
|
entitlement: AccountEntitlementSnapshot;
|
|
504
506
|
}
|
|
505
507
|
export interface AccountAccessStatus_Blocked {
|
|
506
|
-
gate: 'plan_selection_required' | 'misconfigured' | 'missing_entitlement';
|
|
508
|
+
gate: 'plan_selection_required' | 'checkout_pending' | 'misconfigured' | 'missing_entitlement';
|
|
507
509
|
hasEntitlement: boolean;
|
|
508
510
|
canAccessApp: false;
|
|
509
511
|
canAccessPlans: true;
|
|
510
|
-
signupMonetizationStrategy: SignupMonetizationStrategy;
|
|
511
512
|
planSelectionRequired: boolean;
|
|
512
513
|
message: string;
|
|
513
514
|
entitlement: AccountEntitlementSnapshot | null;
|
|
@@ -130,6 +130,9 @@ export function useTaskFormActions(input) {
|
|
|
130
130
|
if (!editingTaskId || attachmentsDirty) {
|
|
131
131
|
body.attachments = attachments;
|
|
132
132
|
}
|
|
133
|
+
if (editingTaskId) {
|
|
134
|
+
body.saveSource = source;
|
|
135
|
+
}
|
|
133
136
|
const res = await fetch(url, {
|
|
134
137
|
method,
|
|
135
138
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -1571,7 +1571,12 @@ export function useSyncOrchestrator(input) {
|
|
|
1571
1571
|
status: 'error',
|
|
1572
1572
|
statusCode: pullResult.status,
|
|
1573
1573
|
errorMessage: message,
|
|
1574
|
-
requestMs: pullResult.pullRequestMs
|
|
1574
|
+
requestMs: pullResult.pullRequestMs,
|
|
1575
|
+
details: {
|
|
1576
|
+
pages: pullResult.pages,
|
|
1577
|
+
applyMs: pullResult.applyMs,
|
|
1578
|
+
...(pullResult.serverDiagnostics ? { serverPullDiagnostics: pullResult.serverDiagnostics } : {})
|
|
1579
|
+
}
|
|
1575
1580
|
});
|
|
1576
1581
|
if (pullResult.transient || pullResult.hasTransientApplyFailure || isTransientWorkspaceSyncStatus(pullResult.status)) {
|
|
1577
1582
|
scheduleWorkspaceSyncRetry(pullResult.retryAfterMs);
|
|
@@ -1638,7 +1643,12 @@ export function useSyncOrchestrator(input) {
|
|
|
1638
1643
|
eventType: workspaceSyncPhase === 'attach-cloud' ? 'bootstrap' : 'pull',
|
|
1639
1644
|
status: 'success',
|
|
1640
1645
|
changeCount: pullResult.appliedChanges,
|
|
1641
|
-
requestMs: pullResult.pullRequestMs
|
|
1646
|
+
requestMs: pullResult.pullRequestMs,
|
|
1647
|
+
details: {
|
|
1648
|
+
pages: pullResult.pages,
|
|
1649
|
+
applyMs: pullResult.applyMs,
|
|
1650
|
+
...(pullResult.serverDiagnostics ? { serverPullDiagnostics: pullResult.serverDiagnostics } : {})
|
|
1651
|
+
}
|
|
1642
1652
|
});
|
|
1643
1653
|
if (workspaceSyncPhase === 'attach-cloud') {
|
|
1644
1654
|
await refreshWorkspaceSyncMarkdownDocumentsSnapshot();
|
|
@@ -1727,8 +1737,11 @@ export function useSyncOrchestrator(input) {
|
|
|
1727
1737
|
const eventId = String(signal?.eventId || '').trim();
|
|
1728
1738
|
if (eventId && realtimeSuppressedEventIdsRef.current.delete(eventId))
|
|
1729
1739
|
return;
|
|
1730
|
-
if (
|
|
1740
|
+
if (workspacePullInFlightRef.current)
|
|
1731
1741
|
return;
|
|
1742
|
+
if (isWorkspaceRetryPending()) {
|
|
1743
|
+
clearWorkspaceRetry();
|
|
1744
|
+
}
|
|
1732
1745
|
if (realtimePullDebounceRef.current !== null) {
|
|
1733
1746
|
window.clearTimeout(realtimePullDebounceRef.current);
|
|
1734
1747
|
}
|
|
@@ -1742,6 +1755,7 @@ export function useSyncOrchestrator(input) {
|
|
|
1742
1755
|
workspaceCloudSyncEnabled,
|
|
1743
1756
|
workspaceSyncPhase,
|
|
1744
1757
|
isWorkspaceRetryPending,
|
|
1758
|
+
clearWorkspaceRetry,
|
|
1745
1759
|
workspacePullInFlightRef,
|
|
1746
1760
|
pullWorkspaceChangesFromCloud
|
|
1747
1761
|
]);
|
|
@@ -3,19 +3,44 @@ import { act, renderHook, waitFor } from '@testing-library/react';
|
|
|
3
3
|
import { useSyncOrchestrator } from './useSyncOrchestrator';
|
|
4
4
|
class MockWebSocket {
|
|
5
5
|
static instances = [];
|
|
6
|
+
static OPEN = 1;
|
|
7
|
+
static CLOSED = 3;
|
|
6
8
|
url;
|
|
9
|
+
readyState = 0;
|
|
10
|
+
sent = [];
|
|
11
|
+
listeners = new Map();
|
|
7
12
|
constructor(url) {
|
|
8
13
|
this.url = url;
|
|
9
14
|
MockWebSocket.instances.push(this);
|
|
10
15
|
}
|
|
11
16
|
close() {
|
|
12
|
-
|
|
17
|
+
this.readyState = MockWebSocket.CLOSED;
|
|
18
|
+
this.emit('close', {});
|
|
13
19
|
}
|
|
14
|
-
addEventListener() {
|
|
15
|
-
|
|
20
|
+
addEventListener(type, listener) {
|
|
21
|
+
const bucket = this.listeners.get(type) || new Set();
|
|
22
|
+
bucket.add(listener);
|
|
23
|
+
this.listeners.set(type, bucket);
|
|
16
24
|
}
|
|
17
|
-
send() {
|
|
18
|
-
|
|
25
|
+
send(payload) {
|
|
26
|
+
if (typeof payload === 'string') {
|
|
27
|
+
this.sent.push(payload);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
emit(type, event) {
|
|
31
|
+
const bucket = this.listeners.get(type);
|
|
32
|
+
if (!bucket)
|
|
33
|
+
return;
|
|
34
|
+
for (const listener of bucket) {
|
|
35
|
+
listener(event);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
open() {
|
|
39
|
+
this.readyState = MockWebSocket.OPEN;
|
|
40
|
+
this.emit('open', {});
|
|
41
|
+
}
|
|
42
|
+
message(payload) {
|
|
43
|
+
this.emit('message', { data: JSON.stringify(payload) });
|
|
19
44
|
}
|
|
20
45
|
}
|
|
21
46
|
const { ensureCloudWorkspaceReadyForSyncServiceMock, runWorkspacePullSyncServiceMock, runWorkspacePushSyncServiceMock, syncUserGlobalSettingsServiceMock } = vi.hoisted(() => ({
|
|
@@ -206,6 +231,73 @@ describe('useSyncOrchestrator', () => {
|
|
|
206
231
|
expect(MockWebSocket.instances[0]?.url).toBe('wss://api.example.com/taskforce-ws');
|
|
207
232
|
unmount();
|
|
208
233
|
});
|
|
234
|
+
it('lets a realtime signal break transient retry backoff and pull immediately', async () => {
|
|
235
|
+
vi.useFakeTimers();
|
|
236
|
+
vi.stubGlobal('WebSocket', MockWebSocket);
|
|
237
|
+
runWorkspacePullSyncServiceMock
|
|
238
|
+
.mockResolvedValueOnce({
|
|
239
|
+
success: false,
|
|
240
|
+
kind: 'pull_http',
|
|
241
|
+
status: 502,
|
|
242
|
+
transient: true,
|
|
243
|
+
retryAfterMs: 60_000,
|
|
244
|
+
cursor: null,
|
|
245
|
+
pages: 0,
|
|
246
|
+
appliedChanges: 0,
|
|
247
|
+
pullRequestMs: 1,
|
|
248
|
+
applyMs: 0,
|
|
249
|
+
pulledDeleteTaskIds: new Set(),
|
|
250
|
+
pulledActiveUpserts: false,
|
|
251
|
+
pulledArchivedUpserts: false,
|
|
252
|
+
documentDecryptFailures: []
|
|
253
|
+
})
|
|
254
|
+
.mockResolvedValueOnce({
|
|
255
|
+
success: true,
|
|
256
|
+
syncedAt: '2026-03-15T18:05:00.000Z',
|
|
257
|
+
cursor: null,
|
|
258
|
+
pages: 1,
|
|
259
|
+
appliedChanges: 1,
|
|
260
|
+
pullRequestMs: 1,
|
|
261
|
+
applyMs: 0,
|
|
262
|
+
pulledDeleteTaskIds: new Set(),
|
|
263
|
+
pulledActiveUpserts: true,
|
|
264
|
+
pulledArchivedUpserts: false,
|
|
265
|
+
documentDecryptFailures: []
|
|
266
|
+
});
|
|
267
|
+
const { result, unmount } = renderHook((props) => useSyncOrchestrator(props), {
|
|
268
|
+
initialProps: buildInput({ realtimeSyncEnabled: true })
|
|
269
|
+
});
|
|
270
|
+
act(() => {
|
|
271
|
+
result.current.applyWorkspaceSyncStateSnapshot({
|
|
272
|
+
enabled: true,
|
|
273
|
+
phase: 'active',
|
|
274
|
+
pullCursor: null
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
await act(async () => {
|
|
278
|
+
await Promise.resolve();
|
|
279
|
+
await Promise.resolve();
|
|
280
|
+
});
|
|
281
|
+
expect(result.current.workspaceRetryAt).not.toBeNull();
|
|
282
|
+
expect(MockWebSocket.instances).toHaveLength(1);
|
|
283
|
+
expect(runWorkspacePullSyncServiceMock).toHaveBeenCalledTimes(1);
|
|
284
|
+
act(() => {
|
|
285
|
+
MockWebSocket.instances[0]?.open();
|
|
286
|
+
MockWebSocket.instances[0]?.message({
|
|
287
|
+
type: 'taskforce:update',
|
|
288
|
+
workspaceId: 'workspace-local-active'
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
await act(async () => {
|
|
292
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
293
|
+
await Promise.resolve();
|
|
294
|
+
await Promise.resolve();
|
|
295
|
+
});
|
|
296
|
+
expect(runWorkspacePullSyncServiceMock).toHaveBeenCalledTimes(2);
|
|
297
|
+
expect(result.current.workspaceRetryAt).toBeNull();
|
|
298
|
+
expect(result.current.workspaceLastPullAt).toBe('2026-03-15T18:05:00.000Z');
|
|
299
|
+
unmount();
|
|
300
|
+
});
|
|
209
301
|
it('retries transient push failures automatically', async () => {
|
|
210
302
|
runWorkspacePushSyncServiceMock
|
|
211
303
|
.mockResolvedValueOnce({
|
|
@@ -152,10 +152,11 @@ export function useTaskData({ tasks, archivedTasks, setTasks, setArchivedTasks,
|
|
|
152
152
|
console.error('[Taskforce] Failed to fetch tasks:', err);
|
|
153
153
|
}
|
|
154
154
|
finally {
|
|
155
|
-
|
|
155
|
+
const isCurrentRequest = tasksAbortRef.current === abortController;
|
|
156
|
+
if (isCurrentRequest) {
|
|
156
157
|
tasksAbortRef.current = null;
|
|
157
158
|
}
|
|
158
|
-
if (!isSilent)
|
|
159
|
+
if (!isSilent && isCurrentRequest)
|
|
159
160
|
setLoadingTasks(false);
|
|
160
161
|
}
|
|
161
162
|
}, [
|